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
4 changes: 4 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
.git
state
*.key
*.pem
29 changes: 29 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
name: CI

on:
push:
branches: [main]
pull_request:

permissions:
contents: read

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-go@v6
with:
go-version: '1.26'
- run: go vet ./...
- run: go test -race -count=1 ./...
# The local inspection route exists only under this tag; the suite
# must pass with it too, and TestNoRouteReadsAHome must pass without.
- run: go test -count=1 -tags ftwclouddev ./...
- name: FTW Cloud imports only the wire format from srcfl/ftw
run: bash scripts/check-imports.sh
- name: What a box can send never panics the service
run: go test -run XXX -fuzz FuzzBoxMessage -fuzztime 30s ./internal/uplink
- name: Builds for the host
run: GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -o /dev/null ./cmd/ftwcloudd
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
/ftwcloudd
/state/
*.key
*.pem
47 changes: 47 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# FTW Cloud guide

`ftwcloudd` keeps a signed, readable copy of each home that turned FTW Cloud
on, and serves it to that home's phones. Read [README.md](README.md) first,
then the normative
[FTW Cloud protocol](https://github.com/srcfl/ftw/blob/master/docs/protocol-cloud.md)
and [ADR 0008](https://github.com/srcfl/ftw/blob/master/docs/adr/0008-ftw-cloud.md)
in `srcfl/ftw`. Fredrik owns FTW's direction; Sourceful develops and
maintains it.

## Invariants

- **FTW Cloud reads and never controls.** It sends a box only `challenge`,
`hello_ok`, `watch`, `resync` and `purged` (`internal/uplink/downlink.go`).
Commands go from the app to the box over Noise, never through here.
- **One way to a home:** `store.For(principal)`. No listing, no second lookup,
no principal for staff. A new route needs a row in `server.Routes` and must
pass `TestNoRouteReadsAHome`.
- **The home id comes from the key that signed hello** on this connection,
never from a body field. Every later box message must verify with that key.
- **No readings on disk.** Memory holds readings; SQLite holds only the home
id, the box key, the signed roster and tombstones. Nothing logs addresses.
- **TLS ends in `ftwcloudd`.** The handshake is bound to the TLS session; a
terminating proxy breaks it on purpose. A TCP load balancer is fine.
- **Forward the box's bytes unchanged.** The service cannot sign for a box,
so it cannot make data look fresher; keep it that way.
- **Import only `go/protocol` from `srcfl/ftw`.** `scripts/check-imports.sh`
fails on anything else.

## Work

- Check open PRs on the files you touch; an open PR has right of way.
- A wire change is a pair: `srcfl/ftw` (`go/protocol` and its golden vectors)
first, then this repository, moving the pin in `go.mod`. Say so in both PRs.
- Keep the service small. The budget is about 3,000 lines of Go; tiles go
first if it is overrun.
- Nothing is deployed from here without the owner. `deploy/` describes the
intended host; it does not run itself.
- Keep task breakdowns and agent notes out of the repository.

## Check

```bash
go vet ./... && go test -race ./...
go test -tags ftwclouddev ./...
bash scripts/check-imports.sh
```
39 changes: 39 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Contributing to FTW Cloud

Sourceful maintains this repository. Fredrik owns FTW's direction in
[the shared vision](https://github.com/srcfl/ftw/blob/master/VISION.md).
External PRs are welcome, preferably based on an
[issue](https://github.com/srcfl/ftw-cloud/issues). Small fixes need no
separate issue.

This service holds readable household data, so a change to what it stores,
logs, forwards or exposes needs a reason in the PR and a test that shows the
limit holds. Broad ideas may start as short Markdown PRs; mark proposed
behaviour as proposed. A wire change starts in `srcfl/ftw` (`go/protocol`)
and is paired with a PR here. Keep task breakdowns and agent scratch notes
out of the repository.

Work is agentic first: give people and agents a clear problem, scope,
reproducible test steps and actual results. Check agent output before
submitting it. Sourceful reviews and maintains changes. A contribution grants
no merge, release or deployment authority. See
[FTW's contribution guide](https://github.com/srcfl/ftw/blob/master/CONTRIBUTING.md).

## License of contributions

This repository is licensed under the GNU Affero General Public License,
version 3 only (AGPL-3.0-only); see [LICENSE](LICENSE). Contributions use the
same terms. Preserve third-party licenses and notices.

## Developer Certificate of Origin (DCO)

We use the [Developer Certificate of Origin 1.1](https://developercertificate.org/)
instead of a CLA. Sign off every commit with your real name and email:

```bash
git commit -s -m "fix(uplink): close on a frame over the limit"
```

The sign-off confirms that you wrote the contribution or have the right to
submit it under these terms. A sign-off does not assign copyright or grant
commercial relicensing rights.
24 changes: 24 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# ftwcloudd, built from this repository.
#
# The image carries the source it was built from, served at /source, because
# AGPL-3.0 asks that everyone who uses the service over a network can get the
# code running it.

FROM golang:1.26-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# No cgo: the SQLite driver is pure Go, so the binary runs on a static base.
RUN CGO_ENABLED=0 go build -trimpath -ldflags "-s -w" -o /out/ftwcloudd ./cmd/ftwcloudd
RUN mkdir -p /out/share && tar -czf /out/share/source.tar.gz \
--exclude=./.git --exclude=./state . \
&& mkdir -p /out/state

FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/ftwcloudd /usr/local/bin/ftwcloudd
COPY --from=build /out/share/source.tar.gz /usr/share/ftw-cloud/source.tar.gz
COPY --from=build --chown=nonroot:nonroot /out/state /state
USER nonroot
EXPOSE 443
ENTRYPOINT ["/usr/local/bin/ftwcloudd"]
107 changes: 107 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
# FTW Cloud

FTW Cloud keeps a signed, readable copy of each home that turns it on, and
serves it to that home's phones. It reads and never controls. The box opens
the connection, picks and signs every message, and can revoke at any time.

This is `ftwcloudd`, the service. The decision is
[ADR 0008](https://github.com/srcfl/ftw/blob/master/docs/adr/0008-ftw-cloud.md)
and the wire format is the normative
[FTW Cloud protocol](https://github.com/srcfl/ftw/blob/master/docs/protocol-cloud.md),
both in `srcfl/ftw`. Both are proposed; nothing here is deployed.

## What works today (C2)

- A box with FTW Cloud on keeps one WebSocket at `/v1/box`. The service
checks that the box signed `hello` over this TLS session's exporter value,
answers with a `hello_ok` signed by the service key, and then accepts only
messages signed by that box's key.
- Each home's newest message of each class lives in memory, as the exact bytes
the box sent. Readings are dropped an hour after the box goes quiet and are
never written to disk.
- The registry (SQLite) holds the home id, the box key and the signed roster.
A box that turns FTW Cloud off gets its home deleted and a signed receipt.
- `/source` serves the source of the running build, as AGPL-3.0 asks.

Not built yet, with a marked place in the code:

| Step | What | Where |
|---|---|---|
| C3 | The app's Noise session, `/v1/app` | `internal/server`, `store.For` with a `Device` principal |
| C4 | Alerts over APNs, `box.unreachable` | not started |
| C5 | Widget token reads, `/v1/now`, tiles | `internal/server` |
| C7–C8 | Statistics | not started |

## How it holds a home

```
box ──wss, TLS 1.3, signed frames──▶ /v1/box ──▶ store (memory) ◀── store.For(principal)
└────▶ registry (SQLite: key, roster, tombstones)
```

- **The home id is a hash of the box's key.** It comes from the key that
signed `hello`, never from a field anyone sends. A second box cannot claim
a home that is not its own.
- **One way in.** `store.For(principal)` is the only access to a home: a
`Box` principal may write its own home, and a `Device` principal (C3) may
read the classes its role and the owner's grant both allow. There is no
listing and no principal for staff; `TestNoRouteReadsAHome` checks every
route.
- **The service sends five things.** `challenge`, `hello_ok`, `watch`,
`resync` and `purged` are the whole downlink, and none asks the box to act.
The box closes the uplink on anything else.
- **No TLS terminator in front.** Box and service bind the handshake to the
TLS session, so `ftwcloudd` ends TLS itself. A TCP load balancer is fine.
- **Root on the host can read memory.** The privacy notice says so.

## Run it locally

```bash
go build -tags ftwclouddev -o ftwcloudd ./cmd/ftwcloudd
./ftwcloudd keygen -out service.key # prints the public key
./ftwcloudd -listen 127.0.0.1:18443 -state ./state -service-key service.key \
-tls-cert leaf.pem -tls-key leaf.key
```

Build Core from `srcfl/ftw` against it. A release build names no service, so
a local build sets the endpoint, the service's public key and a root for the
local certificate at link time:

```bash
go build -ldflags "\
-X github.com/srcfl/ftw/go/internal/cloudlink.Endpoint=wss://127.0.0.1:18443/v1/box \
-X github.com/srcfl/ftw/go/internal/cloudlink.ServiceKeys=<public key> \
-X github.com/srcfl/ftw/go/internal/cloudlink.DevRootCA=$PWD/ca.pem" ./cmd/ftw
```

Then turn FTW Cloud on from the box itself:

```bash
curl -X POST http://127.0.0.1:8080/api/cloud/grant \
-d '{"scopes":["ftw.live.read"],"policy":"local-test"}'
curl http://127.0.0.1:8080/api/cloud/preview
```

`-tags ftwclouddev` adds `/dev/held?home=<id>` on loopback, which shows what
the service holds so a test can compare it with the preview. Release builds
do not have it.

## Develop

```bash
go vet ./... && go test -race ./...
go test -run XXX -fuzz FuzzBoxMessage -fuzztime 30s ./internal/uplink
bash scripts/check-imports.sh
```

The wire types come from `github.com/srcfl/ftw/go/protocol`, Apache-2.0,
pinned to a commit in `go.mod`. A wire change is a pair of pull requests:
first in `srcfl/ftw` (`go/protocol` and its golden vectors), then here, moving
the pin. The service may import nothing else from `srcfl/ftw`; CI checks that
with `go list -deps`, so no control code can reach this build.

## License

`ftwcloudd` is licensed under the GNU Affero General Public License, version 3
only (AGPL-3.0-only). See [LICENSE](LICENSE) and
[CONTRIBUTING.md](CONTRIBUTING.md).
16 changes: 16 additions & 0 deletions cmd/ftwcloudd/harden_linux.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package main

import (
"log/slog"

"golang.org/x/sys/unix"
)

// harden makes the process not dumpable: no core file, and no ptrace attach
// from another process of the same user. Household readings live in this
// process's memory and nowhere else.
func harden() {
if err := unix.Prctl(unix.PR_SET_DUMPABLE, 0, 0, 0, 0); err != nil {
slog.Warn("could not turn off core dumps", "err", err)
}
}
6 changes: 6 additions & 0 deletions cmd/ftwcloudd/harden_other.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
//go:build !linux

package main

// harden has nothing to set outside Linux, where the service runs.
func harden() {}
Loading
Loading