diff --git a/.changeset/cloud-tun-cap.md b/.changeset/cloud-tun-cap.md new file mode 100644 index 00000000..4584eca2 --- /dev/null +++ b/.changeset/cloud-tun-cap.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +A box whose FTW Cloud link carries the phones' sessions says so in `hello_ok.caps` as `cloud.tun`, a new registry capability, so a phone uses that path only when the box takes it. The registry also names the carrier state `tunnel` for a session that FTW Cloud carries. diff --git a/contract/registry.yaml b/contract/registry.yaml index 0cfdaecc..63b4de55 100644 --- a/contract/registry.yaml +++ b/contract/registry.yaml @@ -103,6 +103,10 @@ capabilities: # only and offers no FTW Cloud. It says nothing about whether the owner # turned FTW Cloud on; the box's grant decides that. - cloud.link + # The box takes its app session through FTW Cloud, end to end, as `tun` + # (docs/protocol-cloud.md, Tunnel, in srcfl/ftw). Absent means a phone + # reaches the box through the relay, even when it knows the home id. + - cloud.tun # The box can take a watch: it opens the relay's exchange socket and mints # watch codes. Absent means the phone offers no Add watch. The wire is # docs/watch-exchange.md in srcfl/ftw-webapp. @@ -274,7 +278,9 @@ client_errors: # the two into one enum cannot express "connected, but the inverter is quiet". # --------------------------------------------------------------------------- source_states: [live, lagging, stale, down, never] -carrier_states: [webrtc, relay, cloud, cache, none] +# tunnel is the box's own session carried by FTW Cloud, which cannot read it. +# cloud is the box-signed copy FTW Cloud holds and can read. +carrier_states: [webrtc, relay, tunnel, cloud, cache, none] # --------------------------------------------------------------------------- # History resolutions backed by what the box actually stores. diff --git a/docs/protocol-cloud.md b/docs/protocol-cloud.md index eb37ccac..4558fed5 100644 --- a/docs/protocol-cloud.md +++ b/docs/protocol-cloud.md @@ -698,6 +698,10 @@ Noise session, in the app protocol's `hello_ok.cloud`. added later has no name on the phone until its next session with the box. - The box lists `cloud.link` in `hello_ok.caps` when its build can run the uplink. A build that names no service does not. +- It also lists `cloud.tun` when its link carries the phones' own sessions + as [`tun`](#tunnel). A phone sends its box session through the service + only to a box that said so at its last hello, or to one it has never + reached when a v3 QR named the home. Otherwise it uses the relay. ## Tunnel @@ -1106,6 +1110,8 @@ to the app. | `cloud_purposes` | List: `serve`, `stats` | `registry.yaml`, new block | | `cloud.link` | Capability: the box can run the FTW Cloud uplink. Absent means the app uses the relay only and offers no FTW Cloud | `registry.yaml`, `capabilities` | | `cloud` | Carrier state, between `relay` and `cache` | `registry.yaml`, `carrier_states` | +| `cloud.tun` | Capability: the box takes its app session through the service as `tun`. Absent means the phone uses the relay, even when it knows the home id | `registry.yaml`, `capabilities` | +| `tunnel` | Carrier state, between `relay` and `cloud`: the box's own session, carried by the service | `registry.yaml`, `carrier_states` | | `cloud.granted`, `cloud.revoked` | Push kinds, sent to every owner when FTW Cloud is turned on or off | `push-catalogue.yaml` | Class names, field names, close codes and the signing tags are not registry diff --git a/go/cmd/ftw/cloud_link.go b/go/cmd/ftw/cloud_link.go index 9fb18ed8..ca4e34eb 100644 --- a/go/cmd/ftw/cloud_link.go +++ b/go/cmd/ftw/cloud_link.go @@ -392,10 +392,16 @@ func (c *cloudSession) load() *cloudlink.Link { } // caps adds cloud.link to an app session's capabilities when this build can -// run the uplink. A build that names no service says nothing. +// run the uplink, and cloud.tun when the link also carries the phones' own +// sessions. A build that names no service says nothing. func (c *cloudSession) caps(caps []string) []string { - if link := c.load(); link != nil && link.Available() { - return append(slices.Clip(caps), appproto.CapCloudLink) + link := c.load() + if link == nil || !link.Available() { + return caps + } + caps = append(slices.Clip(caps), appproto.CapCloudLink) + if link.TakesTun() { + caps = append(caps, appproto.CapCloudTun) } return caps } diff --git a/go/cmd/ftw/cloud_link_test.go b/go/cmd/ftw/cloud_link_test.go index d9d27184..08aa2459 100644 --- a/go/cmd/ftw/cloud_link_test.go +++ b/go/cmd/ftw/cloud_link_test.go @@ -164,8 +164,53 @@ func TestAppSessionsHearOfFTWCloudOnlyWhenItCanRun(t *testing.T) { t.Fatalf("source %q named %q", id, name) } } + if slices.Contains(caps, appproto.CapCloudTun) { + t.Fatalf("cloud.tun from a link that takes no tun: %v", caps) + } } +// A phone hears cloud.tun only from a box whose link carries its session, so +// a phone that knows the home id never waits on a tunnel the box cannot take. +func TestAppSessionsHearOfTheTunnelOnlyWhenTheLinkTakesIt(t *testing.T) { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + keys := []*ecdsa.PublicKey{&key.PublicKey} + open := func(tunnel cloudlink.Tunnel, keys []*ecdsa.PublicKey) []string { + st, err := state.Open(filepath.Join(t.TempDir(), "state.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { st.Close() }) + link, err := cloudlink.New(cloudlink.Options{ + Site: quietCloudSite{}, Roster: noPhones{}, KV: st, Tunnel: tunnel, + KeyPath: filepath.Join(t.TempDir(), "cloud.key"), ServiceKeys: keys, + Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + }) + if err != nil { + t.Fatal(err) + } + c := &cloudSession{} + c.bind(link) + return c.caps(appproto.DefaultCaps()) + } + if caps := open(nil, keys); slices.Contains(caps, appproto.CapCloudTun) { + t.Fatalf("no tunnel: %v", caps) + } + if caps := open(nopTunnel{}, []*ecdsa.PublicKey{}); slices.Contains(caps, appproto.CapCloudTun) || slices.Contains(caps, appproto.CapCloudLink) { + t.Fatalf("a build that names no service: %v", caps) + } + if caps := open(nopTunnel{}, keys); !slices.Contains(caps, appproto.CapCloudTun) || !slices.Contains(caps, appproto.CapCloudLink) { + t.Fatalf("a link with a tunnel: %v", caps) + } +} + +type nopTunnel struct{} + +func (nopTunnel) Deliver(cloudwire.Tun) {} +func (nopTunnel) Drop() {} + // FTW Cloud's live body is the same field set the app session streams, and // each phase follows the site meter's freshness there too. The fuse is // config, so it names no driver. diff --git a/go/internal/appproto/protocol.go b/go/internal/appproto/protocol.go index 26783d47..66261447 100644 --- a/go/internal/appproto/protocol.go +++ b/go/internal/appproto/protocol.go @@ -224,6 +224,7 @@ const ( CapPriceSpot = protocol.CapPriceSpot CapApiPassthrough = protocol.CapApiPassthrough CapCloudLink = protocol.CapCloudLink + CapCloudTun = protocol.CapCloudTun CapCmdExchange = protocol.CapCmdExchange ScopeLiveRead = protocol.ScopeLiveRead ScopeHistoryRead = protocol.ScopeHistoryRead diff --git a/go/internal/cloudlink/link.go b/go/internal/cloudlink/link.go index 3c5d26fd..3ee70ad1 100644 --- a/go/internal/cloudlink/link.go +++ b/go/internal/cloudlink/link.go @@ -794,6 +794,10 @@ func (l *Link) Home() string { // uplink at all. Release builds without service keys cannot. func (l *Link) Available() bool { return len(l.opts.ServiceKeys) > 0 } +// TakesTun reports whether the link carries the phones' own sessions as tun, +// so a phone that knows the home id can reach the box without the relay. +func (l *Link) TakesTun() bool { return l.Available() && l.opts.Tunnel != nil } + // SessionInfo is what a phone needs, over its Noise session with the box, to // reach this home through FTW Cloud: the home id, the key that signs the // readable copy, the boot that anchors its times and names for the opaque diff --git a/go/protocol/contract_gen.go b/go/protocol/contract_gen.go index f808a477..0dc9e9ef 100644 --- a/go/protocol/contract_gen.go +++ b/go/protocol/contract_gen.go @@ -125,6 +125,7 @@ const ( CapPriceSpot = "price.spot" CapApiPassthrough = "api.passthrough" CapCloudLink = "cloud.link" + CapCloudTun = "cloud.tun" CapCmdExchange = "cmd.exchange" ) @@ -147,6 +148,7 @@ var AllCapabilities = []string{ CapPriceSpot, CapApiPassthrough, CapCloudLink, + CapCloudTun, CapCmdExchange, }