Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions RELEASE_NOTES_2026.09.1.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,45 @@ Files are hashed once at discovery, and the ledger survives restarts, so a spoke

This is the **manual** form, and it is OSS. The automatic scheduled agent will be an Enterprise feature.

### Air-gap bundles

Some spokes have no network path at all — a submarine, a classified facility, a vehicle whose data comes off on a physical drive. For those, a spoke writes a **signed bundle** to removable media:

```toml
[edge_sync.spoke.bundle]
enabled = true # default false
allowed_dirs = ["/mnt/usb"] # REQUIRED; an empty list refuses every export
max_files = 10000 # per bundle
max_bytes = 68719476736 # 64 GiB per bundle
```

`edge_sync.spoke.bundle.enabled` is **independent of `edge_sync.spoke.enabled`**: a fully air-gapped spoke exports bundles and never runs the network path, so it needs no `hub_url` at all. A spoke that has both intermittent connectivity and a drive courier can run both.

```bash
curl -X POST https://edge.local:8000/api/v1/spoke-sync/export \
-H "Authorization: Bearer $ARC_TOKEN" \
-d '{"path": "/mnt/usb"}'
```

A bundle is a **directory**, not an archive:

```
bundle-submarine-01-06FXVSQXJ2C0EBDFDQ9D24S1E8/
manifest.json signed header: bundle ID, spoke, hub, entry digest, MAC
entries.jsonl one JSON object per file
data/ the Parquet files, under their original paths
```

Chosen over a tar for two reasons. **Resume is free** — an interrupted copy leaves whole files, and the manifest's per-file SHA identifies exactly which landed, so resuming re-copies the mismatches rather than restarting. And it is **auditable**: someone has to inspect what crosses an air gap, and `ls` plus `sha256sum entries.jsonl` answers that without opening anything.

Signing uses a third HMAC family (`sync-bundle`) alongside the two online ones. The canonical input is length-prefixed, so a bundle MAC cannot validate on `/sync/file` or `/sync/reconcile`, nor the reverse. Unlike those families the bundle MAC carries **no timestamp window** — a bundle legitimately crosses an air gap over weeks, and a freshness check would reject exactly the artifacts this transport exists to carry. Replay protection is the hub's dedup ledger, which arrives with the import side.

**Where a bundle may be written is explicit.** Every other Arc write path is confined to the storage root by its backend; a USB mount is outside that root by definition, so `allowed_dirs` is required and an empty list refuses every export. Paths are resolved through symlinks before the check, compared at a path-segment boundary, and Arc **refuses to export into its own storage root** — the next discovery pass would otherwise find the exported copies and queue them for sync.

Exported files move to a new ledger state, `exported`, rather than staying pending. Without it a capped export would re-take the newest files every time and the oldest would never leave the box. They are reported separately from `pending` in `/status` but still counted in `pending_bytes`, because a file on a drive in transit has not arrived. If a drive is lost, `POST /api/v1/spoke-sync/export/{bundle_id}/revert` returns just that bundle's files to pending.

The hub-side import, and the acknowledgment that advances `exported` to `synced`, land next.

## Security hardening

### Strip client-controlled forwarding headers at the inter-node boundary (CVE-2026-45045 class)
Expand Down
128 changes: 101 additions & 27 deletions cmd/arc/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -2114,7 +2114,10 @@ func main() {
// The secret comes from ARC_EDGE_SYNC_SPOKE_SECRET only; config load
// already refused a config-file secret and verified every required field,
// so by here the configuration is known good.
if cfg.EdgeSync.Spoke.Enabled {
// Either role independently: a spoke with intermittent connectivity runs
// the agent, a fully air-gapped one only exports bundles, and a spoke that
// does both runs both. The shared ledger is why they live in one block.
if cfg.EdgeSync.Spoke.Enabled || cfg.EdgeSync.Spoke.Bundle.Enabled {
spokeLogger := logger.Get("edgesync-spoke")

// The ledger shares the auth database, like the hub index. Reuse the
Expand All @@ -2135,40 +2138,111 @@ func main() {
log.Fatal().Err(err).Msg("Failed to create the edge sync ledger; refusing to start")
}

spokeTransport, err := edgesync.NewHTTPTransport(edgesync.HTTPTransportConfig{
BaseURL: cfg.EdgeSync.Spoke.HubURL,
SpokeID: cfg.EdgeSync.Spoke.SpokeID,
Secret: cfg.EdgeSync.Spoke.Secret,
})
if err != nil {
log.Fatal().Err(err).Msg("Failed to create the edge sync transport; refusing to start")
}

syncAgent, err := edgesync.NewAgent(edgesync.AgentConfig{
Ledger: spokeLedger,
Transport: spokeTransport,
Backend: storageBackend,
HubID: cfg.EdgeSync.Spoke.HubID,
SpokeID: cfg.EdgeSync.Spoke.SpokeID,
MaxAttempts: cfg.EdgeSync.Spoke.MaxAttempts,
MaxConcurrent: cfg.EdgeSync.Spoke.MaxConcurrent,
BatchSize: cfg.EdgeSync.Spoke.BatchSize,
Logger: spokeLogger,
})
if err != nil {
log.Fatal().Err(err).Msg("Failed to create the edge sync agent; refusing to start")
// The network agent. Skipped entirely on a fully air-gapped spoke,
// which has no hub URL to build a transport from.
var syncAgent *edgesync.Agent
if cfg.EdgeSync.Spoke.Enabled {
spokeTransport, err := edgesync.NewHTTPTransport(edgesync.HTTPTransportConfig{
BaseURL: cfg.EdgeSync.Spoke.HubURL,
SpokeID: cfg.EdgeSync.Spoke.SpokeID,
Secret: cfg.EdgeSync.Spoke.Secret,
})
if err != nil {
log.Fatal().Err(err).Msg("Failed to create the edge sync transport; refusing to start")
}

syncAgent, err = edgesync.NewAgent(edgesync.AgentConfig{
Ledger: spokeLedger,
Transport: spokeTransport,
Backend: storageBackend,
HubID: cfg.EdgeSync.Spoke.HubID,
SpokeID: cfg.EdgeSync.Spoke.SpokeID,
MaxAttempts: cfg.EdgeSync.Spoke.MaxAttempts,
MaxConcurrent: cfg.EdgeSync.Spoke.MaxConcurrent,
BatchSize: cfg.EdgeSync.Spoke.BatchSize,
Logger: spokeLogger,
})
if err != nil {
log.Fatal().Err(err).Msg("Failed to create the edge sync agent; refusing to start")
}
}

// Air-gap export, independent of the network agent above.
var bundleExporter *edgesync.Exporter
if cfg.EdgeSync.Spoke.Bundle.Enabled {
// The storage root is passed so the policy can refuse it outright:
// exporting into it would make the next discovery pass find the
// exported copies and queue them for sync.
// Only meaningful for a local backend; for S3/Azure there is no
// local root to protect and the policy simply skips that check.
localRoot := ""
if cfg.Storage.Backend == "local" {
localRoot = cfg.Storage.LocalPath
}
policy, err := edgesync.NewDestinationPolicy(
cfg.EdgeSync.Spoke.Bundle.AllowedDirs, localRoot)
if err != nil {
log.Fatal().Err(err).Msg("Failed to resolve the bundle destination policy; refusing to start")
}

bundleWriter, err := edgesync.NewBundleWriter(edgesync.BundleWriterConfig{
Backend: storageBackend,
SpokeID: cfg.EdgeSync.Spoke.SpokeID,
HubID: cfg.EdgeSync.Spoke.HubID,
Secret: cfg.EdgeSync.Spoke.Secret,
Logger: spokeLogger,
})
if err != nil {
log.Fatal().Err(err).Msg("Failed to create the bundle writer; refusing to start")
}

// Its own discoverer: an air-gapped spoke runs no agent, so this
// is the only thing that ever populates its ledger.
bundleDiscoverer, err := edgesync.NewDiscoverer(
spokeLedger, storageBackend, cfg.EdgeSync.Spoke.HubID, spokeLogger)
if err != nil {
log.Fatal().Err(err).Msg("Failed to create the bundle discoverer; refusing to start")
}

bundleExporter, err = edgesync.NewExporter(edgesync.ExporterConfig{
Ledger: spokeLedger,
Writer: bundleWriter,
Policy: policy,
Discoverer: bundleDiscoverer,
HubID: cfg.EdgeSync.Spoke.HubID,
MaxFiles: cfg.EdgeSync.Spoke.Bundle.MaxFiles,
MaxBytes: cfg.EdgeSync.Spoke.Bundle.MaxBytes,
Logger: spokeLogger,
})
if err != nil {
log.Fatal().Err(err).Msg("Failed to create the bundle exporter; refusing to start")
}
}

spokeHandler, err := api.NewEdgeSyncSpokeHandler(syncAgent, authManager, spokeLogger)
spokeHandler, err := api.NewEdgeSyncSpokeHandler(syncAgent, bundleExporter, authManager, spokeLogger)
if err != nil {
log.Fatal().Err(err).Msg("Failed to create the edge sync spoke handler; refusing to start")
}
spokeHandler.RegisterRoutes(server.GetApp())

spokeLogger.Info().
Str("hub_url", cfg.EdgeSync.Spoke.HubURL).
// Reports what is actually enabled: an air-gap-only spoke has no hub
// URL and no /run endpoint, so claiming both would send an operator
// looking for a route that returns 503.
evt := spokeLogger.Info().
Str("spoke_id", cfg.EdgeSync.Spoke.SpokeID).
Msg("Edge sync spoke enabled; trigger a pass with POST /api/v1/spoke-sync/run")
Bool("network_sync", syncAgent != nil).
Bool("bundle_export", bundleExporter != nil)
if syncAgent != nil {
evt = evt.Str("hub_url", cfg.EdgeSync.Spoke.HubURL)
}
switch {
case syncAgent != nil && bundleExporter != nil:
evt.Msg("Edge sync spoke enabled; POST /api/v1/spoke-sync/run to sync, /export for an air-gap bundle")
case syncAgent != nil:
evt.Msg("Edge sync spoke enabled; trigger a pass with POST /api/v1/spoke-sync/run")
default:
evt.Msg("Edge sync air-gap export enabled; write a bundle with POST /api/v1/spoke-sync/export")
}
}

// Register TLE handler (streaming TLE ingestion)
Expand Down
Loading