diff --git a/.gitea/workflows/agent-release.yml b/.gitea/workflows/agent-release.yml index e7e4526..f3ce83d 100644 --- a/.gitea/workflows/agent-release.yml +++ b/.gitea/workflows/agent-release.yml @@ -9,10 +9,21 @@ jobs: build: runs-on: ubuntu-docker container: node:26 + env: + GOPRIVATE: gitea.hostxtra.co.uk/* steps: - name: Checkout uses: actions/checkout@v4 + # vantage-shared is a private module, so the Go builds below cannot + # resolve it without a credential. + - name: Write the module fetch credential + run: | + umask 077 + printf 'machine gitea.hostxtra.co.uk\nlogin %s\npassword %s\n' \ + "${{ secrets.REGISTRY_USER }}" "${{ secrets.RELEASE_TOKEN }}" \ + > "$HOME/.netrc" + - name: Set up Go uses: actions/setup-go@v5 with: @@ -57,10 +68,20 @@ jobs: msi: needs: build runs-on: windows-2022 + env: + GOPRIVATE: gitea.hostxtra.co.uk/* steps: - name: Checkout uses: actions/checkout@v4 + # Same private-module credential as the build job, in the file + # Windows Go looks for: _netrc in the profile directory, not .netrc. + - name: Write the module fetch credential + shell: pwsh + run: | + "machine gitea.hostxtra.co.uk`nlogin ${{ secrets.REGISTRY_USER }}`npassword ${{ secrets.RELEASE_TOKEN }}" | + Out-File -Encoding ascii "$env:USERPROFILE\_netrc" + - name: Set up Go uses: actions/setup-go@v5 with: diff --git a/.gitea/workflows/server-deploy.yml b/.gitea/workflows/server-deploy.yml index 3a7b859..01da71a 100644 --- a/.gitea/workflows/server-deploy.yml +++ b/.gitea/workflows/server-deploy.yml @@ -71,22 +71,22 @@ jobs: fi } - # The three Go images here build from the repo root and - # COPY shared/ plus their own directory, so shared/ rebuilds - # all three. vantagectl also depends on shared/ but is NOT - # built here: it is a released tool, so its image is built and - # version-tagged by vantagectl-release.yml on a vantagectl/v* - # tag. A shared/ change therefore reaches it at the next - # release rather than on the next push to main, which is the - # point — an operator restoring a database should be running a - # version they can name, not whatever main built last night. - # proto/ is in server's list as insurance: the - # generated pb is committed under server/, but a proto change - # that someone regenerates in the same push should not depend - # on that ordering. - flag server '^(server/|shared/|proto/|default_steps/|go\.work)' - flag sitesvc '^(sitesvc/|shared/|go\.work)' - flag admin '^(admin/|shared/|go\.work)' + # shared/ is gone from this repository: it is the private + # module gitea.hostxtra.co.uk/vantage/vantage-shared, pinned + # per service in its own go.mod. A change over there reaches + # a service when somebody bumps that pin, which is a commit + # under the service's own directory and so already matches + # below. There is no longer a directory whose change fans out + # to three images, and no longer a way to ship a service + # against a shared/ it was never built with. + # + # proto/ stays in server's list as insurance: the hand-written + # pb now lives in vantage-shared, but a proto change made here + # in the same push should not depend on the pin being bumped + # in that same push to trigger a rebuild. + flag server '^(server/|proto/|default_steps/|go\.work)' + flag sitesvc '^(sitesvc/|go\.work)' + flag admin '^(admin/|go\.work)' # The three Next images and the docs site use their own # directory as the build context, so nothing outside it can @@ -96,6 +96,17 @@ jobs: flag adminsite '^adminsite/' flag docsite '^docsite/' + # vantage-shared is private, so every Go build below needs a + # credential for it. A netrc is written once here rather than a + # token being passed as a build arg, which would survive in the + # builder layer's history. + - name: Write the module fetch credential + run: | + umask 077 + printf 'machine gitea.hostxtra.co.uk\nlogin %s\npassword %s\n' \ + "${{ secrets.REGISTRY_USER }}" "${{ secrets.RELEASE_TOKEN }}" \ + > "$HOME/.netrc" + - name: Log in to registry run: | echo "${{ secrets.RELEASE_TOKEN }}" | \ @@ -109,13 +120,22 @@ jobs: go-version: "1.26" cache: true cache-dependency-path: server/go.sum + env: + GOPRIVATE: gitea.hostxtra.co.uk/* - name: Verify the OpenAPI document is current if: steps.changed.outputs.server == 'true' + env: + GOPRIVATE: gitea.hostxtra.co.uk/* run: | go install github.com/swaggo/swag/v2/cmd/swag@v2.0.0-rc5 cd server - swag init --generalInfo cmd/main.go --dir ./,../shared \ + # swag reads Go source, not the import graph, so it needs the + # shared module's files on disk. They are in the module cache + # now rather than at ../shared, and the cache is read-only, + # which swag does not mind. + SHARED_DIR="$(go list -m -f '{{.Dir}}' gitea.hostxtra.co.uk/vantage/vantage-shared)" + swag init --generalInfo cmd/main.go --dir "./,$SHARED_DIR" \ --output internal/api/docs --outputTypes json --v3.1 mv -f internal/api/docs/swagger.json internal/api/docs/openapi.json git diff --exit-code internal/api/docs/openapi.json @@ -124,8 +144,8 @@ jobs: if: steps.changed.outputs.server == 'true' run: | IMAGE="${{ vars.DOCKER_HOST }}/${{ github.repository_owner }}/vantage/server:latest" - # Root context: server depends on the shared module. - docker build -t "$IMAGE" -f server/Dockerfile . + docker build --secret id=netrc,src="$HOME/.netrc" \ + -t "$IMAGE" -f server/Dockerfile . docker push "$IMAGE" - name: Build and push web image @@ -154,16 +174,16 @@ jobs: if: steps.changed.outputs.sitesvc == 'true' run: | IMAGE="${{ vars.DOCKER_HOST }}/${{ github.repository_owner }}/vantage/sitesvc:latest" - # Root context: sitesvc depends on the shared module. - docker build -t "$IMAGE" -f sitesvc/Dockerfile . + docker build --secret id=netrc,src="$HOME/.netrc" \ + -t "$IMAGE" -f sitesvc/Dockerfile sitesvc/ docker push "$IMAGE" - name: Build and push admin image if: steps.changed.outputs.admin == 'true' run: | IMAGE="${{ vars.DOCKER_HOST }}/${{ github.repository_owner }}/vantage/admin:latest" - # Root context: admin depends on the shared module. - docker build -t "$IMAGE" -f admin/Dockerfile . + docker build --secret id=netrc,src="$HOME/.netrc" \ + -t "$IMAGE" -f admin/Dockerfile admin/ docker push "$IMAGE" - name: Build and push adminsite image diff --git a/.gitea/workflows/vantagectl-release.yml b/.gitea/workflows/vantagectl-release.yml index 716941c..019a4d2 100644 --- a/.gitea/workflows/vantagectl-release.yml +++ b/.gitea/workflows/vantagectl-release.yml @@ -9,10 +9,21 @@ jobs: build: runs-on: ubuntu-docker container: node:26 + env: + GOPRIVATE: gitea.hostxtra.co.uk/* steps: - name: Checkout uses: actions/checkout@v4 + # vantage-shared is a private module, so the Go builds below cannot + # resolve it without a credential. + - name: Write the module fetch credential + run: | + umask 077 + printf 'machine gitea.hostxtra.co.uk\nlogin %s\npassword %s\n' \ + "${{ secrets.REGISTRY_USER }}" "${{ secrets.RELEASE_TOKEN }}" \ + > "$HOME/.netrc" + - name: Set up Go uses: actions/setup-go@v5 with: @@ -86,6 +97,16 @@ jobs: echo "VERSION=${VERSION}" >> $GITHUB_OUTPUT echo "IMAGE_TAG=${VERSION#v}" >> $GITHUB_OUTPUT + # The image build resolves the private vantage-shared module, so + # it needs a credential of its own — this job does not share the + # build job's filesystem. + - name: Write the module fetch credential + run: | + umask 077 + printf 'machine gitea.hostxtra.co.uk\nlogin %s\npassword %s\n' \ + "${{ secrets.REGISTRY_USER }}" "${{ secrets.RELEASE_TOKEN }}" \ + > "$HOME/.netrc" + - name: Log in to registry run: | echo "${{ secrets.RELEASE_TOKEN }}" | \ @@ -98,14 +119,15 @@ jobs: IMAGE_TAG: ${{ steps.version.outputs.IMAGE_TAG }} run: | REPO="${{ vars.DOCKER_HOST }}/${{ github.repository_owner }}/vantage/vantagectl" - # Root context: vantagectl depends on the shared module through - # a replace directive, so the build needs shared/ alongside it. # VERSION is passed through so `vantagectl --version` inside the # image reports the tag it was built from rather than "dev". + # The context is vantagectl/ now that shared/ is an external + # module rather than a sibling directory. docker build \ --build-arg VERSION="${VERSION}" \ + --secret id=netrc,src="$HOME/.netrc" \ -t "${REPO}:${IMAGE_TAG}" \ -t "${REPO}:latest" \ - -f vantagectl/Dockerfile . + -f vantagectl/Dockerfile vantagectl/ docker push "${REPO}:${IMAGE_TAG}" docker push "${REPO}:latest" diff --git a/CLAUDE.md b/CLAUDE.md index 23e7369..d5621ed 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -99,17 +99,47 @@ vantage/ │ ├── src/css/custom.css # site/'s tokens, copied, mapped onto --ifm-* │ ├── sidebars.ts # authored by hand, not autogenerated │ └── nginx.conf # serves the build under /docs -├── shared/ # imported by server, sitesvc and admin -│ ├── mail/ # the one email system: transport + tmpl templates -│ ├── license/ # payload, sign, verify, trusted keys, plans -│ ├── models/ # Instance, User, Settings -│ └── cmd/lkctl/ # issue and inspect licences by hand ├── proto/vantage/v1/vantage.proto ├── installer/ # Windows: setup.ps1, nssm.exe, WiX .wxs ├── deploy/ # docker-compose.yml, agent.service └── .gitea/workflows/ # agent-release.yml, server-deploy.yml ``` +**`shared/` is not in this repository.** It is the private module +`gitea.hostxtra.co.uk/vantage/vantage-shared`, and it holds `mail/` (the one +email system: transport plus templates), `license/` (payload, sign, verify, +trusted keys, plans), `models/` (Instance, User, Settings), `provision/`, +`backup/`, `cryptobox/`, `indexes/`, `grpc/pb` + `grpc/codec`, and +`cmd/lkctl/`. Five modules here depend on it — `server`, `agent`, `admin`, +`sitesvc`, `vantagectl` — each pinning a version in its own `go.mod`. It was a +directory in this repository until it was extracted with its history; the +`replace ../shared` directives and the `./shared` entry in `go.work` are gone +with it. + +**A version pin is now the coupling, and that is the point.** While it was a +directory, every service in a given commit built against exactly one `shared/`, +and a change there rebuilt three images at once whether or not they were ready +for it. Now a service moves when somebody bumps its pin, which is a commit under +that service's own directory — so the existing per-directory rebuild triggers +already cover it, and there is no longer any way to ship a service against a +`shared/` it was never built against. The cost is the obvious one: a fix in +`vantage-shared` is live nowhere until each consumer's pin is bumped, and +nothing in this repository will remind you. + +Every Go build now needs a credential for it — `GOPRIVATE=gitea.hostxtra.co.uk/*` +plus a netrc. CI writes one per job from `REGISTRY_USER` + `RELEASE_TOKEN` +(**that token needs read access to the `vantage` org, not only `mrhid6`**), and +the four Go Dockerfiles take it as a **BuildKit secret** rather than a build +arg, because an arg survives in the builder layer's history and this one is a +Gitea token. Locally, either a netrc or +`git config --global url."git@gitea.hostxtra.co.uk:".insteadOf https://gitea.hostxtra.co.uk/`. + +**Three build contexts shrank as a result.** `admin`, `sitesvc` and `vantagectl` +build from their own directory now; only `server` still builds from the +repository root, and only because its runtime stage copies `default_steps/`. +One side effect worth knowing: `admin/.dockerignore` was inert while the context +was the root, and is live now. + --- ## Subsystems @@ -460,12 +490,15 @@ drifted: the agent's `UnimplementedVantageServer` was three methods stale and carried no `ReportWorkloads` at all. The agent links the server half as dead code, which the linker drops. -This makes `agent` the **fifth** consumer of `shared/`, and the second one CI -does not rebuild on a push to main: like `vantagectl`, the agent image is cut by -`agent-release.yml` on an `agent/v*` tag, so a wire change reaches the server at -the next push and the fleet at the next agent release. That gap existed before -too — it is just now a compile error in the same tree rather than a silent -mismatch between two copies that both compiled. +This makes `agent` the **fifth** consumer of `shared/`, and the wire contract now +lives outside this repository entirely: a message added to `vantage-shared` is +not a message either side has until its pin is bumped. What that buys is that +the mismatch is a compile error rather than two copies that both compiled and +disagreed on the wire. What it costs is ordering — a wire change needs a +`vantage-shared` release, then a pin bump in `server/` (live at the next push to +main) and a pin bump in `agent/` (live at the next `agent/v*` tag). The +server-ahead-of-fleet gap existed before too; it is now explicit in two `go.mod` +files instead of implicit in a shared directory. ### Status pages @@ -645,8 +678,8 @@ mid-write. Reconstructing the key document from JSON also lost compound key order, which is significant. **`backup.ciphertextFields` mirrors `server/internal/models` by hand.** -`shared/` is a separate module and `models` is under `server/internal`, so -`shared/backup` cannot import it; the map naming each collection's `*_enc` +`shared/` is a separate module — a separate *repository* now — and `models` is +under `server/internal`, so `shared/backup` cannot import it; the map naming each collection's `*_enc` fields (`keys`, `secrets`, `auth_providers`, `console_sessions`) must change in the same commit as any of those bson tags, the same hazard as `web/lib/targets.ts` and `services.MaxWorkloadLogLines`. Wrong field names are @@ -668,11 +701,10 @@ temporary directory before verifying its checksums, and a scratch image has no `server`, but here it would break every restore rather than only vulnerability scanning. -**`shared/` reaches four Go images, but only three of them from -`server-deploy.yml`** (`server`, `sitesvc`, `admin`). The `vantagectl` image is -built by `vantagectl-release.yml` on a `vantagectl/v*` tag instead, so a -`shared/` change reaches it at the next release rather than the next push to -main — see the CI section below. +**Nothing in this repository rebuilds when `vantage-shared` changes.** It is an +external module, so a `shared/backup` fix reaches `vantagectl` when somebody +bumps `vantagectl/go.mod` and cuts a `vantagectl/v*` tag, and reaches nothing +else until its pin moves too — see the CI section below. ### API tokens and OpenAPI @@ -1343,24 +1375,30 @@ cd /opt/vantage && docker compose -f docker-compose.yml -f docker-compose.site.y **Each image only rebuilds when its own inputs changed.** A `git diff` against `github.event.before` decides, which is why the checkout uses `fetch-depth: 0` — the default shallow clone has one commit and nothing to diff — and why `git` is installed in the `docker:dind` container. The mapping follows the build contexts exactly: -| Image | Rebuilds when | -| ---------------------------- | ----------------------------------------- | -| `server` | `server/`, `shared/`, `proto/`, `go.work` | -| `admin` | `admin/`, `shared/`, `go.work` | -| `sitesvc` | `sitesvc/`, `shared/`, `go.work` | -| `web` · `site` · `adminsite` · `docsite` | their own directory only | +| Image | Rebuilds when | +| ---------------------------- | -------------------------------- | +| `server` | `server/`, `proto/`, `go.work` | +| `admin` | `admin/`, `go.work` | +| `sitesvc` | `sitesvc/`, `go.work` | +| `web` · `site` · `adminsite` · `docsite` | their own directory only | -`shared/` fans out to **three** images here (`server`, `sitesvc`, `admin`) -because each of their Dockerfiles copies `shared/` from a root context — **if a -fourth service ever imports `shared/`, add it to that list or it will ship -stale**. `vantagectl` also imports `shared/` and is the exception: it is built -by `vantagectl-release.yml`, so a `shared/` fix reaches it only when someone -cuts a `vantagectl/v*` tag. That is deliberate — an operator restoring a -database should be running a version they can name — but it does mean a -`shared/backup` fix is not live until it is released. `agent` is the same shape -of exception since `shared/grpc/pb` moved there: it is built by -`agent-release.yml` on an `agent/v*` tag, so a wire change lands on the server -at the next push to main and on the fleet only at the next agent release. A change to the workflow file rebuilds everything, since +**No path in this table names `shared/` any more**, and no fan-out rule replaces +it: `vantage-shared` is an external module pinned per service, so a service +rebuilds when its own `go.mod` moves, which its own directory pattern already +matches. What that removes is the failure where a `shared/` edit rebuilt three +images and one of them was not ready; what it adds is that nothing here reminds +you a pin is stale. + +Every Go build in these workflows writes a netrc from `REGISTRY_USER` + +`RELEASE_TOKEN` before it runs, and sets `GOPRIVATE=gitea.hostxtra.co.uk/*`. +There are **five** such places, and each needs its own because jobs do not share +a filesystem: `server-deploy.yml`'s single job, both jobs of +`agent-release.yml` (the `msi` job is Windows, where Go reads `%USERPROFILE%\_netrc`, +not `.netrc`) and both jobs of `vantagectl-release.yml`. The docker builds pass +it on as `--secret id=netrc`, never a build arg. **`RELEASE_TOKEN` needs read +access to the `vantage` org** on top of its existing scopes; without it every Go +build fails at `go mod download` with a 404 on the module, which reads like a +missing tag rather than a missing permission. A change to the workflow file rebuilds everything, since a build arg is baked into the image. So does anything that leaves no trustworthy base commit: a manual `workflow_dispatch`, a new branch, or a force-push whose old head is gone. @@ -1392,7 +1430,7 @@ git push origin main # server + web deploy | Name | Type | Value | | ----------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `RELEASE_TOKEN` | Secret | Gitea API token. Needs `write:release` (agent releases), `write:package` (container images and the Helm chart). **This is the only token any workflow authenticates with** — `docker login` and the chart publish both pair it with `REGISTRY_USER` | +| `RELEASE_TOKEN` | Secret | Gitea API token. Needs `write:release` (agent releases), `write:package` (container images and the Helm chart) and **read access to the `vantage` org**, which is where the private `vantage-shared` module lives — without that last one every Go build fails at `go mod download` with what looks like a missing tag. **This is the only token any workflow authenticates with** — `docker login`, the chart publish and the module netrc all pair it with `REGISTRY_USER` | | `REGISTRY_USER` | Secret | Gitea username. Must own `RELEASE_TOKEN`, or basic auth is rejected | | ~~`REGISTRY_PASSWORD`~~ | — | **Not used.** Named here historically; no workflow reads it. Referencing an unset secret yields an empty password and a `401 Failed to authenticate user` that looks like a token scope problem. Use `RELEASE_TOKEN` | | `DOCKER_HOST` | Variable | registry host used for image tags | diff --git a/admin/Dockerfile b/admin/Dockerfile index b1d9fdd..e2bd57a 100644 --- a/admin/Dockerfile +++ b/admin/Dockerfile @@ -1,17 +1,25 @@ -# Context is the repository root; admin depends on the shared module. +# Context is admin/ itself. It used to be the repository root, so that +# shared/ could be copied in beside it; shared is now the private module +# gitea.hostxtra.co.uk/vantage/vantage-shared, fetched like any other +# dependency. The credential for it arrives as a BuildKit secret rather than a +# build arg, which would be baked into this stage's layer history. FROM golang:1.26-alpine AS builder WORKDIR /src -COPY shared/go.mod shared/go.sum ./shared/ -COPY admin/go.mod admin/go.sum ./admin/ -RUN cd admin && go mod download +ENV GOPRIVATE=gitea.hostxtra.co.uk/* +RUN apk add --no-cache git -COPY shared/ ./shared/ -COPY admin/ ./admin/ +COPY go.mod go.sum ./ +RUN --mount=type=secret,id=netrc,target=/root/.netrc \ + go mod download -RUN cd admin && CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/admin ./cmd -RUN cd admin && CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/adminctl ./cmd/adminctl +COPY . . + +RUN --mount=type=secret,id=netrc,target=/root/.netrc \ + CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/admin ./cmd +RUN --mount=type=secret,id=netrc,target=/root/.netrc \ + CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/adminctl ./cmd/adminctl FROM alpine:3.20 AS runner diff --git a/admin/go.mod b/admin/go.mod index f4f6e65..15c631e 100644 --- a/admin/go.mod +++ b/admin/go.mod @@ -3,10 +3,10 @@ module gitea.hostxtra.co.uk/mrhid6/vantage/admin go 1.26 require ( + gitea.hostxtra.co.uk/vantage/vantage-shared v0.1.0 github.com/gin-gonic/gin v1.10.0 github.com/google/uuid v1.6.0 github.com/joho/godotenv v1.5.1 - gitea.hostxtra.co.uk/vantage/vantage-shared v0.0.0-00010101000000-000000000000 github.com/redis/go-redis/v9 v9.20.1 go.mongodb.org/mongo-driver/v2 v2.8.0 golang.org/x/crypto v0.54.0 @@ -51,5 +51,3 @@ require ( gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) - -replace gitea.hostxtra.co.uk/vantage/vantage-shared => ../shared diff --git a/admin/go.sum b/admin/go.sum index 83bc835..f80fb2d 100644 --- a/admin/go.sum +++ b/admin/go.sum @@ -1,3 +1,5 @@ +gitea.hostxtra.co.uk/vantage/vantage-shared v0.1.0 h1:H6PCb8JHucrRiqPe9kGOhXUjBD66tKFHCP3qz5TjdZc= +gitea.hostxtra.co.uk/vantage/vantage-shared v0.1.0/go.mod h1:dWjeOFLltQ8sv9Pnn1xRxGfWGgqa2fkG0esuaJLoPXQ= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= diff --git a/agent/go.mod b/agent/go.mod index 161550b..b1da6f8 100644 --- a/agent/go.mod +++ b/agent/go.mod @@ -3,17 +3,15 @@ module gitea.hostxtra.co.uk/mrhid6/vantage/agent go 1.26 require ( - golang.org/x/sys v0.20.0 + golang.org/x/sys v0.47.0 google.golang.org/grpc v1.64.0 gopkg.in/yaml.v3 v3.0.1 ) require ( - gitea.hostxtra.co.uk/vantage/vantage-shared v0.0.0 - golang.org/x/net v0.25.0 // indirect + gitea.hostxtra.co.uk/vantage/vantage-shared v0.1.0 + golang.org/x/net v0.56.0 // indirect golang.org/x/text v0.40.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20240521202816-d264139d666e // indirect google.golang.org/protobuf v1.34.1 // indirect ) - -replace gitea.hostxtra.co.uk/vantage/vantage-shared => ../shared diff --git a/agent/go.sum b/agent/go.sum index 4f5c8b1..1270524 100644 --- a/agent/go.sum +++ b/agent/go.sum @@ -1,9 +1,11 @@ +gitea.hostxtra.co.uk/vantage/vantage-shared v0.1.0 h1:H6PCb8JHucrRiqPe9kGOhXUjBD66tKFHCP3qz5TjdZc= +gitea.hostxtra.co.uk/vantage/vantage-shared v0.1.0/go.mod h1:dWjeOFLltQ8sv9Pnn1xRxGfWGgqa2fkG0esuaJLoPXQ= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= -golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= -golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= -golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= google.golang.org/genproto/googleapis/rpc v0.0.0-20240521202816-d264139d666e h1:Elxv5MwEkCI9f5SkoL6afed6NTdxaGoAo39eANBwHL8= diff --git a/go.work b/go.work index ed44a2f..8a7f0bc 100644 --- a/go.work +++ b/go.work @@ -4,7 +4,6 @@ use ( ./admin ./agent ./server - ./shared ./sitesvc ./vantagectl ) diff --git a/go.work.sum b/go.work.sum index c0b7320..e8e7d70 100644 --- a/go.work.sum +++ b/go.work.sum @@ -1,5 +1,6 @@ cloud.google.com/go/compute v1.25.1/go.mod h1:oopOIR53ly6viBYxaDhBfJwzUAxf1zE//uf3IB011ls= cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= +gitea.hostxtra.co.uk/vantage/vantage-shared v0.1.0/go.mod h1:dWjeOFLltQ8sv9Pnn1xRxGfWGgqa2fkG0esuaJLoPXQ= github.com/Intevation/gval v1.3.0/go.mod h1:xmGyGpP5be12EL0P12h+dqiYG8qn2j3PJxIgkoOHO5o= github.com/Intevation/jsonpath v0.2.1/go.mod h1:WnZ8weMmwAx/fAO3SutjYFU+v7DFreNYnibV7CiaYIw= github.com/VividCortex/ewma v1.2.0/go.mod h1:nz4BbCtbLyFDeC9SUHbtcT5644juEuWfUAUnGx7j5l4= @@ -76,6 +77,8 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= google.golang.org/genproto/googleapis/api v0.0.0-20240318140521-94a12d6c2237/go.mod h1:Z5Iiy3jtmioajWHDGFk7CeugTyHtPvMHA4UTmUkyalE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240318140521-94a12d6c2237/go.mod h1:WtryC6hu0hhx87FDGxWCDptyssuo68sk10vYjF+T9fY= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= k8s.io/utils v0.0.0-20201110183641-67b214c5f920/go.mod h1:jPW/WVKK9YHAvNhRxK0md/EJ228hCsBRufyofKtW8HA= diff --git a/server/Dockerfile b/server/Dockerfile index 44714a4..b6076bf 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -1,21 +1,27 @@ # Build stage # -# Context is the repository root, not server/, because server depends on the -# shared module through a replace directive. +# Context is still the repository root, not server/, because the runtime stage +# copies default_steps/ from it. +# +# vantage-shared is a private module, so every step that resolves it needs a +# credential. It arrives as a BuildKit secret rather than a build arg: an arg +# is baked into the builder layer's history, and this one is a Gitea token. FROM golang:1.26 AS builder WORKDIR /src -# Manifests first so the dependency layer caches independently of source edits. -COPY shared/go.mod shared/go.sum ./shared/ -COPY server/go.mod server/go.sum ./server/ -RUN cd server && go mod download +ENV GOPRIVATE=gitea.hostxtra.co.uk/* + +# Manifests first so the dependency layer caches independently of source edits. +COPY server/go.mod server/go.sum ./server/ +RUN --mount=type=secret,id=netrc,target=/root/.netrc \ + cd server && go mod download -COPY shared/ ./shared/ COPY server/ ./server/ ARG VERSION=dev -RUN cd server && CGO_ENABLED=0 GOOS=linux go build \ +RUN --mount=type=secret,id=netrc,target=/root/.netrc \ + cd server && CGO_ENABLED=0 GOOS=linux go build \ -ldflags="-s -w -X main.Version=${VERSION}" -o /vantage-server ./cmd # Staged so the scratch image below can have a /tmp. It cannot mkdir one diff --git a/server/go.mod b/server/go.mod index 986fc24..7c459c1 100644 --- a/server/go.mod +++ b/server/go.mod @@ -37,7 +37,7 @@ require ( ) require ( - gitea.hostxtra.co.uk/vantage/vantage-shared v0.0.0 + gitea.hostxtra.co.uk/vantage/vantage-shared v0.1.0 github.com/bytedance/sonic v1.11.6 // indirect github.com/bytedance/sonic/loader v0.1.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect @@ -77,5 +77,3 @@ require ( google.golang.org/protobuf v1.34.2 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) - -replace gitea.hostxtra.co.uk/vantage/vantage-shared => ../shared diff --git a/server/go.sum b/server/go.sum index fc8c83a..7efa9f7 100644 --- a/server/go.sum +++ b/server/go.sum @@ -1,3 +1,5 @@ +gitea.hostxtra.co.uk/vantage/vantage-shared v0.1.0 h1:H6PCb8JHucrRiqPe9kGOhXUjBD66tKFHCP3qz5TjdZc= +gitea.hostxtra.co.uk/vantage/vantage-shared v0.1.0/go.mod h1:dWjeOFLltQ8sv9Pnn1xRxGfWGgqa2fkG0esuaJLoPXQ= github.com/aquasecurity/bolt-fixtures v0.0.0-20200903104109-d34e7f983986 h1:2a30xLN2sUZcMXl50hg+PJCIDdJgIvIbVcKqLJ/ZrtM= github.com/aquasecurity/bolt-fixtures v0.0.0-20200903104109-d34e7f983986/go.mod h1:NT+jyeCzXk6vXR5MTkdn4z64TgGfE5HMLC8qfj5unl8= github.com/aquasecurity/trivy-db v0.0.0-20260713131703-4be526083c54 h1:4CZNoDkNfcuACevZeDraACGmP1+L0nKkRY52+jV8k1M= diff --git a/shared/backup/archive.go b/shared/backup/archive.go deleted file mode 100644 index 752111b..0000000 --- a/shared/backup/archive.go +++ /dev/null @@ -1,253 +0,0 @@ -package backup - -import ( - "archive/tar" - "compress/gzip" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "io" - "os" - "path" - "path/filepath" - "strings" - "time" -) - -// ErrChecksum is returned when an archive member does not match the checksum -// the manifest recorded for it. -var ErrChecksum = errors.New("archive member failed its checksum") - -// Writer streams a tar.gz. Members are written in the order they are produced -// and the manifest goes last, because its per-collection checksums are only -// known once every collection has been written. -type Writer struct { - gz *gzip.Writer - tar *tar.Writer -} - -// NewWriter starts an archive on out. out may be a file or stdout; nothing here -// seeks. -func NewWriter(out io.Writer) *Writer { - gz := gzip.NewWriter(out) - return &Writer{gz: gz, tar: tar.NewWriter(gz)} -} - -func (w *Writer) writeMember(name string, body []byte) error { - h := &tar.Header{ - Name: name, - Mode: 0o600, - Size: int64(len(body)), - ModTime: time.Now().UTC(), - Typeflag: tar.TypeReg, - } - if err := w.tar.WriteHeader(h); err != nil { - return fmt.Errorf("write header %s: %w", name, err) - } - if _, err := w.tar.Write(body); err != nil { - return fmt.Errorf("write %s: %w", name, err) - } - return nil -} - -// WriteCollection writes the concatenated raw BSON of one collection and -// returns the manifest entry describing it. -func (w *Writer) WriteCollection(name string, docs [][]byte) (CollectionEntry, error) { - var body []byte - for _, d := range docs { - body = append(body, d...) - } - sum := sha256.Sum256(body) - entry := CollectionEntry{ - Name: name, - Documents: int64(len(docs)), - Bytes: int64(len(body)), - SHA256: hex.EncodeToString(sum[:]), - } - if err := w.writeMember(collectionMember(name), body); err != nil { - return CollectionEntry{}, err - } - return entry, nil -} - -// WriteIndexes writes a collection's index specifications verbatim. -func (w *Writer) WriteIndexes(name string, specsJSON []byte) error { - return w.writeMember(indexMember(name), specsJSON) -} - -// Close writes the manifest and finishes the archive. -func (w *Writer) Close(m Manifest) error { - raw, err := json.MarshalIndent(m, "", " ") - if err != nil { - return fmt.Errorf("marshal manifest: %w", err) - } - if err := w.writeMember(ManifestName, raw); err != nil { - return err - } - if err := w.tar.Close(); err != nil { - return err - } - return w.gz.Close() -} - -func collectionMember(name string) string { return "collections/" + name + ".bson" } -func indexMember(name string) string { return "indexes/" + name + ".json" } - -// Reader is an opened archive. -// -// Open extracts to a temporary directory rather than streaming, because gzip -// offers no random access and the manifest — which carries the checksums every -// other member is judged against — is written last. Verifying before writing a -// single document to the target is worth one pass over local disk. This is why -// the container image needs a /tmp. -type Reader struct { - dir string - manifest Manifest -} - -// Open extracts, verifies and returns the archive at path. The caller must -// Close it. -func Open(archivePath string) (*Reader, error) { - dir, err := os.MkdirTemp("", "vantage-restore-*") - if err != nil { - return nil, fmt.Errorf("temp dir: %w", err) - } - r := &Reader{dir: dir} - - if err := r.extract(archivePath); err != nil { - r.Close() - return nil, err - } - if err := r.loadManifest(); err != nil { - r.Close() - return nil, err - } - if err := r.verifyMembers(); err != nil { - r.Close() - return nil, err - } - return r, nil -} - -func (r *Reader) extract(archivePath string) error { - f, err := os.Open(archivePath) - if err != nil { - return fmt.Errorf("open archive: %w", err) - } - defer f.Close() - - gz, err := gzip.NewReader(f) - if err != nil { - return fmt.Errorf("archive is not gzip: %w", err) - } - defer gz.Close() - - tr := tar.NewReader(gz) - for { - h, err := tr.Next() - if err == io.EOF { - return nil - } - if err != nil { - return fmt.Errorf("read archive: %w", err) - } - if h.Typeflag != tar.TypeReg { - continue - } - dest, err := safeJoin(r.dir, h.Name) - if err != nil { - return err - } - if err := os.MkdirAll(filepath.Dir(dest), 0o700); err != nil { - return fmt.Errorf("mkdir: %w", err) - } - out, err := os.OpenFile(dest, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600) - if err != nil { - return fmt.Errorf("create %s: %w", h.Name, err) - } - if _, err := io.Copy(out, tr); err != nil { - out.Close() - return fmt.Errorf("extract %s: %w", h.Name, err) - } - if err := out.Close(); err != nil { - return err - } - } -} - -// safeJoin refuses a member name that escapes the extraction directory. An -// archive is operator-supplied input and may not be one we wrote. -func safeJoin(dir, name string) (string, error) { - clean := path.Clean("/" + name) - dest := filepath.Join(dir, filepath.FromSlash(strings.TrimPrefix(clean, "/"))) - if !strings.HasPrefix(dest, filepath.Clean(dir)+string(os.PathSeparator)) { - return "", fmt.Errorf("archive member %q escapes the extraction directory", name) - } - return dest, nil -} - -func (r *Reader) loadManifest() error { - raw, err := os.ReadFile(filepath.Join(r.dir, ManifestName)) - if err != nil { - return fmt.Errorf("archive has no %s: %w", ManifestName, err) - } - if err := json.Unmarshal(raw, &r.manifest); err != nil { - return fmt.Errorf("parse %s: %w", ManifestName, err) - } - return r.manifest.Check() -} - -func (r *Reader) verifyMembers() error { - for _, c := range r.manifest.Collections { - f, err := r.OpenCollection(c.Name) - if err != nil { - return fmt.Errorf("%w: %s is named in the manifest but absent from the archive", - ErrChecksum, c.Name) - } - h := sha256.New() - n, err := io.Copy(h, f) - f.Close() - if err != nil { - return fmt.Errorf("read %s: %w", c.Name, err) - } - if n != c.Bytes { - return fmt.Errorf("%w: %s is %d bytes, manifest says %d", ErrChecksum, c.Name, n, c.Bytes) - } - if got := hex.EncodeToString(h.Sum(nil)); got != c.SHA256 { - return fmt.Errorf("%w: %s checksum %s, manifest says %s", ErrChecksum, c.Name, got, c.SHA256) - } - } - return nil -} - -// Manifest returns the verified manifest. -func (r *Reader) Manifest() Manifest { return r.manifest } - -// OpenCollection returns the raw BSON stream for one collection. -func (r *Reader) OpenCollection(name string) (io.ReadCloser, error) { - p, err := safeJoin(r.dir, collectionMember(name)) - if err != nil { - return nil, err - } - return os.Open(p) -} - -// IndexesJSON returns a collection's index specifications, or nil when the -// archive holds none. A collection with no indexes beyond _id_ is ordinary and -// is not an error. -func (r *Reader) IndexesJSON(name string) ([]byte, error) { - p, err := safeJoin(r.dir, indexMember(name)) - if err != nil { - return nil, err - } - raw, err := os.ReadFile(p) - if os.IsNotExist(err) { - return nil, nil - } - return raw, err -} - -// Close removes the extraction directory. -func (r *Reader) Close() error { return os.RemoveAll(r.dir) } diff --git a/shared/backup/archive_test.go b/shared/backup/archive_test.go deleted file mode 100644 index 62c7a67..0000000 --- a/shared/backup/archive_test.go +++ /dev/null @@ -1,216 +0,0 @@ -package backup - -import ( - "archive/tar" - "bytes" - "compress/gzip" - "errors" - "io" - "os" - "path/filepath" - "testing" - "time" -) - -// writeSampleArchive builds a two-collection archive on disk and returns its path. -func writeSampleArchive(t *testing.T) string { - t.Helper() - path := filepath.Join(t.TempDir(), "sample.tar.gz") - f, err := os.Create(path) - if err != nil { - t.Fatalf("create: %v", err) - } - defer f.Close() - - w := NewWriter(f) - servers, err := w.WriteCollection("servers", [][]byte{[]byte("one"), []byte("two")}) - if err != nil { - t.Fatalf("WriteCollection: %v", err) - } - if err := w.WriteIndexes("servers", []byte(`[{"name":"idx"}]`)); err != nil { - t.Fatalf("WriteIndexes: %v", err) - } - keys, err := w.WriteCollection("keys", [][]byte{[]byte("k")}) - if err != nil { - t.Fatalf("WriteCollection: %v", err) - } - if err := w.Close(Manifest{ - FormatVersion: FormatVersion, - CreatedAt: time.Now().UTC(), - MongoDB: "vantage", - Collections: []CollectionEntry{servers, keys}, - }); err != nil { - t.Fatalf("Close: %v", err) - } - return path -} - -func TestWriterRecordsCountsAndChecksums(t *testing.T) { - var buf bytes.Buffer - w := NewWriter(&buf) - e, err := w.WriteCollection("servers", [][]byte{[]byte("one"), []byte("two")}) - if err != nil { - t.Fatalf("WriteCollection: %v", err) - } - if e.Name != "servers" { - t.Fatalf("name %q", e.Name) - } - if e.Documents != 2 { - t.Fatalf("documents %d, want 2", e.Documents) - } - if e.Bytes != 6 { - t.Fatalf("bytes %d, want 6", e.Bytes) - } - if len(e.SHA256) != 64 { - t.Fatalf("sha256 %q is not 64 hex chars", e.SHA256) - } -} - -func TestRoundTrip(t *testing.T) { - r, err := Open(writeSampleArchive(t)) - if err != nil { - t.Fatalf("Open: %v", err) - } - defer r.Close() - - if r.Manifest().MongoDB != "vantage" { - t.Fatalf("manifest not read back: %+v", r.Manifest()) - } - - rc, err := r.OpenCollection("servers") - if err != nil { - t.Fatalf("OpenCollection: %v", err) - } - defer rc.Close() - got, err := io.ReadAll(rc) - if err != nil { - t.Fatalf("read: %v", err) - } - if string(got) != "onetwo" { - t.Fatalf("got %q, want %q", got, "onetwo") - } - - idx, err := r.IndexesJSON("servers") - if err != nil { - t.Fatalf("IndexesJSON: %v", err) - } - if string(idx) != `[{"name":"idx"}]` { - t.Fatalf("indexes round-tripped as %q", idx) - } -} - -func TestIndexesJSONAbsentIsEmptyNotError(t *testing.T) { - r, err := Open(writeSampleArchive(t)) - if err != nil { - t.Fatalf("Open: %v", err) - } - defer r.Close() - idx, err := r.IndexesJSON("keys") - if err != nil { - t.Fatalf("a collection with no index member must not error: %v", err) - } - if len(idx) != 0 { - t.Fatalf("want empty, got %q", idx) - } -} - -func TestOpenRejectsCorruptedMember(t *testing.T) { - path := writeSampleArchive(t) - - // Rewrite the archive with one byte of a collection member flipped, leaving - // the manifest's checksum describing the original. - corrupt := filepath.Join(t.TempDir(), "corrupt.tar.gz") - rewriteFlippingCollectionByte(t, path, corrupt, "servers") - - if _, err := Open(corrupt); !errors.Is(err, ErrChecksum) { - t.Fatalf("got %v, want ErrChecksum", err) - } -} - -func TestOpenRejectsUnknownFormatVersion(t *testing.T) { - path := filepath.Join(t.TempDir(), "future.tar.gz") - f, err := os.Create(path) - if err != nil { - t.Fatalf("create: %v", err) - } - w := NewWriter(f) - if err := w.Close(Manifest{FormatVersion: 99}); err != nil { - t.Fatalf("Close: %v", err) - } - f.Close() - - if _, err := Open(path); !errors.Is(err, ErrUnknownFormat) { - t.Fatalf("got %v, want ErrUnknownFormat", err) - } -} - -func TestCloseRemovesTempDir(t *testing.T) { - r, err := Open(writeSampleArchive(t)) - if err != nil { - t.Fatalf("Open: %v", err) - } - dir := r.dir - if _, err := os.Stat(dir); err != nil { - t.Fatalf("temp dir missing while open: %v", err) - } - if err := r.Close(); err != nil { - t.Fatalf("Close: %v", err) - } - if _, err := os.Stat(dir); !os.IsNotExist(err) { - t.Fatalf("temp dir %s survived Close", dir) - } -} - -// rewriteFlippingCollectionByte copies an archive, flipping one byte inside the -// named collection's .bson member so its content no longer matches the checksum -// the manifest recorded. -func rewriteFlippingCollectionByte(t *testing.T, src, dst, collection string) { - t.Helper() - - in, err := os.Open(src) - if err != nil { - t.Fatalf("open src: %v", err) - } - defer in.Close() - gz, err := gzip.NewReader(in) - if err != nil { - t.Fatalf("gzip: %v", err) - } - defer gz.Close() - - out, err := os.Create(dst) - if err != nil { - t.Fatalf("create dst: %v", err) - } - defer out.Close() - gw := gzip.NewWriter(out) - defer gw.Close() - tw := tar.NewWriter(gw) - defer tw.Close() - - tr := tar.NewReader(gz) - target := "collections/" + collection + ".bson" - for { - h, err := tr.Next() - if err == io.EOF { - return - } - if err != nil { - t.Fatalf("tar next: %v", err) - } - body, err := io.ReadAll(tr) - if err != nil { - t.Fatalf("read member: %v", err) - } - if h.Name == target && len(body) > 0 { - body[0] ^= 0xFF - } - h.Size = int64(len(body)) - if err := tw.WriteHeader(h); err != nil { - t.Fatalf("write header: %v", err) - } - if _, err := tw.Write(body); err != nil { - t.Fatalf("write body: %v", err) - } - } -} diff --git a/shared/backup/dump.go b/shared/backup/dump.go deleted file mode 100644 index 1374d6c..0000000 --- a/shared/backup/dump.go +++ /dev/null @@ -1,184 +0,0 @@ -package backup - -import ( - "context" - "encoding/json" - "fmt" - "io" - "os" - "sort" - "time" - - "go.mongodb.org/mongo-driver/v2/bson" - "go.mongodb.org/mongo-driver/v2/mongo" -) - -// DumpOptions configures one backup. -type DumpOptions struct { - Client *mongo.Client - Database string - Exclude []string - - // KeyHex is KEY_ENCRYPTION_KEY. It is fingerprinted and discarded; it is - // never written to the archive. - KeyHex string - - // AllowNoKey permits a backup of a deployment that stores no encrypted - // material. The manifest then records a null fingerprint, which restore - // reports rather than treating as a match. - AllowNoKey bool - - VantageVersion string - Out io.Writer -} - -// Dump writes a complete archive of one database to opt.Out. -// -// Collections are enumerated live rather than read from a list. A backup tool -// has no equivalent of AssertNoScopedCollectionMissed to catch a hardcoded list -// drifting, and the first symptom of that drift would be a restore silently -// missing a collection added since the list was written. -func Dump(ctx context.Context, opt DumpOptions) (Manifest, error) { - fingerprint, err := dumpFingerprint(opt) - if err != nil { - return Manifest{}, err - } - - db := opt.Client.Database(opt.Database) - names, err := db.ListCollectionNames(ctx, bson.M{}) - if err != nil { - return Manifest{}, fmt.Errorf("list collections: %w", err) - } - sort.Strings(names) - - excluded := map[string]bool{} - for _, e := range opt.Exclude { - excluded[e] = true - } - - serverVersion, err := mongoServerVersion(ctx, opt.Client) - if err != nil { - return Manifest{}, err - } - hostname, err := os.Hostname() - if err != nil { - hostname = "unknown" - } - - w := NewWriter(opt.Out) - entries := make([]CollectionEntry, 0, len(names)) - for _, name := range names { - if excluded[name] { - continue - } - entry, err := dumpCollection(ctx, w, db, name) - if err != nil { - return Manifest{}, err - } - entries = append(entries, entry) - } - - m := Manifest{ - FormatVersion: FormatVersion, - CreatedAt: time.Now().UTC(), - VantageVersion: opt.VantageVersion, - Hostname: hostname, - MongoDB: opt.Database, - MongoServerVersion: serverVersion, - KeyFingerprint: fingerprint, - Collections: entries, - Excluded: append([]string{}, opt.Exclude...), - } - if err := w.Close(m); err != nil { - return Manifest{}, err - } - return m, nil -} - -// dumpFingerprint applies the key policy before any output is produced. An -// archive of ciphertext whose key was never recorded is worse than no archive, -// because it looks like a backup. -func dumpFingerprint(opt DumpOptions) (*string, error) { - if opt.KeyHex == "" { - if opt.AllowNoKey { - return nil, nil - } - return nil, fmt.Errorf("%w: pass --allow-no-key only if this deployment stores no encrypted data", ErrNoKey) - } - fp, err := FingerprintHex(opt.KeyHex) - if err != nil { - return nil, err - } - return &fp, nil -} - -func dumpCollection(ctx context.Context, w *Writer, db *mongo.Database, name string) (CollectionEntry, error) { - cur, err := db.Collection(name).Find(ctx, bson.M{}) - if err != nil { - return CollectionEntry{}, fmt.Errorf("find %s: %w", name, err) - } - defer cur.Close(ctx) - - var docs [][]byte - for cur.Next(ctx) { - // cur.Current is only valid until the next Next, and it is written to - // the archive verbatim rather than through a map, so every BSON type - // survives exactly as the server stored it. - docs = append(docs, append([]byte(nil), cur.Current...)) - } - if err := cur.Err(); err != nil { - return CollectionEntry{}, fmt.Errorf("iterate %s: %w", name, err) - } - - entry, err := w.WriteCollection(name, docs) - if err != nil { - return CollectionEntry{}, err - } - if err := dumpIndexes(ctx, w, db, name); err != nil { - return CollectionEntry{}, err - } - return entry, nil -} - -func dumpIndexes(ctx context.Context, w *Writer, db *mongo.Database, name string) error { - cur, err := db.Collection(name).Indexes().List(ctx) - if err != nil { - return fmt.Errorf("list indexes on %s: %w", name, err) - } - defer cur.Close(ctx) - - // The specs are read as raw BSON and re-encoded as extended JSON, one - // element per index, so key order and every option the server reported — - // partialFilterExpression, collation, weights and the rest — survive - // verbatim. Decoding into bson.M would lose compound key order, and - // reconstructing an index from a hand-picked set of options would drop - // whatever was not picked. - var specs []bson.Raw - if err := cur.All(ctx, &specs); err != nil { - return fmt.Errorf("read indexes on %s: %w", name, err) - } - encoded := make([]json.RawMessage, 0, len(specs)) - for _, spec := range specs { - ej, err := bson.MarshalExtJSON(spec, false, false) - if err != nil { - return fmt.Errorf("encode indexes on %s: %w", name, err) - } - encoded = append(encoded, ej) - } - raw, err := json.Marshal(encoded) - if err != nil { - return fmt.Errorf("encode indexes on %s: %w", name, err) - } - return w.WriteIndexes(name, raw) -} - -func mongoServerVersion(ctx context.Context, client *mongo.Client) (string, error) { - var res struct { - Version string `bson:"version"` - } - err := client.Database("admin").RunCommand(ctx, bson.D{{Key: "buildInfo", Value: 1}}).Decode(&res) - if err != nil { - return "", fmt.Errorf("buildInfo: %w", err) - } - return res.Version, nil -} diff --git a/shared/backup/dump_test.go b/shared/backup/dump_test.go deleted file mode 100644 index f13a845..0000000 --- a/shared/backup/dump_test.go +++ /dev/null @@ -1,206 +0,0 @@ -package backup - -import ( - "bytes" - "context" - "errors" - "io" - "os" - "path/filepath" - "testing" - "time" - - "go.mongodb.org/mongo-driver/v2/bson" - "go.mongodb.org/mongo-driver/v2/mongo" -) - -func seed(t *testing.T, client *mongo.Client, dbName string) { - t.Helper() - ctx := context.Background() - db := client.Database(dbName) - if _, err := db.Collection("servers").InsertMany(ctx, []any{ - bson.M{"_id": bson.NewObjectID(), "name": "alpha", "instance_id": "i1"}, - bson.M{"_id": bson.NewObjectID(), "name": "beta", "instance_id": "i1"}, - }); err != nil { - t.Fatalf("insert servers: %v", err) - } - if _, err := db.Collection("audit_logs").InsertOne(ctx, bson.M{"action": "login"}); err != nil { - t.Fatalf("insert audit_logs: %v", err) - } -} - -func dumpToFile(t *testing.T, opt DumpOptions) (string, Manifest) { - t.Helper() - path := filepath.Join(t.TempDir(), "out.tar.gz") - f, err := os.Create(path) - if err != nil { - t.Fatalf("create: %v", err) - } - opt.Out = f - m, err := Dump(context.Background(), opt) - if cerr := f.Close(); cerr != nil { - t.Fatalf("close: %v", cerr) - } - if err != nil { - t.Fatalf("Dump: %v", err) - } - return path, m -} - -func TestDumpEnumeratesEveryCollection(t *testing.T) { - client, dbName := testDB(t) - seed(t, client, dbName) - - _, m := dumpToFile(t, DumpOptions{ - Client: client, Database: dbName, KeyHex: validKeyHex, VantageVersion: "test", - }) - - if _, ok := m.Collection("servers"); !ok { - t.Fatal("servers missing from the manifest") - } - if _, ok := m.Collection("audit_logs"); !ok { - t.Fatal("audit_logs missing; enumeration must not filter by a hardcoded list") - } - servers, _ := m.Collection("servers") - if servers.Documents != 2 { - t.Fatalf("servers documents %d, want 2", servers.Documents) - } - if m.MongoDB != dbName { - t.Fatalf("manifest database %q, want %q", m.MongoDB, dbName) - } - if m.MongoServerVersion == "" { - t.Fatal("manifest records no MongoDB server version") - } - if m.Hostname == "" { - t.Fatal("manifest records no hostname") - } -} - -func TestDumpRecordsKeyFingerprint(t *testing.T) { - client, dbName := testDB(t) - seed(t, client, dbName) - - _, m := dumpToFile(t, DumpOptions{Client: client, Database: dbName, KeyHex: validKeyHex}) - - want, err := FingerprintHex(validKeyHex) - if err != nil { - t.Fatalf("FingerprintHex: %v", err) - } - if m.KeyFingerprint == nil || *m.KeyFingerprint != want { - t.Fatalf("fingerprint %v, want %s", m.KeyFingerprint, want) - } -} - -func TestDumpRefusesWithoutAKey(t *testing.T) { - client, dbName := testDB(t) - seed(t, client, dbName) - - var buf bytes.Buffer - _, err := Dump(context.Background(), DumpOptions{ - Client: client, Database: dbName, Out: &buf, - }) - if !errors.Is(err, ErrNoKey) { - t.Fatalf("got %v, want ErrNoKey", err) - } - if buf.Len() != 0 { - t.Fatal("refusal must happen before anything is written") - } -} - -func TestDumpAllowNoKeyStampsNull(t *testing.T) { - client, dbName := testDB(t) - seed(t, client, dbName) - - _, m := dumpToFile(t, DumpOptions{Client: client, Database: dbName, AllowNoKey: true}) - if m.KeyFingerprint != nil { - t.Fatalf("want a null fingerprint, got %v", *m.KeyFingerprint) - } -} - -func TestDumpRejectsMalformedKey(t *testing.T) { - client, dbName := testDB(t) - var buf bytes.Buffer - _, err := Dump(context.Background(), DumpOptions{ - Client: client, Database: dbName, KeyHex: "nonsense", Out: &buf, - }) - if !errors.Is(err, ErrBadKey) { - t.Fatalf("got %v, want ErrBadKey", err) - } -} - -func TestDumpExcludeIsRecordedAndOmitted(t *testing.T) { - client, dbName := testDB(t) - seed(t, client, dbName) - - path, m := dumpToFile(t, DumpOptions{ - Client: client, Database: dbName, KeyHex: validKeyHex, - Exclude: []string{"audit_logs"}, - }) - - if _, ok := m.Collection("audit_logs"); ok { - t.Fatal("excluded collection is in the manifest's collection list") - } - if len(m.Excluded) != 1 || m.Excluded[0] != "audit_logs" { - t.Fatalf("excluded recorded as %v", m.Excluded) - } - - r, err := Open(path) - if err != nil { - t.Fatalf("Open: %v", err) - } - defer r.Close() - if _, err := r.OpenCollection("audit_logs"); err == nil { - t.Fatal("excluded collection is present in the archive") - } -} - -func TestDumpPreservesAwkwardBSONTypes(t *testing.T) { - client, dbName := testDB(t) - ctx := context.Background() - - dec, err := bson.ParseDecimal128("1234.5678") - if err != nil { - t.Fatalf("ParseDecimal128: %v", err) - } - doc := bson.M{ - "_id": bson.NewObjectID(), - "decimal": dec, - "when": bson.NewDateTimeFromTime(mustTime(t)), - "binary": bson.Binary{Subtype: 0x00, Data: []byte{0x01, 0x02, 0x03}}, - "nothing": nil, - "nested": bson.A{bson.M{"deep": bson.A{1, 2, 3}}}, - } - if _, err := client.Database(dbName).Collection("odd").InsertOne(ctx, doc); err != nil { - t.Fatalf("insert: %v", err) - } - - path, _ := dumpToFile(t, DumpOptions{Client: client, Database: dbName, KeyHex: validKeyHex}) - - original, err := client.Database(dbName).Collection("odd").FindOne(ctx, bson.M{}).Raw() - if err != nil { - t.Fatalf("read back: %v", err) - } - - r, err := Open(path) - if err != nil { - t.Fatalf("Open: %v", err) - } - defer r.Close() - rc, err := r.OpenCollection("odd") - if err != nil { - t.Fatalf("OpenCollection: %v", err) - } - defer rc.Close() - archived, err := io.ReadAll(rc) - if err != nil { - t.Fatalf("read: %v", err) - } - if !bytes.Equal(archived, []byte(original)) { - t.Fatal("archived BSON differs from what the driver returned") - } -} - -func mustTime(t *testing.T) time.Time { - t.Helper() - return time.Date(2026, 9, 7, 12, 0, 0, 0, time.UTC) -} diff --git a/shared/backup/fingerprint.go b/shared/backup/fingerprint.go deleted file mode 100644 index 2d65d94..0000000 --- a/shared/backup/fingerprint.go +++ /dev/null @@ -1,57 +0,0 @@ -// Package backup dumps and restores a whole Vantage MongoDB database. -// -// The archive never contains KEY_ENCRYPTION_KEY. It contains a fingerprint of -// it, which is enough to answer "will this archive restore into this -// deployment" and is not a hint at the value. -package backup - -import ( - "crypto/sha256" - "encoding/hex" - "errors" - "fmt" - - "gitea.hostxtra.co.uk/vantage/vantage-shared/cryptobox" -) - -// ErrNoKey is returned when no key was supplied at all. It is distinct from -// ErrBadKey because the operator remedies are different: one is "set the -// variable", the other is "the value you set is wrong". -var ErrNoKey = errors.New("KEY_ENCRYPTION_KEY is not set") - -// ErrBadKey is returned when a key was supplied but is not 64 hex characters. -var ErrBadKey = errors.New("KEY_ENCRYPTION_KEY must be a 64-character hex string (32 bytes)") - -// ParseKey decodes the hex form used by KEY_ENCRYPTION_KEY. -func ParseKey(hexKey string) ([]byte, error) { - if hexKey == "" { - return nil, ErrNoKey - } - key, err := hex.DecodeString(hexKey) - if err != nil { - return nil, fmt.Errorf("%w: not hexadecimal", ErrBadKey) - } - if len(key) != cryptobox.KeySize { - return nil, fmt.Errorf("%w: decoded to %d bytes", ErrBadKey, len(key)) - } - return key, nil -} - -// Fingerprint is the SHA-256 of the raw key bytes, hex encoded. -// -// Of the raw bytes rather than of the hex string, so an operator who writes the -// key in uppercase in one deployment and lowercase in another still gets one -// fingerprint for one key. -func Fingerprint(key []byte) string { - sum := sha256.Sum256(key) - return hex.EncodeToString(sum[:]) -} - -// FingerprintHex parses and fingerprints in one step. -func FingerprintHex(hexKey string) (string, error) { - key, err := ParseKey(hexKey) - if err != nil { - return "", err - } - return Fingerprint(key), nil -} diff --git a/shared/backup/fingerprint_test.go b/shared/backup/fingerprint_test.go deleted file mode 100644 index d05fc7a..0000000 --- a/shared/backup/fingerprint_test.go +++ /dev/null @@ -1,61 +0,0 @@ -package backup - -import ( - "errors" - "strings" - "testing" -) - -const validKeyHex = "0000000000000000000000000000000000000000000000000000000000000001" - -func TestFingerprintIsStableAndNotTheKey(t *testing.T) { - fp, err := FingerprintHex(validKeyHex) - if err != nil { - t.Fatalf("FingerprintHex: %v", err) - } - if len(fp) != 64 { - t.Fatalf("fingerprint is %d chars, want 64", len(fp)) - } - if strings.EqualFold(fp, validKeyHex) { - t.Fatal("fingerprint equals the key") - } - again, err := FingerprintHex(validKeyHex) - if err != nil { - t.Fatalf("FingerprintHex: %v", err) - } - if fp != again { - t.Fatal("fingerprint is not stable across calls") - } -} - -func TestFingerprintDiffersPerKey(t *testing.T) { - other := "0000000000000000000000000000000000000000000000000000000000000002" - a, err := FingerprintHex(validKeyHex) - if err != nil { - t.Fatalf("FingerprintHex: %v", err) - } - b, err := FingerprintHex(other) - if err != nil { - t.Fatalf("FingerprintHex: %v", err) - } - if a == b { - t.Fatal("two different keys produced the same fingerprint") - } -} - -func TestParseKeyRejections(t *testing.T) { - if _, err := ParseKey(""); !errors.Is(err, ErrNoKey) { - t.Fatalf("empty key: got %v, want ErrNoKey", err) - } - for _, bad := range []string{"zz", validKeyHex[:62], validKeyHex + "00"} { - if _, err := ParseKey(bad); !errors.Is(err, ErrBadKey) { - t.Fatalf("key %q: got %v, want ErrBadKey", bad, err) - } - } -} - -func TestParseKeyAcceptsUppercase(t *testing.T) { - if _, err := ParseKey(strings.ToUpper(validKeyHex)); err != nil { - t.Fatalf("uppercase hex rejected: %v", err) - } -} diff --git a/shared/backup/manifest.go b/shared/backup/manifest.go deleted file mode 100644 index 0251892..0000000 --- a/shared/backup/manifest.go +++ /dev/null @@ -1,102 +0,0 @@ -package backup - -import ( - "errors" - "fmt" - "strings" - "time" -) - -// FormatVersion is the archive format this build reads and writes. Restore -// refuses anything else rather than guessing at a layout it does not know. -const FormatVersion = 1 - -// ManifestName is the archive member holding the manifest. -const ManifestName = "manifest.json" - -// ErrUnknownFormat is returned for an archive this build cannot read. -var ErrUnknownFormat = errors.New("unsupported archive format version") - -// ErrBadCollectionName is returned for a manifest naming a collection that -// cannot safely be used as a path component. -var ErrBadCollectionName = errors.New("manifest names an unusable collection") - -// CollectionEntry describes one collection in the archive. Bytes and SHA256 -// cover the uncompressed .bson member, which is what restore verifies before -// writing anything. -type CollectionEntry struct { - Name string `json:"name"` - Documents int64 `json:"documents"` - Bytes int64 `json:"bytes"` - SHA256 string `json:"sha256"` -} - -// Manifest is the archive's index and its provenance. -// -// KeyFingerprint is a pointer so "this archive recorded no key" is a distinct -// state from "this archive recorded the empty string". A null here is a real -// condition an operator must be told about, not a default. -type Manifest struct { - FormatVersion int `json:"format_version"` - CreatedAt time.Time `json:"created_at"` - VantageVersion string `json:"vantage_version"` - Hostname string `json:"hostname"` - MongoDB string `json:"mongo_db"` - MongoServerVersion string `json:"mongo_server_version"` - KeyFingerprint *string `json:"key_fingerprint"` - Collections []CollectionEntry `json:"collections"` - Excluded []string `json:"excluded"` -} - -// Check validates what can be validated without reading the rest of the archive. -func (m Manifest) Check() error { - if m.FormatVersion != FormatVersion { - return fmt.Errorf("%w: archive is version %d, this build reads version %d", - ErrUnknownFormat, m.FormatVersion, FormatVersion) - } - // Collection names become path components inside the extraction directory, - // and an archive is operator-supplied input that may not be one we wrote. - for _, c := range m.Collections { - if err := checkCollectionName(c.Name); err != nil { - return err - } - } - return nil -} - -// checkCollectionName refuses a name that could escape a directory when joined -// as a path component. -func checkCollectionName(name string) error { - switch { - case name == "": - return fmt.Errorf("%w: a collection entry has no name", ErrBadCollectionName) - case name == "." || name == "..": - return fmt.Errorf("%w: %q", ErrBadCollectionName, name) - case strings.ContainsAny(name, "/\\"), strings.Contains(name, ".."): - return fmt.Errorf("%w: %q", ErrBadCollectionName, name) - } - return nil -} - -// Collection looks up one entry by name. -func (m Manifest) Collection(name string) (CollectionEntry, bool) { - for _, c := range m.Collections { - if c.Name == name { - return c, true - } - } - return CollectionEntry{}, false -} - -// CiphertextCollections names the collections holding AES-GCM ciphertext. -// -// It exists to be printed. When a restore proceeds under a key that does not -// match the archive, this is the list of what will be unreadable afterwards, -// and an operator deserves to see it before the write rather than discover it -// a week later. -// -// settings is not in the list: it holds no encrypted material. Its ESO read -// token is a SHA-256 hash, not ciphertext. -func CiphertextCollections() []string { - return []string{"keys", "secrets", "auth_providers", "console_sessions"} -} diff --git a/shared/backup/manifest_test.go b/shared/backup/manifest_test.go deleted file mode 100644 index 1b1bff2..0000000 --- a/shared/backup/manifest_test.go +++ /dev/null @@ -1,103 +0,0 @@ -package backup - -import ( - "encoding/json" - "errors" - "strings" - "testing" - "time" -) - -func TestManifestJSONShape(t *testing.T) { - fp := "abc" - m := Manifest{ - FormatVersion: FormatVersion, - CreatedAt: time.Date(2026, 9, 7, 12, 0, 0, 0, time.UTC), - VantageVersion: "dev", - Hostname: "box", - MongoDB: "vantage", - MongoServerVersion: "7.0.5", - KeyFingerprint: &fp, - Collections: []CollectionEntry{{Name: "servers", Documents: 3, Bytes: 120, SHA256: "dead"}}, - Excluded: []string{"audit_logs"}, - } - raw, err := json.Marshal(m) - if err != nil { - t.Fatalf("marshal: %v", err) - } - for _, want := range []string{ - `"format_version":1`, `"created_at":"2026-09-07T12:00:00Z"`, - `"key_fingerprint":"abc"`, `"mongo_server_version":"7.0.5"`, - `"excluded":["audit_logs"]`, - } { - if !strings.Contains(string(raw), want) { - t.Fatalf("manifest JSON missing %s\ngot: %s", want, raw) - } - } -} - -func TestManifestNullFingerprint(t *testing.T) { - raw, err := json.Marshal(Manifest{FormatVersion: FormatVersion}) - if err != nil { - t.Fatalf("marshal: %v", err) - } - if !strings.Contains(string(raw), `"key_fingerprint":null`) { - t.Fatalf("absent key must marshal as null, got: %s", raw) - } -} - -func TestManifestCheckRejectsOtherVersions(t *testing.T) { - if err := (Manifest{FormatVersion: FormatVersion}).Check(); err != nil { - t.Fatalf("current version rejected: %v", err) - } - for _, v := range []int{0, 2, 99} { - if err := (Manifest{FormatVersion: v}).Check(); !errors.Is(err, ErrUnknownFormat) { - t.Fatalf("version %d: got %v, want ErrUnknownFormat", v, err) - } - } -} - -func TestManifestCollectionLookup(t *testing.T) { - m := Manifest{Collections: []CollectionEntry{{Name: "keys", Documents: 1}}} - if _, ok := m.Collection("keys"); !ok { - t.Fatal("known collection not found") - } - if _, ok := m.Collection("nope"); ok { - t.Fatal("unknown collection reported as found") - } -} - -func TestCiphertextCollections(t *testing.T) { - got := CiphertextCollections() - want := []string{"keys", "secrets", "auth_providers", "console_sessions"} - if len(got) != len(want) { - t.Fatalf("got %v, want %v", got, want) - } - for i := range want { - if got[i] != want[i] { - t.Fatalf("got %v, want %v", got, want) - } - } -} - -// TestManifestRefusesUnusableCollectionNames covers names being used as path -// components inside the extraction directory. An archive is operator-supplied -// input and may not be one we wrote. -func TestManifestRefusesUnusableCollectionNames(t *testing.T) { - for _, name := range []string{"", ".", "..", "../etc/passwd", "a/b", "a..b"} { - m := Manifest{ - FormatVersion: FormatVersion, - Collections: []CollectionEntry{{Name: name}}, - } - if err := m.Check(); !errors.Is(err, ErrBadCollectionName) { - t.Fatalf("collection name %q was accepted (err %v)", name, err) - } - } - m := Manifest{ - FormatVersion: FormatVersion, - Collections: []CollectionEntry{{Name: "workflow_log_lines"}}, - } - if err := m.Check(); err != nil { - t.Fatalf("an ordinary collection name was refused: %v", err) - } -} diff --git a/shared/backup/mongo_test.go b/shared/backup/mongo_test.go deleted file mode 100644 index b9b2081..0000000 --- a/shared/backup/mongo_test.go +++ /dev/null @@ -1,44 +0,0 @@ -package backup - -import ( - "context" - "fmt" - "os" - "testing" - "time" - - "go.mongodb.org/mongo-driver/v2/mongo" - "go.mongodb.org/mongo-driver/v2/mongo/options" -) - -// testDB connects to the MongoDB named by MONGO_TEST_URI and returns a client -// plus a database name unique to this test, dropped when the test ends. -// -// Skips rather than fails when the variable is unset: these tests need a real -// server, and a developer without one should still be able to run the rest of -// the suite. -func testDB(t *testing.T) (*mongo.Client, string) { - t.Helper() - uri := os.Getenv("MONGO_TEST_URI") - if uri == "" { - t.Skip("MONGO_TEST_URI is not set; skipping tests that need MongoDB") - } - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - client, err := mongo.Connect(options.Client().ApplyURI(uri)) - if err != nil { - t.Fatalf("connect: %v", err) - } - if err := client.Ping(ctx, nil); err != nil { - t.Fatalf("ping: %v", err) - } - name := fmt.Sprintf("vantage_test_%d", time.Now().UnixNano()) - t.Cleanup(func() { - c, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - _ = client.Database(name).Drop(c) - _ = client.Disconnect(c) - }) - return client, name -} diff --git a/shared/backup/restore.go b/shared/backup/restore.go deleted file mode 100644 index 31ed217..0000000 --- a/shared/backup/restore.go +++ /dev/null @@ -1,393 +0,0 @@ -package backup - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "io" - "sort" - "strings" - - "go.mongodb.org/mongo-driver/v2/bson" - "go.mongodb.org/mongo-driver/v2/mongo" - "go.mongodb.org/mongo-driver/v2/mongo/options" -) - -// BatchSize is how many documents are inserted per bulk write. -const BatchSize = 1000 - -// ErrTargetNotEmpty is returned when the target database already holds data and -// Force was not set. -var ErrTargetNotEmpty = errors.New("target database is not empty") - -// ErrKeyMismatch is returned when the archive's key fingerprint does not match -// the key supplied. -var ErrKeyMismatch = errors.New("KEY_ENCRYPTION_KEY does not match the archive") - -// ErrIndexBuild is returned when a unique index in the archive cannot be built -// on the restored data. -var ErrIndexBuild = errors.New("index could not be built on the restored data") - -// RestoredCollection is what one collection's restore produced. -type RestoredCollection struct { - Name string - Documents int64 - Indexes int -} - -// RestoreResult is the summary a caller prints. -type RestoreResult struct { - Collections []RestoredCollection -} - -// RestoreOptions configures one restore. -type RestoreOptions struct { - Client *mongo.Client - Database string - Archive *Reader - - // Force drops each collection in the archive before loading it. Without it - // a non-empty target is refused. - Force bool - - KeyHex string - - // IgnoreKeyMismatch proceeds past a fingerprint mismatch, having first - // warned which collections will hold unreadable ciphertext afterwards. - IgnoreKeyMismatch bool - - // Warn receives operator-facing warnings. A nil Warn discards them. - Warn func(string) -} - -func (o RestoreOptions) warn(format string, args ...any) { - if o.Warn != nil { - o.Warn(fmt.Sprintf(format, args...)) - } -} - -// Restore loads an archive into a database. -// -// The order is fixed and every check that can refuse does so before the first -// write: format, checksums (done by Open), key policy, then target inspection. -// A restore that has begun writing and then fails leaves a partial database -// which the next run refuses to touch, which is correct — the alternative is a -// silent merge, and merging two control planes reconciles nothing. -func Restore(ctx context.Context, opt RestoreOptions) (RestoreResult, error) { - m := opt.Archive.Manifest() - - if err := checkKey(m, opt); err != nil { - return RestoreResult{}, err - } - if err := checkTarget(ctx, opt); err != nil { - return RestoreResult{}, err - } - if err := warnLeftovers(ctx, opt, m); err != nil { - return RestoreResult{}, err - } - if len(m.Excluded) > 0 { - opt.warn("this archive excluded %s; those collections will be empty after the restore", - strings.Join(m.Excluded, ", ")) - } - opt.warn("Redis is not restored. Sessions are the only state it holds, so everyone signs in again.") - - res := RestoreResult{} - if err := warnVersionGap(ctx, opt, m); err != nil { - return res, err - } - - for _, entry := range m.Collections { - rc, err := restoreCollection(ctx, opt, entry) - if err != nil { - return res, err - } - res.Collections = append(res.Collections, rc) - } - return res, nil -} - -// checkKey applies the fingerprint policy. -func checkKey(m Manifest, opt RestoreOptions) error { - if m.KeyFingerprint == nil { - opt.warn("this archive carries no key fingerprint, so nothing here proves your " + - "KEY_ENCRYPTION_KEY opens its ciphertext") - return nil - } - if opt.KeyHex == "" { - return fmt.Errorf("%w: the archive records a key fingerprint, so a key is required "+ - "(pass --ignore-key-mismatch only if you accept unreadable secrets)", ErrNoKey) - } - got, err := FingerprintHex(opt.KeyHex) - if err != nil { - return err - } - if got == *m.KeyFingerprint { - return nil - } - if !opt.IgnoreKeyMismatch { - return fmt.Errorf("%w: archive fingerprint %s, your key fingerprints as %s", - ErrKeyMismatch, *m.KeyFingerprint, got) - } - opt.warn("proceeding past a key mismatch: ciphertext in %s will be permanently unreadable", - strings.Join(CiphertextCollections(), ", ")) - return nil -} - -// checkTarget refuses a non-empty database unless Force was set. -func checkTarget(ctx context.Context, opt RestoreOptions) error { - db := opt.Client.Database(opt.Database) - names, err := db.ListCollectionNames(ctx, bson.M{}) - if err != nil { - return fmt.Errorf("inspect target: %w", err) - } - if len(names) == 0 || opt.Force { - return nil - } - sort.Strings(names) - - var found []string - for _, n := range names { - count, err := db.Collection(n).CountDocuments(ctx, bson.M{}) - if err != nil { - return fmt.Errorf("count %s: %w", n, err) - } - found = append(found, fmt.Sprintf("%s (%d)", n, count)) - } - return fmt.Errorf("%w: %s holds %s", ErrTargetNotEmpty, opt.Database, strings.Join(found, ", ")) -} - -// warnLeftovers names the collections already in the target that the archive -// does not carry. -// -// They are named rather than dropped. A --force restore of an archive taken -// with --exclude workflow_log_lines leaves the old logs joined to restored -// runs, which the operator must know; but dropping a collection the archive -// never mentioned would delete data nobody asked to delete, and there is no -// way back from that. -func warnLeftovers(ctx context.Context, opt RestoreOptions, m Manifest) error { - if !opt.Force { - // Without Force the target was already proven empty. - return nil - } - names, err := opt.Client.Database(opt.Database).ListCollectionNames(ctx, bson.M{}) - if err != nil { - return fmt.Errorf("inspect target: %w", err) - } - inArchive := map[string]bool{} - for _, c := range m.Collections { - inArchive[c.Name] = true - } - var leftover []string - for _, n := range names { - if !inArchive[n] { - leftover = append(leftover, n) - } - } - if len(leftover) == 0 { - return nil - } - sort.Strings(leftover) - opt.warn("this archive does not carry %s, which already exist in %s and are left "+ - "untouched: their contents will sit alongside the restored data", - strings.Join(leftover, ", "), opt.Database) - return nil -} - -// warnVersionGap reports a major version difference between the server that -// produced the archive and the one receiving it. It warns rather than refuses: -// restoring across a major version is a normal part of an upgrade, and a tool -// that refused would be blocking the migration it exists to make safe. -func warnVersionGap(ctx context.Context, opt RestoreOptions, m Manifest) error { - if m.MongoServerVersion == "" { - return nil - } - target, err := mongoServerVersion(ctx, opt.Client) - if err != nil { - return err - } - if majorOf(m.MongoServerVersion) != majorOf(target) { - opt.warn("this archive came from MongoDB %s and you are restoring onto %s", - m.MongoServerVersion, target) - } - return nil -} - -func majorOf(version string) string { - if i := strings.IndexByte(version, '.'); i >= 0 { - return version[:i] - } - return version -} - -func restoreCollection(ctx context.Context, opt RestoreOptions, entry CollectionEntry) (RestoredCollection, error) { - coll := opt.Client.Database(opt.Database).Collection(entry.Name) - if opt.Force { - if err := coll.Drop(ctx); err != nil { - return RestoredCollection{}, fmt.Errorf("drop %s: %w", entry.Name, err) - } - } - - written, err := insertDocuments(ctx, opt, coll, entry) - if err != nil { - return RestoredCollection{}, err - } - indexes, err := replayIndexes(ctx, opt, coll, entry.Name) - if err != nil { - return RestoredCollection{}, err - } - return RestoredCollection{Name: entry.Name, Documents: written, Indexes: indexes}, nil -} - -func insertDocuments(ctx context.Context, opt RestoreOptions, coll *mongo.Collection, entry CollectionEntry) (int64, error) { - rc, err := opt.Archive.OpenCollection(entry.Name) - if err != nil { - return 0, fmt.Errorf("open %s in archive: %w", entry.Name, err) - } - defer rc.Close() - - raw, err := io.ReadAll(rc) - if err != nil { - return 0, fmt.Errorf("read %s: %w", entry.Name, err) - } - - var written int64 - batch := make([]any, 0, BatchSize) - flush := func() error { - if len(batch) == 0 { - return nil - } - if _, err := coll.InsertMany(ctx, batch, options.InsertMany().SetOrdered(false)); err != nil { - return fmt.Errorf("insert into %s: %w", entry.Name, err) - } - written += int64(len(batch)) - batch = batch[:0] - return nil - } - - for len(raw) > 0 { - doc, rest, err := splitBSON(raw) - if err != nil { - return 0, fmt.Errorf("%s: %w", entry.Name, err) - } - batch = append(batch, doc) - raw = rest - if len(batch) == BatchSize { - if err := flush(); err != nil { - return 0, err - } - } - } - if err := flush(); err != nil { - return 0, err - } - return written, nil -} - -// splitBSON peels one document off the front of a concatenated BSON stream. A -// BSON document declares its own length in its first four bytes. -func splitBSON(raw []byte) (bson.Raw, []byte, error) { - if len(raw) < 4 { - return nil, nil, fmt.Errorf("truncated BSON: %d trailing bytes", len(raw)) - } - n := int(int32(raw[0]) | int32(raw[1])<<8 | int32(raw[2])<<16 | int32(raw[3])<<24) - if n < 5 || n > len(raw) { - return nil, nil, fmt.Errorf("BSON document declares length %d with %d bytes remaining", n, len(raw)) - } - return bson.Raw(raw[:n]), raw[n:], nil -} - -// replayIndexes recreates the archived indexes. -// -// The specs are handed to the createIndexes command exactly as the source -// server reported them, rather than reconstructed into a mongo.IndexModel from -// a hand-picked set of options. Reconstruction dropped every option nobody had -// thought to pick — partialFilterExpression above all, which this codebase -// relies on for partial unique indexes, and which replayed as a full unique -// index fails on any real database. It also lost compound key order, which is -// significant. -// -// A unique index that will not build means the restored data violates it, and -// the unique indexes here — (instance_id, email), instance slug, settings -// instance, the ESO token hash — are tenant-isolation properties rather than -// optimisations. That aborts. A non-unique index failing is a performance -// problem and warns. -func replayIndexes(ctx context.Context, opt RestoreOptions, coll *mongo.Collection, name string) (int, error) { - raw, err := opt.Archive.IndexesJSON(name) - if err != nil { - return 0, fmt.Errorf("read index specs for %s: %w", name, err) - } - if len(raw) == 0 { - return 0, nil - } - var encoded []json.RawMessage - if err := json.Unmarshal(raw, &encoded); err != nil { - return 0, fmt.Errorf("parse index specs for %s: %w", name, err) - } - - db := coll.Database() - created := 0 - for _, ej := range encoded { - spec, indexName, unique, ok, err := indexSpecFrom(ej) - if err != nil { - return created, fmt.Errorf("parse index specs for %s: %w", name, err) - } - if !ok { - continue - } - cmd := bson.D{ - {Key: "createIndexes", Value: name}, - {Key: "indexes", Value: bson.A{spec}}, - } - if err := db.RunCommand(ctx, cmd).Err(); err != nil { - if unique { - return created, fmt.Errorf("%w: %s on %s: %v", ErrIndexBuild, indexName, name, err) - } - opt.warn("index %s on %s was not created: %v", indexName, name, err) - continue - } - created++ - } - return created, nil -} - -// droppedIndexSpecFields are the fields the server reports on an existing index -// but rejects when creating one. Everything else is passed through untouched. -var droppedIndexSpecFields = map[string]bool{"v": true, "ns": true} - -// indexSpecFrom decodes one archived extended-JSON index specification into an -// ordered bson.D suitable for createIndexes. -// -// The _id_ index is skipped: MongoDB creates it itself and refuses an explicit -// attempt to create it. -func indexSpecFrom(ej []byte) (bson.D, string, bool, bool, error) { - var d bson.D - if err := bson.UnmarshalExtJSON(ej, false, &d); err != nil { - return nil, "", false, false, err - } - - out := make(bson.D, 0, len(d)) - var name string - unique := false - hasKey := false - for _, e := range d { - switch e.Key { - case "name": - name, _ = e.Value.(string) - case "unique": - if u, ok := e.Value.(bool); ok { - unique = u - } - case "key": - hasKey = true - } - if droppedIndexSpecFields[e.Key] { - continue - } - out = append(out, e) - } - if name == "_id_" || !hasKey { - return nil, name, false, false, nil - } - return out, name, unique, true, nil -} diff --git a/shared/backup/restore_test.go b/shared/backup/restore_test.go deleted file mode 100644 index 0272d26..0000000 --- a/shared/backup/restore_test.go +++ /dev/null @@ -1,439 +0,0 @@ -package backup - -import ( - "context" - "errors" - "os" - "path/filepath" - "strings" - "testing" - "time" - - "go.mongodb.org/mongo-driver/v2/bson" - "go.mongodb.org/mongo-driver/v2/mongo" - "go.mongodb.org/mongo-driver/v2/mongo/options" -) - -// archiveOf seeds a database, dumps it, and returns an opened Reader. -func archiveOf(t *testing.T, client *mongo.Client, keyHex string, allowNoKey bool) *Reader { - t.Helper() - _, srcDB := testDB(t) - seed(t, client, srcDB) - path, _ := dumpToFile(t, DumpOptions{ - Client: client, Database: srcDB, KeyHex: keyHex, AllowNoKey: allowNoKey, - }) - r, err := Open(path) - if err != nil { - t.Fatalf("Open: %v", err) - } - t.Cleanup(func() { r.Close() }) - return r -} - -func countIn(t *testing.T, client *mongo.Client, dbName, coll string) int64 { - t.Helper() - n, err := client.Database(dbName).Collection(coll).CountDocuments(context.Background(), bson.M{}) - if err != nil { - t.Fatalf("count %s: %v", coll, err) - } - return n -} - -func TestRestoreIntoEmptyDatabase(t *testing.T) { - client, _ := testDB(t) - archive := archiveOf(t, client, validKeyHex, false) - _, target := testDB(t) - - res, err := Restore(context.Background(), RestoreOptions{ - Client: client, Database: target, Archive: archive, KeyHex: validKeyHex, - }) - if err != nil { - t.Fatalf("Restore: %v", err) - } - if countIn(t, client, target, "servers") != 2 { - t.Fatal("servers not restored") - } - if len(res.Collections) == 0 { - t.Fatal("result reports no collections") - } -} - -func TestRestoreRefusesNonEmptyTarget(t *testing.T) { - client, _ := testDB(t) - archive := archiveOf(t, client, validKeyHex, false) - _, target := testDB(t) - if _, err := client.Database(target).Collection("servers"). - InsertOne(context.Background(), bson.M{"name": "existing"}); err != nil { - t.Fatalf("seed target: %v", err) - } - - _, err := Restore(context.Background(), RestoreOptions{ - Client: client, Database: target, Archive: archive, KeyHex: validKeyHex, - }) - if !errors.Is(err, ErrTargetNotEmpty) { - t.Fatalf("got %v, want ErrTargetNotEmpty", err) - } - if countIn(t, client, target, "servers") != 1 { - t.Fatal("a refused restore modified the target") - } -} - -func TestRestoreForceReplaces(t *testing.T) { - client, _ := testDB(t) - archive := archiveOf(t, client, validKeyHex, false) - _, target := testDB(t) - if _, err := client.Database(target).Collection("servers"). - InsertOne(context.Background(), bson.M{"name": "existing"}); err != nil { - t.Fatalf("seed target: %v", err) - } - - if _, err := Restore(context.Background(), RestoreOptions{ - Client: client, Database: target, Archive: archive, KeyHex: validKeyHex, Force: true, - }); err != nil { - t.Fatalf("Restore --force: %v", err) - } - if got := countIn(t, client, target, "servers"); got != 2 { - t.Fatalf("servers has %d documents, want 2; force must drop, not merge", got) - } - n, err := client.Database(target).Collection("servers"). - CountDocuments(context.Background(), bson.M{"name": "existing"}) - if err != nil { - t.Fatalf("count: %v", err) - } - if n != 0 { - t.Fatal("the pre-existing document survived --force") - } -} - -func TestRestoreRefusesKeyMismatch(t *testing.T) { - client, _ := testDB(t) - archive := archiveOf(t, client, validKeyHex, false) - _, target := testDB(t) - other := "0000000000000000000000000000000000000000000000000000000000000002" - - _, err := Restore(context.Background(), RestoreOptions{ - Client: client, Database: target, Archive: archive, KeyHex: other, - }) - if !errors.Is(err, ErrKeyMismatch) { - t.Fatalf("got %v, want ErrKeyMismatch", err) - } - names, err := client.Database(target).ListCollectionNames(context.Background(), bson.M{}) - if err != nil { - t.Fatalf("list: %v", err) - } - if len(names) != 0 { - t.Fatalf("a refused restore wrote %v", names) - } -} - -func TestRestoreRefusesWhenArchiveHasKeyAndEnvironmentDoesNot(t *testing.T) { - client, _ := testDB(t) - archive := archiveOf(t, client, validKeyHex, false) - _, target := testDB(t) - - _, err := Restore(context.Background(), RestoreOptions{ - Client: client, Database: target, Archive: archive, - }) - if !errors.Is(err, ErrNoKey) { - t.Fatalf("got %v, want ErrNoKey", err) - } -} - -func TestRestoreIgnoreKeyMismatchWarnsAndProceeds(t *testing.T) { - client, _ := testDB(t) - archive := archiveOf(t, client, validKeyHex, false) - _, target := testDB(t) - other := "0000000000000000000000000000000000000000000000000000000000000002" - - var warnings []string - if _, err := Restore(context.Background(), RestoreOptions{ - Client: client, Database: target, Archive: archive, - KeyHex: other, IgnoreKeyMismatch: true, - Warn: func(s string) { warnings = append(warnings, s) }, - }); err != nil { - t.Fatalf("Restore: %v", err) - } - joined := strings.Join(warnings, "\n") - for _, name := range CiphertextCollections() { - if !strings.Contains(joined, name) { - t.Fatalf("warning does not name %s\ngot:\n%s", name, joined) - } - } - if countIn(t, client, target, "servers") != 2 { - t.Fatal("restore did not proceed") - } -} - -func TestRestoreNullFingerprintIsReportedNotAssumed(t *testing.T) { - client, _ := testDB(t) - archive := archiveOf(t, client, "", true) - _, target := testDB(t) - - var warnings []string - if _, err := Restore(context.Background(), RestoreOptions{ - Client: client, Database: target, Archive: archive, KeyHex: validKeyHex, - Warn: func(s string) { warnings = append(warnings, s) }, - }); err != nil { - t.Fatalf("Restore: %v", err) - } - if !strings.Contains(strings.Join(warnings, "\n"), "no key fingerprint") { - t.Fatalf("a null fingerprint must be reported, got: %v", warnings) - } -} - -func TestRestoreReplaysIndexes(t *testing.T) { - client, _ := testDB(t) - ctx := context.Background() - _, srcDB := testDB(t) - seed(t, client, srcDB) - if _, err := client.Database(srcDB).Collection("servers").Indexes(). - CreateOne(ctx, mongo.IndexModel{Keys: bson.D{{Key: "name", Value: 1}}}); err != nil { - t.Fatalf("create index: %v", err) - } - path, _ := dumpToFile(t, DumpOptions{Client: client, Database: srcDB, KeyHex: validKeyHex}) - archive, err := Open(path) - if err != nil { - t.Fatalf("Open: %v", err) - } - defer archive.Close() - - _, target := testDB(t) - res, err := Restore(ctx, RestoreOptions{ - Client: client, Database: target, Archive: archive, KeyHex: validKeyHex, - }) - if err != nil { - t.Fatalf("Restore: %v", err) - } - - cur, err := client.Database(target).Collection("servers").Indexes().List(ctx) - if err != nil { - t.Fatalf("list indexes: %v", err) - } - var specs []bson.M - if err := cur.All(ctx, &specs); err != nil { - t.Fatalf("read indexes: %v", err) - } - found := false - for _, s := range specs { - if s["name"] == "name_1" { - found = true - } - } - if !found { - t.Fatalf("index name_1 not replayed; got %v", specs) - } - for _, c := range res.Collections { - if c.Name == "servers" && c.Indexes < 1 { - t.Fatal("result reports no indexes created for servers") - } - } -} - -// TestRestoreAbortsWhenAUniqueIndexCannotBuild replaces the brief's -// TestRestoreAbortsOnUniqueIndexViolation per ruling 1: creating an index on -// the target also creates the collection, so that version's assertion -// (err == nil) would have passed on the wrong error (ErrTargetNotEmpty), and -// Force: true does not rescue it because the drop removes the index before -// replayIndexes runs. This version builds a hand-made archive whose data and -// index specification directly contradict each other, which is the actual -// shape of a corrupted archive that replayIndexes must refuse to load. -func TestRestoreAbortsWhenAUniqueIndexCannotBuild(t *testing.T) { - client, _ := testDB(t) - ctx := context.Background() - - // Built by hand rather than dumped: two documents that collide on email - // alongside an index specification declaring email unique. No live database - // would let those coexist, which is exactly the point — this is the shape - // of a corrupted or hand-edited archive, and restore must refuse rather - // than load the rows and leave the index missing. - a, err := bson.Marshal(bson.M{"email": "a@example.com"}) - if err != nil { - t.Fatalf("marshal: %v", err) - } - b, err := bson.Marshal(bson.M{"email": "a@example.com"}) - if err != nil { - t.Fatalf("marshal: %v", err) - } - - path := filepath.Join(t.TempDir(), "dupes.tar.gz") - f, err := os.Create(path) - if err != nil { - t.Fatalf("create: %v", err) - } - w := NewWriter(f) - entry, err := w.WriteCollection("users", [][]byte{a, b}) - if err != nil { - t.Fatalf("WriteCollection: %v", err) - } - if err := w.WriteIndexes("users", - []byte(`[{"name":"email_1","key":{"email":1},"unique":true}]`)); err != nil { - t.Fatalf("WriteIndexes: %v", err) - } - fp, err := FingerprintHex(validKeyHex) - if err != nil { - t.Fatalf("FingerprintHex: %v", err) - } - if err := w.Close(Manifest{ - FormatVersion: FormatVersion, - CreatedAt: time.Now().UTC(), - MongoDB: "handmade", - KeyFingerprint: &fp, - Collections: []CollectionEntry{entry}, - }); err != nil { - t.Fatalf("Close: %v", err) - } - if err := f.Close(); err != nil { - t.Fatalf("close: %v", err) - } - - archive, err := Open(path) - if err != nil { - t.Fatalf("Open: %v", err) - } - defer archive.Close() - - _, target := testDB(t) - if _, err := Restore(ctx, RestoreOptions{ - Client: client, Database: target, Archive: archive, KeyHex: validKeyHex, - }); !errors.Is(err, ErrIndexBuild) { - t.Fatalf("got %v, want ErrIndexBuild", err) - } else if !strings.Contains(err.Error(), "email_1") { - t.Fatalf("the error must name the offending index, got: %v", err) - } -} - -func TestRestoreSameVersionDoesNotWarnAboutIt(t *testing.T) { - client, _ := testDB(t) - archive := archiveOf(t, client, validKeyHex, false) - _, target := testDB(t) - - var warnings []string - if _, err := Restore(context.Background(), RestoreOptions{ - Client: client, Database: target, Archive: archive, KeyHex: validKeyHex, - Warn: func(s string) { warnings = append(warnings, s) }, - }); err != nil { - t.Fatalf("Restore: %v", err) - } - for _, w := range warnings { - if strings.Contains(w, "you are restoring onto") { - t.Fatalf("same-version restore warned about a version gap: %s", w) - } - } -} - -func TestCompoundIndexKeyOrderIsPreserved(t *testing.T) { - spec, name, unique, ok, err := indexSpecFrom([]byte( - `{"v":2,"key":{"b":1,"a":1},"name":"b_1_a_1","ns":"db.c"}`)) - if err != nil { - t.Fatalf("indexSpecFrom: %v", err) - } - if !ok { - t.Fatal("spec rejected") - } - if unique { - t.Fatal("index reported as unique") - } - if name != "b_1_a_1" { - t.Fatalf("name %q", name) - } - - var keys bson.D - for _, e := range spec { - switch e.Key { - case "v", "ns": - t.Fatalf("%q must be stripped before createIndexes, got %v", e.Key, spec) - case "key": - d, isD := e.Value.(bson.D) - if !isD { - t.Fatalf("key is %T, want bson.D", e.Value) - } - keys = d - } - } - // Compound index key order is significant, so it is carried through - // verbatim rather than reconstructed from an unordered map. - if len(keys) != 2 || keys[0].Key != "b" || keys[1].Key != "a" { - t.Fatalf("key order not preserved, got %v", keys) - } -} - -func TestIdIndexIsSkipped(t *testing.T) { - _, name, _, ok, err := indexSpecFrom([]byte(`{"v":2,"key":{"_id":1},"name":"_id_"}`)) - if err != nil { - t.Fatalf("indexSpecFrom: %v", err) - } - if ok { - t.Fatal("_id_ must be skipped; MongoDB creates it itself") - } - if name != "_id_" { - t.Fatalf("name %q", name) - } -} - -// TestRestoreReplaysPartialUniqueIndex is the regression guard for the defect -// that made a restore abort on any real database: a partial unique index — -// this codebase has them on workflow_steps and settings — replayed as a full -// unique index hits duplicate keys, and a failing unique index is fatal. -func TestRestoreReplaysPartialUniqueIndex(t *testing.T) { - client, _ := testDB(t) - ctx := context.Background() - _, srcDB := testDB(t) - seed(t, client, srcDB) - - coll := client.Database(srcDB).Collection("workflow_steps") - docs := []any{ - bson.M{"instance_id": "i1", "slug": "same", "source": "default"}, - bson.M{"instance_id": "i1", "slug": "same", "source": "custom"}, - bson.M{"instance_id": "i1", "slug": "same", "source": "custom"}, - } - if _, err := coll.InsertMany(ctx, docs); err != nil { - t.Fatalf("insert: %v", err) - } - if _, err := coll.Indexes().CreateOne(ctx, mongo.IndexModel{ - Keys: bson.D{{Key: "instance_id", Value: 1}, {Key: "slug", Value: 1}}, - Options: options.Index().SetName("default_step_slug").SetUnique(true). - SetPartialFilterExpression(bson.M{"source": "default"}), - }); err != nil { - t.Fatalf("create partial index: %v", err) - } - - path, _ := dumpToFile(t, DumpOptions{Client: client, Database: srcDB, KeyHex: validKeyHex}) - archive, err := Open(path) - if err != nil { - t.Fatalf("Open: %v", err) - } - defer archive.Close() - - _, target := testDB(t) - if _, err := Restore(ctx, RestoreOptions{ - Client: client, Database: target, Archive: archive, KeyHex: validKeyHex, - }); err != nil { - t.Fatalf("Restore: %v", err) - } - - cur, err := client.Database(target).Collection("workflow_steps").Indexes().List(ctx) - if err != nil { - t.Fatalf("list indexes: %v", err) - } - var specs []bson.M - if err := cur.All(ctx, &specs); err != nil { - t.Fatalf("read indexes: %v", err) - } - for _, s := range specs { - if s["name"] != "default_step_slug" { - continue - } - if s["unique"] != true { - t.Fatalf("index lost its uniqueness: %v", s) - } - if s["partialFilterExpression"] == nil { - t.Fatalf("partialFilterExpression was dropped: %v", s) - } - keys, isD := s["key"].(bson.D) - if isD && (len(keys) != 2 || keys[0].Key != "instance_id" || keys[1].Key != "slug") { - t.Fatalf("compound key order not preserved: %v", keys) - } - return - } - t.Fatalf("partial unique index not replayed; got %v", specs) -} diff --git a/shared/backup/verify.go b/shared/backup/verify.go deleted file mode 100644 index 043f603..0000000 --- a/shared/backup/verify.go +++ /dev/null @@ -1,197 +0,0 @@ -package backup - -import ( - "context" - "fmt" - - "gitea.hostxtra.co.uk/vantage/vantage-shared/cryptobox" - "go.mongodb.org/mongo-driver/v2/bson" - "go.mongodb.org/mongo-driver/v2/mongo" -) - -// VerifyOptions configures a verification. Client and Database are optional; -// supplying them turns on the live probe. -type VerifyOptions struct { - Archive *Reader - KeyHex string - Client *mongo.Client - Database string -} - -// VerifyReport is what verify found. -type VerifyReport struct { - ArchiveFingerprint *string - KeyFingerprint *string - KeyMatchesArchive bool - - // ProbeAttempted is false when no client was supplied, and also when the - // database holds no ciphertext to probe. - ProbeAttempted bool - ProbeCollection string - ProbeDecrypted bool - - Problems []string -} - -// OK reports whether this archive is usable with the key in hand. -func (r VerifyReport) OK() bool { return len(r.Problems) == 0 } - -func (r *VerifyReport) problem(format string, args ...any) { - r.Problems = append(r.Problems, fmt.Sprintf(format, args...)) -} - -// Verify checks an already-opened archive against the key in hand and, when a -// client is supplied, against a live database. -// -// Open has already verified every member's checksum, so integrity is not -// rechecked here. What this adds is the question an operator actually has: -// will the key I hold open the data this archive carries. A fingerprint -// comparison proves two archives agree; only the probe proves the key opens -// real ciphertext. -func Verify(ctx context.Context, opt VerifyOptions) (VerifyReport, error) { - m := opt.Archive.Manifest() - rep := VerifyReport{ArchiveFingerprint: m.KeyFingerprint} - - if opt.KeyHex != "" { - fp, err := FingerprintHex(opt.KeyHex) - if err != nil { - return rep, err - } - rep.KeyFingerprint = &fp - } - - switch { - case m.KeyFingerprint == nil && rep.KeyFingerprint == nil: - rep.problem("neither the archive nor this environment names a key; nothing here " + - "proves the archive's ciphertext can ever be read") - case m.KeyFingerprint == nil: - rep.problem("the archive carries no key fingerprint, so it cannot be matched " + - "against the key you hold") - case rep.KeyFingerprint == nil: - rep.problem("KEY_ENCRYPTION_KEY is not set, so the archive's fingerprint %s "+ - "cannot be checked against anything", *m.KeyFingerprint) - case *m.KeyFingerprint == *rep.KeyFingerprint: - rep.KeyMatchesArchive = true - default: - rep.problem("key mismatch: archive fingerprint %s, your key fingerprints as %s", - *m.KeyFingerprint, *rep.KeyFingerprint) - } - - if opt.Client == nil || opt.Database == "" || opt.KeyHex == "" { - return rep, nil - } - if err := probe(ctx, opt, &rep); err != nil { - return rep, err - } - return rep, nil -} - -// probe reads one ciphertext field from the live database and tries to open it. -func probe(ctx context.Context, opt VerifyOptions, rep *VerifyReport) error { - key, err := ParseKey(opt.KeyHex) - if err != nil { - return err - } - for _, coll := range CiphertextCollections() { - ciphertext, ok, err := findCiphertext(ctx, opt.Client.Database(opt.Database), coll) - if err != nil { - return err - } - if !ok { - continue - } - rep.ProbeAttempted = true - rep.ProbeCollection = coll - if _, err := cryptobox.Open(key, ciphertext); err != nil { - rep.problem("the key in hand does not decrypt live ciphertext in %s", coll) - return nil - } - rep.ProbeDecrypted = true - return nil - } - // No ciphertext anywhere is an ordinary state — a deployment that has - // stored no secrets, keys or SSO configuration yet — and is not a failure. - return nil -} - -// ciphertextFields names, per collection, the fields that hold hex ciphertext. -// A value is a candidate only if it is a hex string long enough to carry a GCM -// nonce and tag, which is what keeps this from probing a plaintext field. -// -// This map MIRRORS BY HAND the bson tags in server/internal/models, which this -// package cannot import: shared/ is a separate module and models is under -// server/internal. It must change in the same commit as any rename of the -// fields below — the same mirrored-constant hazard as web/lib/targets.ts and -// services.MaxWorkloadLogLines. The sources are: -// -// keys — models/key.go: private_key_enc, passphrase_enc -// secrets — models/secret.go: encrypted_value -// auth_providers — models/auth_provider.go: client_secret_enc -// console_sessions — models/console_session.go: rdp_user_enc, rdp_pass_enc -// -// settings is deliberately absent: it holds no ciphertext at all. The ESO read -// token is stored as a SHA-256 hash, which no key opens. -var ciphertextFields = map[string][]string{ - "keys": {"private_key_enc", "passphrase_enc"}, - "secrets": {"encrypted_value"}, - "auth_providers": {"client_secret_enc"}, - "console_sessions": {"rdp_user_enc", "rdp_pass_enc"}, -} - -func findCiphertext(ctx context.Context, db *mongo.Database, coll string) (string, bool, error) { - fields, ok := ciphertextFields[coll] - if !ok { - return "", false, nil - } - cur, err := db.Collection(coll).Find(ctx, bson.M{}) - if err != nil { - return "", false, fmt.Errorf("probe %s: %w", coll, err) - } - defer cur.Close(ctx) - - for cur.Next(ctx) { - var doc bson.M - if err := cur.Decode(&doc); err != nil { - return "", false, fmt.Errorf("probe %s: %w", coll, err) - } - for _, f := range fields { - if v, ok := looksLikeCiphertext(doc[f]); ok { - return v, true, nil - } - } - } - return "", false, cur.Err() -} - -// looksLikeCiphertext accepts a hex string long enough to be a sealed value. It -// descends into a sub-document so a field that holds a map of sealed values is -// still reachable. -func looksLikeCiphertext(v any) (string, bool) { - switch t := v.(type) { - case string: - // 12-byte nonce plus a 16-byte tag is 56 hex characters before any - // plaintext at all, so anything shorter is not a sealed value. - if len(t) < 56 || !isHex(t) { - return "", false - } - return t, true - case bson.M: - for _, inner := range t { - if s, ok := looksLikeCiphertext(inner); ok { - return s, true - } - } - } - return "", false -} - -func isHex(s string) bool { - for _, c := range s { - switch { - case c >= '0' && c <= '9', c >= 'a' && c <= 'f', c >= 'A' && c <= 'F': - default: - return false - } - } - return true -} diff --git a/shared/backup/verify_test.go b/shared/backup/verify_test.go deleted file mode 100644 index b5c68eb..0000000 --- a/shared/backup/verify_test.go +++ /dev/null @@ -1,156 +0,0 @@ -package backup - -import ( - "context" - "testing" - - "gitea.hostxtra.co.uk/vantage/vantage-shared/cryptobox" - "go.mongodb.org/mongo-driver/v2/bson" -) - -func TestVerifyMatchingKey(t *testing.T) { - client, _ := testDB(t) - archive := archiveOf(t, client, validKeyHex, false) - - rep, err := Verify(context.Background(), VerifyOptions{Archive: archive, KeyHex: validKeyHex}) - if err != nil { - t.Fatalf("Verify: %v", err) - } - if !rep.KeyMatchesArchive { - t.Fatal("matching key reported as a mismatch") - } - if rep.ProbeAttempted { - t.Fatal("probe ran with no client supplied") - } - if !rep.OK() { - t.Fatalf("report not OK: %v", rep.Problems) - } -} - -func TestVerifyMismatchedKeyIsNotOK(t *testing.T) { - client, _ := testDB(t) - archive := archiveOf(t, client, validKeyHex, false) - other := "0000000000000000000000000000000000000000000000000000000000000002" - - rep, err := Verify(context.Background(), VerifyOptions{Archive: archive, KeyHex: other}) - if err != nil { - t.Fatalf("Verify: %v", err) - } - if rep.KeyMatchesArchive { - t.Fatal("mismatched key reported as matching") - } - if rep.OK() { - t.Fatal("a mismatch must not report OK") - } -} - -func TestVerifyProbeDecryptsLiveCiphertext(t *testing.T) { - client, dbName := testDB(t) - ctx := context.Background() - - key, err := ParseKey(validKeyHex) - if err != nil { - t.Fatalf("ParseKey: %v", err) - } - sealed, err := cryptobox.Seal(key, "s3cret") - if err != nil { - t.Fatalf("Seal: %v", err) - } - if _, err := client.Database(dbName).Collection("secrets").InsertOne(ctx, bson.M{ - "instance_id": "i1", - "values": bson.M{"TOKEN": sealed}, - }); err != nil { - t.Fatalf("insert: %v", err) - } - - path, _ := dumpToFile(t, DumpOptions{Client: client, Database: dbName, KeyHex: validKeyHex}) - archive, err := Open(path) - if err != nil { - t.Fatalf("Open: %v", err) - } - defer archive.Close() - - rep, err := Verify(ctx, VerifyOptions{ - Archive: archive, KeyHex: validKeyHex, Client: client, Database: dbName, - }) - if err != nil { - t.Fatalf("Verify: %v", err) - } - if !rep.ProbeAttempted { - t.Fatal("probe did not run with a client supplied") - } - if !rep.ProbeDecrypted { - t.Fatalf("probe failed to decrypt live ciphertext: %v", rep.Problems) - } - if rep.ProbeCollection != "secrets" { - t.Fatalf("probe collection %q, want secrets", rep.ProbeCollection) - } - if !rep.OK() { - t.Fatalf("report not OK: %v", rep.Problems) - } -} - -func TestVerifyProbeFailsWithWrongKey(t *testing.T) { - client, dbName := testDB(t) - ctx := context.Background() - - key, err := ParseKey(validKeyHex) - if err != nil { - t.Fatalf("ParseKey: %v", err) - } - sealed, err := cryptobox.Seal(key, "s3cret") - if err != nil { - t.Fatalf("Seal: %v", err) - } - if _, err := client.Database(dbName).Collection("secrets").InsertOne(ctx, bson.M{ - "values": bson.M{"TOKEN": sealed}, - }); err != nil { - t.Fatalf("insert: %v", err) - } - - other := "0000000000000000000000000000000000000000000000000000000000000002" - path, _ := dumpToFile(t, DumpOptions{Client: client, Database: dbName, KeyHex: other}) - archive, err := Open(path) - if err != nil { - t.Fatalf("Open: %v", err) - } - defer archive.Close() - - rep, err := Verify(ctx, VerifyOptions{ - Archive: archive, KeyHex: other, Client: client, Database: dbName, - }) - if err != nil { - t.Fatalf("Verify: %v", err) - } - if rep.ProbeDecrypted { - t.Fatal("probe decrypted with the wrong key") - } - if rep.OK() { - t.Fatal("a failed probe must not report OK") - } -} - -func TestVerifyProbeAbsentCiphertextIsNotAFailure(t *testing.T) { - client, dbName := testDB(t) - seed(t, client, dbName) - - path, _ := dumpToFile(t, DumpOptions{Client: client, Database: dbName, KeyHex: validKeyHex}) - archive, err := Open(path) - if err != nil { - t.Fatalf("Open: %v", err) - } - defer archive.Close() - - rep, err := Verify(context.Background(), VerifyOptions{ - Archive: archive, KeyHex: validKeyHex, Client: client, Database: dbName, - }) - if err != nil { - t.Fatalf("Verify: %v", err) - } - if rep.ProbeAttempted { - t.Fatal("probe claims to have run against a database with no ciphertext") - } - if !rep.OK() { - t.Fatalf("a database storing no secrets must still verify: %v", rep.Problems) - } -} diff --git a/shared/cmd/lkctl/main.go b/shared/cmd/lkctl/main.go deleted file mode 100644 index c83d67e..0000000 --- a/shared/cmd/lkctl/main.go +++ /dev/null @@ -1,178 +0,0 @@ -// Command lkctl issues and inspects Vantage licences by hand. -// -// lkctl keypair -// lkctl issue --instance-id= --instance-name="Acme" --tier=professional --term=1y -// lkctl inspect -// -// issue reads the signing key from LICENSE_SIGNING_KEY. -package main - -import ( - "encoding/json" - "flag" - "fmt" - "os" - "strings" - "time" - - "gitea.hostxtra.co.uk/vantage/vantage-shared/license" - "github.com/google/uuid" - "github.com/hyperboloide/lk" -) - -func main() { - if len(os.Args) < 2 { - usage() - } - switch os.Args[1] { - case "keypair": - keypair() - case "issue": - issue(os.Args[2:]) - case "inspect": - inspect(os.Args[2:]) - default: - usage() - } -} - -func usage() { - fmt.Fprintln(os.Stderr, "usage: lkctl keypair | issue | inspect") - os.Exit(2) -} - -func keypair() { - priv, err := lk.NewPrivateKey() - if err != nil { - fatal("generate key: %v", err) - } - privStr, err := priv.ToB32String() - if err != nil { - fatal("encode private key: %v", err) - } - // PublicKey.ToB32String returns one value, unlike its private counterpart. - pubStr := priv.GetPublicKey().ToB32String() - - fmt.Println("PRIVATE KEY (store in a password manager and in the admin service's") - fmt.Println("LICENSE_SIGNING_KEY; back it up in two places, it cannot be recovered):") - fmt.Println() - fmt.Println(privStr) - fmt.Println() - fmt.Println("PUBLIC KEY (paste into trustedPublicKeys in shared/license/keys.go):") - fmt.Println() - fmt.Println(pubStr) -} - -func issue(args []string) { - fs := flag.NewFlagSet("issue", flag.ExitOnError) - instanceID := fs.String("instance-id", "", "instance UUID the licence is bound to (required)") - instanceName := fs.String("instance-name", "", "display name") - accountID := fs.String("account-id", "", "admin-side account id, optional") - tier := fs.String("tier", "", "free | professional | enterprise (required)") - deployment := fs.String("deployment", "cloud", "cloud | self_hosted") - term := fs.String("term", "1y", "1m or 1y") - expires := fs.String("expires", "", "explicit RFC3339 expiry, overrides --term") - out := fs.String("out", "", "write the blob to this file instead of stdout") - fs.Parse(args) - - if *instanceID == "" || *tier == "" { - fatal("--instance-id and --tier are required") - } - - plan, ok := license.PlanFor(*deployment, *tier) - if !ok { - fatal("no plan for deployment %q tier %q", *deployment, *tier) - } - - key := os.Getenv("LICENSE_SIGNING_KEY") - if key == "" { - fatal("LICENSE_SIGNING_KEY is not set") - } - - now := time.Now().UTC() - var exp time.Time - switch { - case *expires != "": - t, err := time.Parse(time.RFC3339, *expires) - if err != nil { - fatal("parse --expires: %v", err) - } - exp = t.UTC() - case *term == "1m": - exp = now.AddDate(0, 1, 0) - case *term == "1y": - exp = now.AddDate(1, 0, 0) - default: - fatal("--term must be 1m or 1y") - } - - // Self Hosted is sold annually only, so the window in which a cancelled - // licence keeps working is bounded at a year. - if plan.Deployment == license.DeploymentSelfHosted && *term == "1m" && *expires == "" { - fatal("self_hosted is annual only; use --term=1y or an explicit --expires") - } - - name := *instanceName - if name == "" { - name = *instanceID - } - - l := license.License{ - ID: uuid.NewString(), - InstanceID: *instanceID, - AccountID: *accountID, - InstanceName: name, - Tier: plan.Tier, - Deployment: plan.Deployment, - SupportLevel: plan.SupportLevel, - IssuedAt: now, - ExpiresAt: exp, - Limits: plan.Limits, - Features: plan.Features, - } - - blob, err := license.Sign(l, key) - if err != nil { - fatal("%v", err) - } - - if *out != "" { - if err := os.WriteFile(*out, []byte(blob+"\n"), 0o600); err != nil { - fatal("write %s: %v", *out, err) - } - fmt.Fprintf(os.Stderr, "wrote %s (tier=%s deployment=%s expires=%s)\n", - *out, l.Tier, l.Deployment, l.ExpiresAt.Format(time.RFC3339)) - return - } - fmt.Println(blob) -} - -func inspect(args []string) { - if len(args) < 1 { - fatal("usage: lkctl inspect ") - } - blob := args[0] - if b, err := os.ReadFile(blob); err == nil { - blob = strings.TrimSpace(string(b)) - } - - l, err := license.Parse(blob) - if err != nil { - fatal("%v", err) - } - - enc := json.NewEncoder(os.Stdout) - enc.SetIndent("", " ") - if err := enc.Encode(l); err != nil { - fatal("%v", err) - } - - if time.Now().After(l.ExpiresAt) { - fmt.Fprintf(os.Stderr, "\nNOTE: expired %s\n", l.ExpiresAt.Format(time.RFC3339)) - } -} - -func fatal(format string, args ...any) { - fmt.Fprintf(os.Stderr, format+"\n", args...) - os.Exit(1) -} diff --git a/shared/cryptobox/cryptobox.go b/shared/cryptobox/cryptobox.go deleted file mode 100644 index b7edb8b..0000000 --- a/shared/cryptobox/cryptobox.go +++ /dev/null @@ -1,67 +0,0 @@ -// Package cryptobox is the AES-256-GCM primitive used for everything Vantage -// encrypts at rest: SSH private keys, key passphrases, vault secrets, OIDC -// client secrets and console credentials. -// -// It takes a raw key and reads no environment. Key sourcing belongs to the -// caller, because the two callers source it differently: the server reads -// KEY_ENCRYPTION_KEY at the point of use, while vantagectl is handed one. -package cryptobox - -import ( - "crypto/aes" - "crypto/cipher" - "crypto/rand" - "encoding/hex" - "fmt" - "io" -) - -// KeySize is the only key length accepted. AES-256 by construction. -const KeySize = 32 - -func gcmFor(key []byte) (cipher.AEAD, error) { - if len(key) != KeySize { - return nil, fmt.Errorf("key must be %d bytes, got %d", KeySize, len(key)) - } - block, err := aes.NewCipher(key) - if err != nil { - return nil, err - } - return cipher.NewGCM(block) -} - -// Seal encrypts plaintext and returns nonce||ciphertext, hex encoded. -func Seal(key []byte, plaintext string) (string, error) { - gcm, err := gcmFor(key) - if err != nil { - return "", err - } - nonce := make([]byte, gcm.NonceSize()) - if _, err := io.ReadFull(rand.Reader, nonce); err != nil { - return "", err - } - return hex.EncodeToString(gcm.Seal(nonce, nonce, []byte(plaintext), nil)), nil -} - -// Open reverses Seal. Every failure mode returns an error that does not -// distinguish a wrong key from corrupt data, because the caller cannot act on -// the difference and an oracle is worth avoiding for free. -func Open(key []byte, ciphertextHex string) (string, error) { - gcm, err := gcmFor(key) - if err != nil { - return "", err - } - data, err := hex.DecodeString(ciphertextHex) - if err != nil { - return "", fmt.Errorf("invalid ciphertext encoding") - } - n := gcm.NonceSize() - if len(data) < n { - return "", fmt.Errorf("ciphertext too short") - } - plaintext, err := gcm.Open(nil, data[:n], data[n:], nil) - if err != nil { - return "", fmt.Errorf("decryption failed") - } - return string(plaintext), nil -} diff --git a/shared/cryptobox/cryptobox_test.go b/shared/cryptobox/cryptobox_test.go deleted file mode 100644 index a67e1ec..0000000 --- a/shared/cryptobox/cryptobox_test.go +++ /dev/null @@ -1,79 +0,0 @@ -package cryptobox - -import ( - "bytes" - "crypto/rand" - "encoding/hex" - "testing" -) - -func testKey(t *testing.T) []byte { - t.Helper() - k := make([]byte, KeySize) - if _, err := rand.Read(k); err != nil { - t.Fatalf("rand: %v", err) - } - return k -} - -func TestSealOpenRoundTrip(t *testing.T) { - key := testKey(t) - sealed, err := Seal(key, "hunter2") - if err != nil { - t.Fatalf("Seal: %v", err) - } - if _, err := hex.DecodeString(sealed); err != nil { - t.Fatalf("Seal output is not hex: %v", err) - } - if bytes.Contains([]byte(sealed), []byte("hunter2")) { - t.Fatal("plaintext appears in ciphertext") - } - got, err := Open(key, sealed) - if err != nil { - t.Fatalf("Open: %v", err) - } - if got != "hunter2" { - t.Fatalf("got %q, want %q", got, "hunter2") - } -} - -func TestSealIsNonDeterministic(t *testing.T) { - key := testKey(t) - a, err := Seal(key, "same") - if err != nil { - t.Fatalf("Seal: %v", err) - } - b, err := Seal(key, "same") - if err != nil { - t.Fatalf("Seal: %v", err) - } - if a == b { - t.Fatal("two seals of the same plaintext are identical; nonce is not random") - } -} - -func TestOpenWrongKeyFails(t *testing.T) { - sealed, err := Seal(testKey(t), "secret") - if err != nil { - t.Fatalf("Seal: %v", err) - } - if _, err := Open(testKey(t), sealed); err == nil { - t.Fatal("Open with the wrong key succeeded") - } -} - -func TestOpenRejectsBadInput(t *testing.T) { - key := testKey(t) - if _, err := Open(key, "not-hex"); err == nil { - t.Fatal("Open accepted non-hex input") - } - if _, err := Open(key, "abcd"); err == nil { - t.Fatal("Open accepted a ciphertext shorter than the nonce") - } -} - -func TestWrongKeySizeRejected(t *testing.T) { - if _, err := Seal(make([]byte, 16), "x"); err == nil { - t.Fatal("Seal accepted a 16-byte key") - } -} diff --git a/shared/go.mod b/shared/go.mod deleted file mode 100644 index 3bc0bb1..0000000 --- a/shared/go.mod +++ /dev/null @@ -1,25 +0,0 @@ -module gitea.hostxtra.co.uk/vantage/vantage-shared - -go 1.26 - -require ( - github.com/google/uuid v1.6.0 - github.com/hyperboloide/lk v0.0.0-20251220053519-b291812e3216 - go.mongodb.org/mongo-driver/v2 v2.8.0 - golang.org/x/crypto v0.54.0 - google.golang.org/grpc v1.64.0 -) - -require ( - github.com/klauspost/compress v1.17.6 // indirect - github.com/xdg-go/pbkdf2 v1.0.0 // indirect - github.com/xdg-go/scram v1.2.0 // indirect - github.com/xdg-go/stringprep v1.0.4 // indirect - github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect - golang.org/x/net v0.56.0 // indirect - golang.org/x/sync v0.22.0 // indirect - golang.org/x/sys v0.47.0 // indirect - golang.org/x/text v0.40.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20240318140521-94a12d6c2237 // indirect - google.golang.org/protobuf v1.33.0 // indirect -) diff --git a/shared/go.sum b/shared/go.sum deleted file mode 100644 index 68a74c1..0000000 --- a/shared/go.sum +++ /dev/null @@ -1,66 +0,0 @@ -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= -github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/hyperboloide/lk v0.0.0-20251220053519-b291812e3216 h1:Luh+sE/W2M+V0Y+jlZN7nJefLNHc4/y93xxl+rFD7k0= -github.com/hyperboloide/lk v0.0.0-20251220053519-b291812e3216/go.mod h1:/OLW9HZj6qtQ7gWTGwuO3JrUZ+MC7I7TLRuNl14TYuo= -github.com/klauspost/compress v1.17.6 h1:60eq2E/jlfwQXtvZEeBUYADs+BwKBWURIY+Gj2eRGjI= -github.com/klauspost/compress v1.17.6/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= -github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= -github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs= -github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8= -github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= -github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= -github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= -github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= -github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -go.mongodb.org/mongo-driver/v2 v2.8.0 h1:CxWDGQYY8QQwNjAl/aq2sfWakdnWZynnqJ9F4DhHbP8= -go.mongodb.org/mongo-driver/v2 v2.8.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= -golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= -golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= -golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= -golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= -golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= -golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= -golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/genproto/googleapis/rpc v0.0.0-20240318140521-94a12d6c2237 h1:NnYq6UN9ReLM9/Y01KWNOWyI5xQ9kbIms5GGJVwS/Yc= -google.golang.org/genproto/googleapis/rpc v0.0.0-20240318140521-94a12d6c2237/go.mod h1:WtryC6hu0hhx87FDGxWCDptyssuo68sk10vYjF+T9fY= -google.golang.org/grpc v1.64.0 h1:KH3VH9y/MgNQg1dE7b3XfVK0GsPSIzJwdF617gUSbvY= -google.golang.org/grpc v1.64.0/go.mod h1:oxjF8E3FBnjp+/gVFYdWacaLDx9na1aqy9oovLpxQYg= -google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= -google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/shared/grpc/codec/codec.go b/shared/grpc/codec/codec.go deleted file mode 100644 index bb88907..0000000 --- a/shared/grpc/codec/codec.go +++ /dev/null @@ -1,19 +0,0 @@ -package codec - -import ( - "encoding/json" -) - -type JSONCodec struct{} - -func (JSONCodec) Marshal(v interface{}) ([]byte, error) { - return json.Marshal(v) -} - -func (JSONCodec) Unmarshal(data []byte, v interface{}) error { - return json.Unmarshal(data, v) -} - -func (JSONCodec) Name() string { - return "proto" -} diff --git a/shared/grpc/pb/vantage.pb.go b/shared/grpc/pb/vantage.pb.go deleted file mode 100644 index b4d2a1f..0000000 --- a/shared/grpc/pb/vantage.pb.go +++ /dev/null @@ -1,685 +0,0 @@ -package pb - -import ( - "context" - - "google.golang.org/grpc" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" -) - -type RegisterRequest struct { - ServerId string `json:"server_id"` - PreRegToken string `json:"pre_reg_token"` - Hostname string `json:"hostname"` - IpAddress string `json:"ip_address"` - OsInfo string `json:"os_info"` -} - -type RegisterResponse struct { - AgentToken string `json:"agent_token"` -} - -type SyncRequest struct { - ServerId string `json:"server_id"` - AgentToken string `json:"agent_token"` - AgentVersion string `json:"agent_version,omitempty"` -} - -type SyncResponse struct { - PublicKeys []string `json:"public_keys"` - // CollectPackages tells the agent whether this instance's licence grants - // vulnerability scanning. Absent decodes as false, which is the safe - // direction: an older server leaves agents collecting nothing. - CollectPackages bool `json:"collect_packages,omitempty"` -} - -type OSRelease struct { - Family string `json:"family"` - // VersionId is not optional: Ubuntu 22.04 and 24.04 publish different fixed - // versions for the same CVE, so a scan without it is guesswork. - VersionId string `json:"version_id"` - Arch string `json:"arch,omitempty"` -} - -type InstalledPackage struct { - Name string `json:"name"` - Version string `json:"version"` - Epoch int32 `json:"epoch,omitempty"` - Arch string `json:"arch,omitempty"` - // SourceName is what the Debian and Ubuntu feeds are keyed on: one advisory - // against "openssl" covers libssl3, openssl and libssl-dev. - SourceName string `json:"source_name,omitempty"` -} - -// ReportPackagesRequest carries a server's installed package set. -// -// The agent calls twice at most: first with Packages empty, offering only the -// hash. If the server already holds it, NeedFull is false and the ~150KB body -// is never sent. -type ReportPackagesRequest struct { - ServerId string `json:"server_id"` - AgentToken string `json:"agent_token"` - Hash string `json:"hash"` - Os OSRelease `json:"os"` - Packages []InstalledPackage `json:"packages,omitempty"` -} - -type ReportPackagesResponse struct { - NeedFull bool `json:"need_full"` -} - -type UploadKeyRequest struct { - ServerId string `json:"server_id"` - AgentToken string `json:"agent_token"` - PublicKey string `json:"public_key"` - Label string `json:"label"` - PrivateKey string `json:"private_key,omitempty"` -} - -type UploadKeyResponse struct { - KeyId string `json:"key_id"` -} - -type PackageUpdate struct { - Name string `json:"name"` - CurrentVersion string `json:"current_version,omitempty"` - NewVersion string `json:"new_version"` -} - -type ReportUpdatesRequest struct { - ServerId string `json:"server_id"` - AgentToken string `json:"agent_token"` - Updates []PackageUpdate `json:"updates"` -} - -type ReportUpdatesResponse struct{} - -type CPUReport struct { - Model string `json:"model,omitempty"` - Cores int `json:"cores,omitempty"` - UsagePct float64 `json:"usage_pct"` - Load1 float64 `json:"load1,omitempty"` -} -type MemReport struct { - TotalBytes uint64 `json:"total_bytes"` - UsedBytes uint64 `json:"used_bytes"` -} -type PartitionReport struct { - Device string `json:"device"` - Mountpoint string `json:"mountpoint"` - Fstype string `json:"fstype,omitempty"` - TotalBytes uint64 `json:"total_bytes"` - UsedBytes uint64 `json:"used_bytes"` -} -type InventoryReport struct { - ServerId string `json:"server_id"` - AgentToken string `json:"agent_token"` - IncludeStatic bool `json:"include_static"` - CPU *CPUReport `json:"cpu,omitempty"` - Memory *MemReport `json:"memory,omitempty"` - SwapTotal uint64 `json:"swap_total"` - SwapUsed uint64 `json:"swap_used"` - Partitions []PartitionReport `json:"partitions,omitempty"` - Kernel string `json:"kernel,omitempty"` - RebootRequired bool `json:"reboot_required,omitempty"` -} -type InventoryReportResponse struct{} - -type MonitorSpec struct { - MonitorId string `json:"monitor_id"` - Type string `json:"type"` - URL string `json:"url,omitempty"` - Host string `json:"host,omitempty"` - Port int `json:"port,omitempty"` - Method string `json:"method,omitempty"` - ExpectedStatus int `json:"expected_status,omitempty"` - Keyword string `json:"keyword,omitempty"` - TLSWarnDays int `json:"tls_warn_days,omitempty"` - Insecure bool `json:"insecure,omitempty"` - IntervalSec int `json:"interval_sec"` - Retries int `json:"retries"` -} -type SyncMonitorsRequest struct { - ServerId string `json:"server_id"` - AgentToken string `json:"agent_token"` -} -type SyncMonitorsResponse struct { - Monitors []MonitorSpec `json:"monitors,omitempty"` -} -type CheckResult struct { - MonitorId string `json:"monitor_id"` - Up bool `json:"up"` - LatencyMs int `json:"latency_ms"` - Message string `json:"message,omitempty"` - CertExpiryUnix int64 `json:"cert_expiry_unix,omitempty"` -} -type ReportChecksRequest struct { - ServerId string `json:"server_id"` - AgentToken string `json:"agent_token"` - Results []CheckResult `json:"results,omitempty"` -} -type ReportChecksResponse struct{} - -type ApplyUpdatesCmd struct{} - -type OpenProxyCmd struct { - ProxyId string `json:"proxy_id"` - Port uint32 `json:"port"` -} - -type ProxyOpen struct { - ServerId string `json:"server_id"` - AgentToken string `json:"agent_token"` - ProxyId string `json:"proxy_id"` -} - -type ProxyClose struct { - Reason string `json:"reason,omitempty"` -} - -type ProxyClientMsg struct { - Open *ProxyOpen `json:"open,omitempty"` - Data []byte `json:"data,omitempty"` - Close *ProxyClose `json:"close,omitempty"` -} - -type ProxyServerMsg struct { - Data []byte `json:"data,omitempty"` - Close *ProxyClose `json:"close,omitempty"` -} - -type ServerCommand struct { - CommandId string `json:"command_id"` - GenerateKey *GenerateKeyCmd `json:"generate_key,omitempty"` - DeleteKey *DeleteKeyCmd `json:"delete_key,omitempty"` - UpdateAgent *UpdateAgentCmd `json:"update_agent,omitempty"` - ApplyUpdates *ApplyUpdatesCmd `json:"apply_updates,omitempty"` - RunStep *RunStepCmd `json:"run_step,omitempty"` - CleanupWorkspace *CleanupWorkspaceCmd `json:"cleanup_workspace,omitempty"` - OpenProxy *OpenProxyCmd `json:"open_proxy,omitempty"` - Ping *PingCmd `json:"ping,omitempty"` - - RefreshWorkloads *RefreshWorkloadsCmd `json:"refresh_workloads,omitempty"` - ControlWorkload *ControlWorkloadCmd `json:"control_workload,omitempty"` - WorkloadLogs *WorkloadLogsCmd `json:"workload_logs,omitempty"` -} - -// PingCmd is a server-originated liveness beat. It carries nothing and expects -// no reply: its arrival is the entire message. See the .proto for why gRPC -// keepalive is not sufficient on its own. -type PingCmd struct{} - -type CleanupWorkspaceCmd struct { - WorkspaceId string `json:"workspace_id"` -} - -type DeleteKeyCmd struct { - Label string `json:"label"` -} - -type UpdateAgentCmd struct { - Version string `json:"version"` - GiteaBaseURL string `json:"gitea_base_url"` -} - -type GenerateKeyCmd struct { - Label string `json:"label"` - KeyType string `json:"key_type,omitempty"` - KeySize int `json:"key_size,omitempty"` - Passphrase string `json:"passphrase,omitempty"` - Comment string `json:"comment,omitempty"` -} - -type AgentMessage struct { - ServerId string `json:"server_id"` - AgentToken string `json:"agent_token"` - Ready *AgentReady `json:"ready,omitempty"` - Result *CommandResult `json:"result,omitempty"` - StepResult *StepResult `json:"step_result,omitempty"` - StepOutput *StepOutputChunk `json:"step_output,omitempty"` - - WorkloadLogsResult *WorkloadLogsResult `json:"workload_logs_result,omitempty"` -} - -type AgentReady struct{} - -type CommandResult struct { - CommandId string `json:"command_id"` - Success bool `json:"success"` - Message string `json:"message"` -} - -type RunStepCmd struct { - Interpreter string `json:"interpreter"` - Script string `json:"script"` - Env map[string]string `json:"env,omitempty"` - TimeoutSeconds int `json:"timeout_seconds,omitempty"` - - WorkspaceId string `json:"workspace_id,omitempty"` -} - -type StepResult struct { - CommandId string `json:"command_id"` - ExitCode int `json:"exit_code"` - Stdout string `json:"stdout,omitempty"` - Stderr string `json:"stderr,omitempty"` - OutputEnv map[string]string `json:"output_env,omitempty"` -} - -type StepOutputChunk struct { - CommandId string `json:"command_id"` - Seq uint64 `json:"seq"` - Data []byte `json:"data,omitempty"` - Eof bool `json:"eof,omitempty"` -} - -type Vantage_CommandStreamServer interface { - Send(*ServerCommand) error - Recv() (*AgentMessage, error) - grpc.ServerStream -} - -type vantageCommandStreamServer struct { - grpc.ServerStream -} - -func (s *vantageCommandStreamServer) Send(m *ServerCommand) error { - return s.ServerStream.SendMsg(m) -} - -func (s *vantageCommandStreamServer) Recv() (*AgentMessage, error) { - m := new(AgentMessage) - if err := s.ServerStream.RecvMsg(m); err != nil { - return nil, err - } - return m, nil -} - -type Vantage_CommandStreamClient interface { - Send(*AgentMessage) error - Recv() (*ServerCommand, error) - grpc.ClientStream -} - -type vantageCommandStreamClient struct { - grpc.ClientStream -} - -func (c *vantageCommandStreamClient) Send(m *AgentMessage) error { - return c.ClientStream.SendMsg(m) -} - -func (c *vantageCommandStreamClient) Recv() (*ServerCommand, error) { - m := new(ServerCommand) - if err := c.ClientStream.RecvMsg(m); err != nil { - return nil, err - } - return m, nil -} - -type Vantage_ProxyStreamServer interface { - Send(*ProxyServerMsg) error - Recv() (*ProxyClientMsg, error) - grpc.ServerStream -} - -type vantageProxyStreamServer struct { - grpc.ServerStream -} - -func (s *vantageProxyStreamServer) Send(m *ProxyServerMsg) error { - return s.ServerStream.SendMsg(m) -} - -func (s *vantageProxyStreamServer) Recv() (*ProxyClientMsg, error) { - m := new(ProxyClientMsg) - if err := s.ServerStream.RecvMsg(m); err != nil { - return nil, err - } - return m, nil -} - -type Vantage_ProxyStreamClient interface { - Send(*ProxyClientMsg) error - Recv() (*ProxyServerMsg, error) - CloseSend() error - grpc.ClientStream -} - -type vantageProxyStreamClient struct { - grpc.ClientStream -} - -func (c *vantageProxyStreamClient) Send(m *ProxyClientMsg) error { - return c.ClientStream.SendMsg(m) -} - -func (c *vantageProxyStreamClient) Recv() (*ProxyServerMsg, error) { - m := new(ProxyServerMsg) - if err := c.ClientStream.RecvMsg(m); err != nil { - return nil, err - } - return m, nil -} - -func _Vantage_ProxyStream_Handler(srv interface{}, stream grpc.ServerStream) error { - return srv.(VantageServer).ProxyStream(&vantageProxyStreamServer{stream}) -} - -type VantageServer interface { - Register(context.Context, *RegisterRequest) (*RegisterResponse, error) - SyncKeys(context.Context, *SyncRequest) (*SyncResponse, error) - UploadGeneratedKey(context.Context, *UploadKeyRequest) (*UploadKeyResponse, error) - ReportUpdates(context.Context, *ReportUpdatesRequest) (*ReportUpdatesResponse, error) - ReportPackages(context.Context, *ReportPackagesRequest) (*ReportPackagesResponse, error) - ReportWorkloads(context.Context, *ReportWorkloadsRequest) (*ReportWorkloadsResponse, error) - ReportInventory(context.Context, *InventoryReport) (*InventoryReportResponse, error) - SyncMonitors(context.Context, *SyncMonitorsRequest) (*SyncMonitorsResponse, error) - ReportChecks(context.Context, *ReportChecksRequest) (*ReportChecksResponse, error) - CommandStream(Vantage_CommandStreamServer) error - ProxyStream(Vantage_ProxyStreamServer) error -} - -type UnimplementedVantageServer struct{} - -func (UnimplementedVantageServer) Register(context.Context, *RegisterRequest) (*RegisterResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method Register not implemented") -} - -func (UnimplementedVantageServer) SyncKeys(context.Context, *SyncRequest) (*SyncResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method SyncKeys not implemented") -} - -func (UnimplementedVantageServer) UploadGeneratedKey(context.Context, *UploadKeyRequest) (*UploadKeyResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method UploadGeneratedKey not implemented") -} - -func (UnimplementedVantageServer) ReportUpdates(context.Context, *ReportUpdatesRequest) (*ReportUpdatesResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method ReportUpdates not implemented") -} - -func (UnimplementedVantageServer) ReportPackages(context.Context, *ReportPackagesRequest) (*ReportPackagesResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method ReportPackages not implemented") -} - -func (UnimplementedVantageServer) ReportInventory(context.Context, *InventoryReport) (*InventoryReportResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method ReportInventory not implemented") -} - -func (UnimplementedVantageServer) SyncMonitors(context.Context, *SyncMonitorsRequest) (*SyncMonitorsResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method SyncMonitors not implemented") -} - -func (UnimplementedVantageServer) ReportChecks(context.Context, *ReportChecksRequest) (*ReportChecksResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method ReportChecks not implemented") -} - -func (UnimplementedVantageServer) CommandStream(Vantage_CommandStreamServer) error { - return status.Errorf(codes.Unimplemented, "method CommandStream not implemented") -} - -func (UnimplementedVantageServer) ProxyStream(Vantage_ProxyStreamServer) error { - return status.Errorf(codes.Unimplemented, "method ProxyStream not implemented") -} - -type VantageClient interface { - Register(ctx context.Context, in *RegisterRequest, opts ...grpc.CallOption) (*RegisterResponse, error) - SyncKeys(ctx context.Context, in *SyncRequest, opts ...grpc.CallOption) (*SyncResponse, error) - UploadGeneratedKey(ctx context.Context, in *UploadKeyRequest, opts ...grpc.CallOption) (*UploadKeyResponse, error) - ReportUpdates(ctx context.Context, in *ReportUpdatesRequest, opts ...grpc.CallOption) (*ReportUpdatesResponse, error) - ReportPackages(ctx context.Context, in *ReportPackagesRequest, opts ...grpc.CallOption) (*ReportPackagesResponse, error) - ReportWorkloads(ctx context.Context, in *ReportWorkloadsRequest, opts ...grpc.CallOption) (*ReportWorkloadsResponse, error) - ReportInventory(ctx context.Context, in *InventoryReport, opts ...grpc.CallOption) (*InventoryReportResponse, error) - SyncMonitors(ctx context.Context, in *SyncMonitorsRequest, opts ...grpc.CallOption) (*SyncMonitorsResponse, error) - ReportChecks(ctx context.Context, in *ReportChecksRequest, opts ...grpc.CallOption) (*ReportChecksResponse, error) - CommandStream(ctx context.Context, opts ...grpc.CallOption) (Vantage_CommandStreamClient, error) - ProxyStream(ctx context.Context, opts ...grpc.CallOption) (Vantage_ProxyStreamClient, error) -} - -type keyManagerClient struct { - cc grpc.ClientConnInterface -} - -func NewVantageClient(cc grpc.ClientConnInterface) VantageClient { - return &keyManagerClient{cc} -} - -func (c *keyManagerClient) Register(ctx context.Context, in *RegisterRequest, opts ...grpc.CallOption) (*RegisterResponse, error) { - out := new(RegisterResponse) - if err := c.cc.Invoke(ctx, "/vantage.v1.Vantage/Register", in, out, opts...); err != nil { - return nil, err - } - return out, nil -} - -func (c *keyManagerClient) SyncKeys(ctx context.Context, in *SyncRequest, opts ...grpc.CallOption) (*SyncResponse, error) { - out := new(SyncResponse) - if err := c.cc.Invoke(ctx, "/vantage.v1.Vantage/SyncKeys", in, out, opts...); err != nil { - return nil, err - } - return out, nil -} - -func (c *keyManagerClient) UploadGeneratedKey(ctx context.Context, in *UploadKeyRequest, opts ...grpc.CallOption) (*UploadKeyResponse, error) { - out := new(UploadKeyResponse) - if err := c.cc.Invoke(ctx, "/vantage.v1.Vantage/UploadGeneratedKey", in, out, opts...); err != nil { - return nil, err - } - return out, nil -} - -func (c *keyManagerClient) ReportUpdates(ctx context.Context, in *ReportUpdatesRequest, opts ...grpc.CallOption) (*ReportUpdatesResponse, error) { - out := new(ReportUpdatesResponse) - if err := c.cc.Invoke(ctx, "/vantage.v1.Vantage/ReportUpdates", in, out, opts...); err != nil { - return nil, err - } - return out, nil -} - -func (c *keyManagerClient) ReportPackages(ctx context.Context, in *ReportPackagesRequest, opts ...grpc.CallOption) (*ReportPackagesResponse, error) { - out := new(ReportPackagesResponse) - if err := c.cc.Invoke(ctx, "/vantage.v1.Vantage/ReportPackages", in, out, opts...); err != nil { - return nil, err - } - return out, nil -} - -func (c *keyManagerClient) ReportInventory(ctx context.Context, in *InventoryReport, opts ...grpc.CallOption) (*InventoryReportResponse, error) { - out := new(InventoryReportResponse) - if err := c.cc.Invoke(ctx, "/vantage.v1.Vantage/ReportInventory", in, out, opts...); err != nil { - return nil, err - } - return out, nil -} - -func (c *keyManagerClient) SyncMonitors(ctx context.Context, in *SyncMonitorsRequest, opts ...grpc.CallOption) (*SyncMonitorsResponse, error) { - out := new(SyncMonitorsResponse) - if err := c.cc.Invoke(ctx, "/vantage.v1.Vantage/SyncMonitors", in, out, opts...); err != nil { - return nil, err - } - return out, nil -} - -func (c *keyManagerClient) ReportChecks(ctx context.Context, in *ReportChecksRequest, opts ...grpc.CallOption) (*ReportChecksResponse, error) { - out := new(ReportChecksResponse) - if err := c.cc.Invoke(ctx, "/vantage.v1.Vantage/ReportChecks", in, out, opts...); err != nil { - return nil, err - } - return out, nil -} - -func (c *keyManagerClient) CommandStream(ctx context.Context, opts ...grpc.CallOption) (Vantage_CommandStreamClient, error) { - stream, err := c.cc.NewStream(ctx, &Vantage_ServiceDesc.Streams[0], "/vantage.v1.Vantage/CommandStream", opts...) - if err != nil { - return nil, err - } - return &vantageCommandStreamClient{stream}, nil -} - -func (c *keyManagerClient) ProxyStream(ctx context.Context, opts ...grpc.CallOption) (Vantage_ProxyStreamClient, error) { - stream, err := c.cc.NewStream(ctx, &Vantage_ServiceDesc.Streams[1], "/vantage.v1.Vantage/ProxyStream", opts...) - if err != nil { - return nil, err - } - return &vantageProxyStreamClient{stream}, nil -} - -func RegisterVantageServer(s grpc.ServiceRegistrar, srv VantageServer) { - s.RegisterService(&Vantage_ServiceDesc, srv) -} - -var Vantage_ServiceDesc = grpc.ServiceDesc{ - ServiceName: "vantage.v1.Vantage", - HandlerType: (*VantageServer)(nil), - Methods: []grpc.MethodDesc{ - {MethodName: "Register", Handler: _Vantage_Register_Handler}, - {MethodName: "SyncKeys", Handler: _Vantage_SyncKeys_Handler}, - {MethodName: "UploadGeneratedKey", Handler: _Vantage_UploadGeneratedKey_Handler}, - {MethodName: "ReportUpdates", Handler: _Vantage_ReportUpdates_Handler}, - {MethodName: "ReportPackages", Handler: _Vantage_ReportPackages_Handler}, - {MethodName: "ReportWorkloads", Handler: _Vantage_ReportWorkloads_Handler}, - {MethodName: "ReportInventory", Handler: _Vantage_ReportInventory_Handler}, - {MethodName: "SyncMonitors", Handler: _Vantage_SyncMonitors_Handler}, - {MethodName: "ReportChecks", Handler: _Vantage_ReportChecks_Handler}, - }, - Streams: []grpc.StreamDesc{ - { - StreamName: "CommandStream", - Handler: _Vantage_CommandStream_Handler, - ServerStreams: true, - ClientStreams: true, - }, - { - StreamName: "ProxyStream", - Handler: _Vantage_ProxyStream_Handler, - ServerStreams: true, - ClientStreams: true, - }, - }, - Metadata: "vantage/v1/vantage.proto", -} - -func _Vantage_Register_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(RegisterRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(VantageServer).Register(ctx, in) - } - info := &grpc.UnaryServerInfo{Server: srv, FullMethod: "/vantage.v1.Vantage/Register"} - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(VantageServer).Register(ctx, req.(*RegisterRequest)) - } - return interceptor(ctx, in, info, handler) -} - -func _Vantage_SyncKeys_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(SyncRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(VantageServer).SyncKeys(ctx, in) - } - info := &grpc.UnaryServerInfo{Server: srv, FullMethod: "/vantage.v1.Vantage/SyncKeys"} - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(VantageServer).SyncKeys(ctx, req.(*SyncRequest)) - } - return interceptor(ctx, in, info, handler) -} - -func _Vantage_UploadGeneratedKey_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(UploadKeyRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(VantageServer).UploadGeneratedKey(ctx, in) - } - info := &grpc.UnaryServerInfo{Server: srv, FullMethod: "/vantage.v1.Vantage/UploadGeneratedKey"} - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(VantageServer).UploadGeneratedKey(ctx, req.(*UploadKeyRequest)) - } - return interceptor(ctx, in, info, handler) -} - -func _Vantage_ReportUpdates_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(ReportUpdatesRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(VantageServer).ReportUpdates(ctx, in) - } - info := &grpc.UnaryServerInfo{Server: srv, FullMethod: "/vantage.v1.Vantage/ReportUpdates"} - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(VantageServer).ReportUpdates(ctx, req.(*ReportUpdatesRequest)) - } - return interceptor(ctx, in, info, handler) -} - -func _Vantage_ReportPackages_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(ReportPackagesRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(VantageServer).ReportPackages(ctx, in) - } - info := &grpc.UnaryServerInfo{Server: srv, FullMethod: "/vantage.v1.Vantage/ReportPackages"} - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(VantageServer).ReportPackages(ctx, req.(*ReportPackagesRequest)) - } - return interceptor(ctx, in, info, handler) -} - -func _Vantage_ReportInventory_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(InventoryReport) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(VantageServer).ReportInventory(ctx, in) - } - info := &grpc.UnaryServerInfo{Server: srv, FullMethod: "/vantage.v1.Vantage/ReportInventory"} - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(VantageServer).ReportInventory(ctx, req.(*InventoryReport)) - } - return interceptor(ctx, in, info, handler) -} - -func _Vantage_SyncMonitors_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(SyncMonitorsRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(VantageServer).SyncMonitors(ctx, in) - } - info := &grpc.UnaryServerInfo{Server: srv, FullMethod: "/vantage.v1.Vantage/SyncMonitors"} - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(VantageServer).SyncMonitors(ctx, req.(*SyncMonitorsRequest)) - } - return interceptor(ctx, in, info, handler) -} - -func _Vantage_ReportChecks_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(ReportChecksRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(VantageServer).ReportChecks(ctx, in) - } - info := &grpc.UnaryServerInfo{Server: srv, FullMethod: "/vantage.v1.Vantage/ReportChecks"} - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(VantageServer).ReportChecks(ctx, req.(*ReportChecksRequest)) - } - return interceptor(ctx, in, info, handler) -} - -func _Vantage_CommandStream_Handler(srv interface{}, stream grpc.ServerStream) error { - return srv.(VantageServer).CommandStream(&vantageCommandStreamServer{stream}) -} diff --git a/shared/grpc/pb/workloads.pb.go b/shared/grpc/pb/workloads.pb.go deleted file mode 100644 index cd502ab..0000000 --- a/shared/grpc/pb/workloads.pb.go +++ /dev/null @@ -1,101 +0,0 @@ -package pb - -import ( - "context" - - "google.golang.org/grpc" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" -) - -// Workload registry messages. Hand-written like the rest of this package: the -// .proto is the contract, this file is the Go side of it, and the two must be -// changed together. - -// Workload is one container or one systemd unit. -type Workload struct { - Kind string `json:"kind"` - Id string `json:"id"` - Name string `json:"name"` - State string `json:"state"` - Health string `json:"health,omitempty"` - Image string `json:"image,omitempty"` - Stack string `json:"stack,omitempty"` - Ports []string `json:"ports,omitempty"` - Restarts int32 `json:"restarts,omitempty"` - StartedAt string `json:"started_at,omitempty"` // RFC3339, empty when not running - Protected bool `json:"protected,omitempty"` -} - -// ReportWorkloadsRequest carries what a server is running. -// -// Offer-then-send, the same handshake as ReportPackages: the agent calls once -// with Workloads empty, and resends with the body only if NeedFull is set. -type ReportWorkloadsRequest struct { - ServerId string `json:"server_id"` - AgentToken string `json:"agent_token"` - Hash string `json:"hash"` - DockerOk bool `json:"docker_ok"` - DockerError string `json:"docker_error,omitempty"` - SystemdOk bool `json:"systemd_ok"` - SystemdError string `json:"systemd_error,omitempty"` - Workloads []Workload `json:"workloads,omitempty"` // empty on the offer call - // Full marks the second call. It is not inferred from an empty Workloads - // slice: a host running nothing sends an empty list as its full report. - Full bool `json:"full,omitempty"` -} - -type ReportWorkloadsResponse struct { - NeedFull bool `json:"need_full"` -} - -// RefreshWorkloadsCmd carries no payload back. It makes the agent report -// immediately through ReportWorkloads, so there is exactly one writer for the -// server_workloads collection rather than two arriving by different routes. -type RefreshWorkloadsCmd struct{} - -type ControlWorkloadCmd struct { - Kind string `json:"kind"` - Id string `json:"id"` - Action string `json:"action"` // start | stop | restart -} - -type WorkloadLogsCmd struct { - Kind string `json:"kind"` - Id string `json:"id"` - Tail int32 `json:"tail,omitempty"` -} - -type WorkloadLogsResult struct { - CommandId string `json:"command_id"` - Text string `json:"text,omitempty"` - Truncated bool `json:"truncated,omitempty"` - Error string `json:"error,omitempty"` -} - -func (UnimplementedVantageServer) ReportWorkloads(context.Context, *ReportWorkloadsRequest) (*ReportWorkloadsResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method ReportWorkloads not implemented") -} - -func (c *keyManagerClient) ReportWorkloads(ctx context.Context, in *ReportWorkloadsRequest, opts ...grpc.CallOption) (*ReportWorkloadsResponse, error) { - out := new(ReportWorkloadsResponse) - if err := c.cc.Invoke(ctx, "/vantage.v1.Vantage/ReportWorkloads", in, out, opts...); err != nil { - return nil, err - } - return out, nil -} - -func _Vantage_ReportWorkloads_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(ReportWorkloadsRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(VantageServer).ReportWorkloads(ctx, in) - } - info := &grpc.UnaryServerInfo{Server: srv, FullMethod: "/vantage.v1.Vantage/ReportWorkloads"} - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(VantageServer).ReportWorkloads(ctx, req.(*ReportWorkloadsRequest)) - } - return interceptor(ctx, in, info, handler) -} diff --git a/shared/indexes/indexes.go b/shared/indexes/indexes.go deleted file mode 100644 index cadd84b..0000000 --- a/shared/indexes/indexes.go +++ /dev/null @@ -1,99 +0,0 @@ -// Package indexes declares the MongoDB indexes more than one Vantage service -// depends on. -package indexes - -import ( - "context" - "errors" - "fmt" - - "go.mongodb.org/mongo-driver/v2/bson" - "go.mongodb.org/mongo-driver/v2/mongo" - "go.mongodb.org/mongo-driver/v2/mongo/options" -) - -// legacyUserEmailIndex is the global unique index on users.email that this -// package used to declare. It is dropped on sight. -const legacyUserEmailIndex = "email_1" - -// indexNotFound is MongoDB's IndexNotFound error code. Two services booting at -// once can both decide to drop the legacy index; the loser must not treat that -// as a failure. -const indexNotFound = 27 - -// EnsureCoreIndexes declares the unique indexes on users and instances. -// -// users is unique on (instance_id, email), NOT on email alone. One address is -// one user WITHIN an instance; the same address may hold a user in several -// instances, because an account's people are projected into each instance they -// are granted access to. -// -// This is a security property, not an optimisation, and it is only sufficient -// because every lookup by email is scoped by instance. There is deliberately no -// unscoped lookup by email anywhere in the codebase: an unscoped FindOne would -// return an arbitrary one of several matching users, which on the login path -// means signing someone into a tenant that is not theirs. If you are about to -// add one, you are about to reintroduce that bug. -// -// Creating an index that already exists with the same specification is a no-op, -// so this is safe to call at every boot from every service. -func EnsureCoreIndexes(ctx context.Context, db *mongo.Database) error { - // Create the replacement BEFORE dropping the legacy index. A failure here - // leaves the old constraint in place, which is safe; a failure after the - // drop would leave the collection unconstrained, which is not. - if _, err := db.Collection("users").Indexes().CreateOne(ctx, mongo.IndexModel{ - Keys: bson.D{{Key: "instance_id", Value: 1}, {Key: "email", Value: 1}}, - Options: options.Index().SetUnique(true).SetName("instance_email_unique"), - }); err != nil { - return fmt.Errorf("users.instance_id+email index: %w", err) - } - - if err := dropIndexIfExists(ctx, db.Collection("users"), legacyUserEmailIndex); err != nil { - return fmt.Errorf("drop users.%s: %w", legacyUserEmailIndex, err) - } - - if _, err := db.Collection("instances").Indexes().CreateOne(ctx, mongo.IndexModel{ - Keys: bson.D{{Key: "slug", Value: 1}}, - Options: options.Index().SetUnique(true), - }); err != nil { - return fmt.Errorf("instances.slug index: %w", err) - } - - return nil -} - -// dropIndexIfExists drops name, treating "it was not there" as success whether -// that is discovered by listing or by racing another service to the drop. -func dropIndexIfExists(ctx context.Context, col *mongo.Collection, name string) error { - cur, err := col.Indexes().List(ctx) - if err != nil { - return err - } - var existing []struct { - Name string `bson:"name"` - } - if err := cur.All(ctx, &existing); err != nil { - return err - } - - found := false - for _, i := range existing { - if i.Name == name { - found = true - break - } - } - if !found { - return nil - } - - err = col.Indexes().DropOne(ctx, name) - if err == nil { - return nil - } - var srvErr mongo.ServerError - if errors.As(err, &srvErr) && srvErr.HasErrorCode(indexNotFound) { - return nil - } - return err -} diff --git a/shared/license/keys.go b/shared/license/keys.go deleted file mode 100644 index 4dda60d..0000000 --- a/shared/license/keys.go +++ /dev/null @@ -1,24 +0,0 @@ -package license - -import ( - "fmt" - - "github.com/hyperboloide/lk" -) - -var trustedPublicKeys = []string{ - // Production signing key, generated 2026-07-24. Index 0 is current. - "AS6Z4XBXF7HPTOMBUK47SWHPROAGOSBIW5ZZZHTLCA6FHMYSHCCTV3S6AIN6DOB6VKMHMTLRIWPSBAZ2FOHJV3A6NLOCWGEO2VA7KYGSG62SILEFA4SNJ7VWDDIVZZM4ZU4VJVE22LESH72STAAVIDYI77WA====", -} - -func publicKeys() ([]*lk.PublicKey, error) { - out := make([]*lk.PublicKey, 0, len(trustedPublicKeys)) - for i, s := range trustedPublicKeys { - k, err := lk.PublicKeyFromB32String(s) - if err != nil { - return nil, fmt.Errorf("trusted public key %d is malformed: %w", i, err) - } - out = append(out, k) - } - return out, nil -} diff --git a/shared/license/license.go b/shared/license/license.go deleted file mode 100644 index 651bd92..0000000 --- a/shared/license/license.go +++ /dev/null @@ -1,139 +0,0 @@ -// Package license defines the Vantage licence payload and its offline -// verification. -// -// A licence is a signed blob (ECDSA P-384 with SHA-256). The server checks a -// signature, an expiry, a deployment mode and an instance ID, and asks nobody's -// permission. That buys air-gapped self-hosting and means no instance depends -// on the licensing service being reachable. -// -// It costs revocation: once issued, a licence is valid until it expires -// whatever the billing system later says. Self Hosted is sold annually only so -// that window is bounded. -package license - -import "time" - -const ( - TierFree = "free" - TierProfessional = "professional" - TierEnterprise = "enterprise" - - // TierSelfHosted is LEGACY and no new licence carries it. - // - // It was a tier when self-hosting was a tier rather than a deployment. Blobs - // already signed with it exist and cannot be rewritten, so it stays a - // recognised value that NormaliseTier maps forward. Never put it in a plan - // row and never offer it in a UI. - TierSelfHosted = "self_hosted" - - DeploymentCloud = "cloud" - DeploymentSelfHosted = "self_hosted" - - FeatureConsole = "console" // browser SSH/RDP/VNC - FeatureOIDC = "oidc" // per-instance single sign-on - // FeatureVulnScanning gates package inventory collection as well as the - // findings themselves. The gate is at collection, not display: an ungated - // instance stores no inventory, and storage is the expensive half. - FeatureVulnScanning = "vuln_scanning" - // FeatureStatusPages gates public status pages at both ends: authoring - // them, and serving them. Serving answers 200 with available:false rather - // than 403, because the page has to render an explanation to a member of - // the public who cannot do anything about it. - FeatureStatusPages = "status_pages" -) - -// Support levels. Carried for display and enforced by nothing — there is no code -// path anywhere that branches on these, and there must not be one. They are here -// so an air-gapped install can tell its operator who to call without reaching -// Vantage HQ. -const ( - SupportCommunity = "community" - SupportEmail24x5 = "email_24_5" - SupportEmailCall24x7 = "email_call_24_7" -) - -// Unlimited is the sentinel for "no cap" in every Limits field. -const Unlimited = -1 - -// Limits are the countable caps a licence grants. -// -// Every field is a plain int with Unlimited as the sentinel. AuditRetentionDays -// is the odd one out: it bounds a duration rather than a count, and Unlimited -// there means "never trim" rather than "no cap". -type Limits struct { - MaxServers int `json:"max_servers"` - MaxMonitors int `json:"max_monitors"` - MaxSecretGroups int `json:"max_secret_groups"` - MaxChannels int `json:"max_channels"` - AuditRetentionDays int `json:"audit_retention_days"` -} - -// FillUnset replaces any zero field with the same field from base. -// -// This exists for one reason: a licence signed before a field existed decodes it -// as 0, and 0 would read as the most restrictive possible value — no monitors, -// and an audit log trimmed to nothing. A blob we cannot re-sign must not be -// allowed to mean that. -// -// The cost is that 0 stops being expressible as a real allowance. No plan grants -// zero of anything, so nothing is lost today; a plan that genuinely means zero -// must use a negative-free sentinel of its own rather than reintroducing 0 here. -func (l Limits) FillUnset(base Limits) Limits { - if l.MaxServers == 0 { - l.MaxServers = base.MaxServers - } - if l.MaxMonitors == 0 { - l.MaxMonitors = base.MaxMonitors - } - if l.MaxSecretGroups == 0 { - l.MaxSecretGroups = base.MaxSecretGroups - } - if l.MaxChannels == 0 { - l.MaxChannels = base.MaxChannels - } - if l.AuditRetentionDays == 0 { - l.AuditRetentionDays = base.AuditRetentionDays - } - return l -} - -// License is the signed payload. -// -// InstanceID is always populated: the self-hosted purchase flow links the -// instance UUID before the licence is signed, so there is no unbound licence -// and no claim protocol. -type License struct { - ID string `json:"id"` // uuid, for support and audit - InstanceID string `json:"instance_id"` // the instance this licence is bound to - AccountID string `json:"account_id"` // admin-side customer, informational - InstanceName string `json:"instance_name"` // display only - Tier string `json:"tier"` - Deployment string `json:"deployment"` - SupportLevel string `json:"support_level,omitempty"` // display only - IssuedAt time.Time `json:"issued_at"` - ExpiresAt time.Time `json:"expires_at"` - Limits Limits `json:"limits"` - Features []string `json:"features"` -} - -// HasFeature reports whether the licence grants a named feature. -// -// Callers must use this rather than switching on Tier. Adding a tier, or -// changing what a tier includes, must never require a server release. -func (l License) HasFeature(name string) bool { - for _, f := range l.Features { - if f == name { - return true - } - } - return false -} - -// WithinLimit reports whether one more of something is allowed. -// A max of Unlimited always allows. -func WithinLimit(current, max int) bool { - if max == Unlimited { - return true - } - return current < max -} diff --git a/shared/license/plans.go b/shared/license/plans.go deleted file mode 100644 index 0da8490..0000000 --- a/shared/license/plans.go +++ /dev/null @@ -1,129 +0,0 @@ -package license - -// Plan is the contents of one (deployment, tier) pair at issue time. -// -// This table is the seed. The admin service owns the authoritative copy in its -// `plans` collection, and every issued licence snapshots the plan it was cut -// from — so editing a plan never rewrites an existing licence, the same rule as -// workflow_runs.steps_snapshot. -// -// Limits here are the BASE allowance: what the tier grants before anything is -// bought. A metered dimension adds to it, which is why max_servers is a real -// number at Professional and Enterprise rather than Unlimited. -type Plan struct { - Tier string - Name string - Deployment string - SupportLevel string - Limits Limits - Features []string -} - -// planKey is the composite the table is keyed on. -// -// Keying on tier alone was what made Free cloud-only by construction. There is -// now a self-hosted Free plan, so that guarantee is gone and the Free limit is -// enforced per account AND deployment instead. See licensing.checkFreeLimit. -type planKey struct { - Deployment string - Tier string -} - -// baseFree, baseProfessional and baseEnterprise are shared by both deployments. -// -// The allowances are deliberately identical across cloud and self-hosted: what -// differs between the two is the term on offer, not what you get. Duplicating -// them per deployment would be four places to forget. -var ( - baseFree = Limits{ - MaxServers: 3, MaxMonitors: 3, MaxSecretGroups: 1, - MaxChannels: 1, AuditRetentionDays: 30, - } - baseProfessional = Limits{ - MaxServers: 3, MaxMonitors: Unlimited, MaxSecretGroups: Unlimited, - MaxChannels: Unlimited, AuditRetentionDays: 365, - } - baseEnterprise = Limits{ - MaxServers: 10, MaxMonitors: Unlimited, MaxSecretGroups: Unlimited, - MaxChannels: Unlimited, AuditRetentionDays: Unlimited, - } -) - -var plans = map[planKey]Plan{ - planKey{DeploymentCloud, TierFree}: { - Tier: TierFree, Name: "Free", Deployment: DeploymentCloud, - SupportLevel: SupportCommunity, Limits: baseFree, - // Empty rather than nil: nil marshals as JSON null, and this table is - // the seed every plan and licence is cut from. - Features: []string{}, - }, - planKey{DeploymentCloud, TierProfessional}: { - Tier: TierProfessional, Name: "Professional", Deployment: DeploymentCloud, - SupportLevel: SupportEmail24x5, Limits: baseProfessional, - // Console and SSO are opt-in per customer, so no tier bundles them. The - // field stays because a future tier might. - Features: []string{}, - }, - planKey{DeploymentCloud, TierEnterprise}: { - Tier: TierEnterprise, Name: "Enterprise", Deployment: DeploymentCloud, - SupportLevel: SupportEmailCall24x7, Limits: baseEnterprise, - Features: []string{}, - }, - planKey{DeploymentSelfHosted, TierFree}: { - Tier: TierFree, Name: "Free", Deployment: DeploymentSelfHosted, - SupportLevel: SupportCommunity, Limits: baseFree, - Features: []string{}, - }, - planKey{DeploymentSelfHosted, TierProfessional}: { - Tier: TierProfessional, Name: "Professional", Deployment: DeploymentSelfHosted, - SupportLevel: SupportEmail24x5, Limits: baseProfessional, - Features: []string{}, - }, - planKey{DeploymentSelfHosted, TierEnterprise}: { - Tier: TierEnterprise, Name: "Enterprise", Deployment: DeploymentSelfHosted, - SupportLevel: SupportEmailCall24x7, Limits: baseEnterprise, - Features: []string{}, - }, -} - -// PlanFor returns the seed plan for one deployment and tier. -// -// It normalises first, so a legacy self_hosted licence resolves to the plan that -// replaced it rather than to nothing. -func PlanFor(deployment, tier string) (Plan, bool) { - deployment, tier = NormaliseTier(deployment, tier) - p, ok := plans[planKey{deployment, tier}] - return p, ok -} - -// NormaliseTier maps a legacy tier forward. -// -// tier "self_hosted" predates deployments being separate from tiers. Such a -// licence granted what Professional now grants, on a self-hosted install, so it -// maps to exactly that. Called by PlanFor and by anything reading a tier off an -// already-signed payload. -func NormaliseTier(deployment, tier string) (string, string) { - if tier == TierSelfHosted { - return DeploymentSelfHosted, TierProfessional - } - return deployment, tier -} - -// Tiers is the offer order, for any UI that lists them. -func Tiers() []string { return []string{TierFree, TierProfessional, TierEnterprise} } - -// Deployments is the offer order. -func Deployments() []string { return []string{DeploymentCloud, DeploymentSelfHosted} } - -// TermsFor reports which billing terms a deployment sells. -// -// Self-hosted is annual only, and the reason is in this package's doc comment: an -// offline licence cannot be revoked, so the term length IS the revocation -// window. A self-hosted monthly licence would renew that window twelve times a -// year for no commercial gain. -func TermsFor(deployment string) []string { - if deployment == DeploymentSelfHosted { - return []string{"annual"} - } - return []string{"monthly", "annual"} -} diff --git a/shared/license/sign.go b/shared/license/sign.go deleted file mode 100644 index d812ad2..0000000 --- a/shared/license/sign.go +++ /dev/null @@ -1,43 +0,0 @@ -//go:build !noSign - -package license - -import ( - "encoding/json" - "fmt" - - "github.com/hyperboloide/lk" -) - -// Sign marshals a licence and signs it, returning the base32 blob. -// -// This file carries the !noSign build tag so the signing path can be compiled -// out of the control plane. The server has no reason to hold signing code and -// no reason to ship it into a customer's data centre. -// -// privateKeyB32 comes from LICENSE_SIGNING_KEY on the issuing side only. -func Sign(l License, privateKeyB32 string) (string, error) { - if privateKeyB32 == "" { - return "", fmt.Errorf("no signing key provided") - } - priv, err := lk.PrivateKeyFromB32String(privateKeyB32) - if err != nil { - return "", fmt.Errorf("parse signing key: %w", err) - } - - data, err := json.Marshal(l) - if err != nil { - return "", fmt.Errorf("marshal licence: %w", err) - } - - signed, err := lk.NewLicense(priv, data) - if err != nil { - return "", fmt.Errorf("sign licence: %w", err) - } - - blob, err := signed.ToB32String() - if err != nil { - return "", fmt.Errorf("encode licence: %w", err) - } - return blob, nil -} diff --git a/shared/license/verify.go b/shared/license/verify.go deleted file mode 100644 index 5d0c348..0000000 --- a/shared/license/verify.go +++ /dev/null @@ -1,132 +0,0 @@ -package license - -import ( - "encoding/json" - "fmt" - "time" - - "github.com/hyperboloide/lk" -) - -type State string - -const ( - StateValid State = "valid" - StateExpired State = "expired" - StateInvalid State = "invalid" -) - -// Reasons a licence is not valid. These are stable identifiers: the API returns -// them and the UI maps them to messages, so do not reword them casually. -const ( - ReasonNoLicense = "no_license" - ReasonBadSignature = "bad_signature" - ReasonDeploymentMismatch = "deployment_mismatch" - ReasonInstanceMismatch = "instance_mismatch" - ReasonExpired = "expired" -) - -// VerifyOpts is what the verifier knows about itself. -type VerifyOpts struct { - InstanceID string // this instance's own ID; required - Deployment string // "cloud" or "self_hosted"; required - Now time.Time // zero means time.Now() -} - -type Result struct { - License License - State State - Reason string - // ClockSkewed is set when IssuedAt is in the future, which usually means - // the host clock is wrong. It does not by itself invalidate the licence. - ClockSkewed bool -} - -// Verify checks a licence blob against this instance. -// -// The checks run in a fixed order and stop at the first failure: -// -// 1. signature against a trusted public key -> bad_signature -// 2. deployment matches this install -> deployment_mismatch -// 3. instance ID matches this instance -> instance_mismatch -// 4. not past ExpiresAt -> expired -// -// The order matters. A blob that is both expired and bound to another instance -// reports instance_mismatch, not expired, because that is the more useful thing -// to tell the person holding it. -// -// No clock tolerance is applied. Terms are a month or a year; a host whose clock -// is wrong by enough to matter has larger problems, and a tolerance window is a -// thing to get wrong. -func Verify(blob string, opts VerifyOpts) Result { - if blob == "" { - return Result{State: StateInvalid, Reason: ReasonNoLicense} - } - - l, err := Parse(blob) - if err != nil { - return Result{State: StateInvalid, Reason: ReasonBadSignature} - } - - res := Result{License: l} - - if l.Deployment != opts.Deployment { - res.State, res.Reason = StateInvalid, ReasonDeploymentMismatch - return res - } - if l.InstanceID != opts.InstanceID { - res.State, res.Reason = StateInvalid, ReasonInstanceMismatch - return res - } - - now := opts.Now - if now.IsZero() { - now = time.Now() - } - res.ClockSkewed = l.IssuedAt.After(now) - - if !now.Before(l.ExpiresAt) { - res.State, res.Reason = StateExpired, ReasonExpired - return res - } - - res.State = StateValid - return res -} - -// Parse verifies the signature only, ignoring binding and expiry. -// -// Used to display a licence and to inspect a blob a customer has emailed in. -// Never use it for enforcement — it does not check who the licence is for. -func Parse(blob string) (License, error) { - parsed, err := lk.LicenseFromB32String(blob) - if err != nil { - return License{}, fmt.Errorf("licence is not readable: %w", err) - } - - keys, err := publicKeys() - if err != nil { - return License{}, err - } - if len(keys) == 0 { - return License{}, fmt.Errorf("this build trusts no licence signing keys") - } - - verified := false - for _, k := range keys { - ok, err := parsed.Verify(k) - if err == nil && ok { - verified = true - break - } - } - if !verified { - return License{}, fmt.Errorf("licence signature does not match any trusted key") - } - - var l License - if err := json.Unmarshal(parsed.Data, &l); err != nil { - return License{}, fmt.Errorf("licence contents are not readable: %w", err) - } - return l, nil -} diff --git a/shared/mail/account.go b/shared/mail/account.go deleted file mode 100644 index 394e1b6..0000000 --- a/shared/mail/account.go +++ /dev/null @@ -1,37 +0,0 @@ -package mail - -import ( - "fmt" - "time" -) - -// VerifyWindow is how long a verification or invitation link stays usable. It -// is stated in the email, so it lives beside the message rather than in the -// caller. -const VerifyWindow = 24 * time.Hour - -// SendVerification asks someone to confirm the address they signed up with. -// The link is built from the Sender's PublicURL, because the address of the -// portal is a property of the deployment, not of the call site. -func (s Sender) SendVerification(to, token string) error { - return s.sendTemplate(to, "", "verification", struct { - Link string - TTLHours int - }{ - Link: fmt.Sprintf("%s/verify?token=%s", s.PublicURL, token), - TTLHours: int(VerifyWindow.Hours()), - }) -} - -// SendInvite asks someone to join an existing account and set their own -// password. It names the account, because an unexpected invitation from a -// service you have never used is otherwise indistinguishable from spam. -func (s Sender) SendInvite(to, accountName, token string) error { - return s.sendTemplate(to, "", "invite", struct { - AccountName string - Link string - }{ - AccountName: accountName, - Link: fmt.Sprintf("%s/accept-invite?token=%s", s.PublicURL, token), - }) -} diff --git a/shared/mail/billing.go b/shared/mail/billing.go deleted file mode 100644 index e987c28..0000000 --- a/shared/mail/billing.go +++ /dev/null @@ -1,18 +0,0 @@ -package mail - -// SendCancelled confirms a cancellation and states what stays true: the licence -// keeps working until it expires, then the instance degrades to read-only. -func (s Sender) SendCancelled(to, instanceName string) error { - return s.sendTemplate(to, "", "cancelled", struct { - InstanceName string - }{instanceName}) -} - -// SendPastDue notifies of a failed charge without alarming: the licence is -// untouched while Paddle retries the card. -func (s Sender) SendPastDue(to, instanceName string) error { - return s.sendTemplate(to, "", "pastdue", struct { - InstanceName string - PortalURL string - }{instanceName, s.PublicURL}) -} diff --git a/shared/mail/contact.go b/shared/mail/contact.go deleted file mode 100644 index 40db208..0000000 --- a/shared/mail/contact.go +++ /dev/null @@ -1,27 +0,0 @@ -package mail - -import "time" - -// Enquiry is one submission of the public site's contact form. -type Enquiry struct { - Name string - Email string - Servers string - Topic string - Message string -} - -// SendEnquiry forwards a contact-form submission to the support address. -// -// Reply-To is the sender's address, not From: the message is sent by our own -// SMTP identity so it passes SPF, but hitting reply must reach the person who -// filled the form in. -func (s Sender) SendEnquiry(to string, e Enquiry) error { - return s.sendTemplate(to, e.Email, "contact", struct { - Enquiry - Received string - }{ - Enquiry: e, - Received: time.Now().UTC().Format(time.RFC1123), - }) -} diff --git a/shared/mail/licence.go b/shared/mail/licence.go deleted file mode 100644 index cd9da19..0000000 --- a/shared/mail/licence.go +++ /dev/null @@ -1,73 +0,0 @@ -package mail - -import ( - "fmt" - "time" -) - -// SendLicense delivers the blob inline. It is signed public data, not a secret — -// it is useless on any instance other than the one it names. -func (s Sender) SendLicense(to, instanceName, blob string) error { - return s.sendTemplate(to, "", "license", struct { - InstanceName string - Blob string - }{instanceName, blob}) -} - -// SendInstanceReady tells a customer their cloud instance exists, where it is, -// and when its licence runs out. -// -// The expiry is stated here rather than only in a later reminder: a Free licence -// that quietly expires in a month is a surprise, and the first email is the one -// people keep. -func (s Sender) SendInstanceReady(to, instanceName, loginURL string, expires time.Time) error { - return s.sendTemplate(to, "", "instanceready", struct { - InstanceName string - LoginURL string - Expires time.Time - }{instanceName, loginURL, expires}) -} - -// SendRenewed confirms a renewal and states the new date. -func (s Sender) SendRenewed(to, instanceName string, expires time.Time) error { - return s.sendTemplate(to, "", "renewed", struct { - InstanceName string - Expires time.Time - }{instanceName, expires}) -} - -// SendExpiring is the renew-now nudge, seven days out. -func (s Sender) SendExpiring(to, instanceName, portalURL string, expires time.Time) error { - return s.sendTemplate(to, "", "expiring", struct { - InstanceName string - PortalURL string - Expires time.Time - }{instanceName, portalURL, expires}) -} - -// SendExpired states plainly what has stopped and what happens next. -// -// It names the deletion date rather than a vague warning: the whole point of the -// sequence is that nobody loses an instance without having been told a date. A -// zero deleteOn means the reaper is disabled, and then no date is claimed. -func (s Sender) SendExpired(to, instanceName, portalURL string, deleteOn time.Time) error { - return s.sendTemplate(to, "", "expired", struct { - InstanceName string - PortalURL string - DeleteOn time.Time - }{instanceName, portalURL, deleteOn}) -} - -// SendDeletionWarning is the final countdown, sent at seven days and one day. -func (s Sender) SendDeletionWarning(to, instanceName, portalURL string, deleteOn time.Time, daysLeft int) error { - when := fmt.Sprintf("in %d days", daysLeft) - if daysLeft <= 1 { - when = "tomorrow" - } - return s.sendTemplate(to, "", "deletionwarning", struct { - InstanceName string - PortalURL string - When string - DeleteOn time.Time - }{instanceName, portalURL, when, deleteOn}) -} diff --git a/shared/mail/monitor.go b/shared/mail/monitor.go deleted file mode 100644 index 14b66fc..0000000 --- a/shared/mail/monitor.go +++ /dev/null @@ -1,26 +0,0 @@ -package mail - -import "time" - -// MonitorEvent is a monitor state transition, as the control plane's -// notification dispatcher sees it. It lives here rather than in server/ so that -// the email templates and the caller agree on the fields without server's model -// package leaking into shared. -type MonitorEvent struct { - MonitorName string - Type string - OldStatus string - NewStatus string - Message string - Time time.Time - - // Down drives the pill and the subject verb. The caller decides it, since - // only server/ knows which status strings mean down. - Down bool -} - -// SendMonitorAlert delivers one state-change notification to an SMTP -// notification channel's recipients, which may be a comma-separated list. -func (s Sender) SendMonitorAlert(to string, ev MonitorEvent) error { - return s.sendTemplate(to, "", "monitoralert", ev) -} diff --git a/shared/mail/render.go b/shared/mail/render.go deleted file mode 100644 index 00ccd29..0000000 --- a/shared/mail/render.go +++ /dev/null @@ -1,121 +0,0 @@ -package mail - -import ( - "embed" - "fmt" - htmltmpl "html/template" - "io/fs" - "strings" - texttmpl "text/template" - "time" -) - -//go:embed templates -var files embed.FS - -// A message is rendered from three files: the shared layout, which owns every -// colour and every piece of chrome, and the message's own html/txt pair, which -// owns only its subject and its content. There is one template set per message -// rather than one big set, because each message defines "subject" and "body" -// under the same names and they would otherwise collide. -type set struct { - html *htmltmpl.Template - text *texttmpl.Template -} - -var sets = map[string]set{} - -func init() { - entries, err := fs.Glob(files, "templates/*.html.tmpl") - if err != nil { - panic("mail: glob templates: " + err.Error()) - } - for _, e := range entries { - name := strings.TrimSuffix(strings.TrimPrefix(e, "templates/"), ".html.tmpl") - if name == "layout" { - continue - } - h, err := htmltmpl.New("layout.html.tmpl").Funcs(htmltmpl.FuncMap(funcs)). - ParseFS(files, "templates/layout.html.tmpl", e) - if err != nil { - panic("mail: parse " + e + ": " + err.Error()) - } - t, err := texttmpl.New("layout.txt.tmpl").Funcs(texttmpl.FuncMap(funcs)). - ParseFS(files, "templates/layout.txt.tmpl", "templates/"+name+".txt.tmpl") - if err != nil { - panic("mail: parse " + name + ".txt.tmpl: " + err.Error()) - } - sets[name] = set{html: h, text: t} - } -} - -// render produces the subject and both bodies for one message. -// -// The subject comes from the text set, not the HTML one: html/template would -// escape an ampersand in an instance name into "&" and mail clients show -// subjects verbatim. -func render(name string, data any) (message, error) { - s, ok := sets[name] - if !ok { - return message{}, fmt.Errorf("no such template") - } - - var subject, text, html strings.Builder - if err := s.text.ExecuteTemplate(&subject, "subject", data); err != nil { - return message{}, err - } - if err := s.text.Execute(&text, data); err != nil { - return message{}, err - } - if err := s.html.Execute(&html, data); err != nil { - return message{}, err - } - - return message{ - Subject: strings.TrimSpace(subject.String()), - Text: normaliseText(text.String()), - HTML: html.String(), - }, nil -} - -// normaliseText gives the plain part CRLF line endings and collapses the blank -// runs that fall out of templating whitespace. -func normaliseText(s string) string { - s = strings.ReplaceAll(s, "\r\n", "\n") - for strings.Contains(s, "\n\n\n") { - s = strings.ReplaceAll(s, "\n\n\n", "\n\n") - } - s = strings.TrimSpace(s) + "\n" - return strings.ReplaceAll(s, "\n", "\r\n") -} - -// funcs are shared by both template flavours. They exist so that a message -// template never formats a date or builds a structure itself — two templates -// formatting the same date two ways is exactly the drift this package removes. -var funcs = map[string]any{ - // dict builds a map for the layout's helper templates, which take more - // than one argument. Go templates have no literal for this. - "dict": func(kv ...any) (map[string]any, error) { - if len(kv)%2 != 0 { - return nil, fmt.Errorf("dict: odd argument count") - } - m := make(map[string]any, len(kv)/2) - for i := 0; i < len(kv); i += 2 { - k, ok := kv[i].(string) - if !ok { - return nil, fmt.Errorf("dict: key %d is not a string", i) - } - m[k] = kv[i+1] - } - return m, nil - }, - "list": func(v ...any) []any { return v }, - - // date is the one long-date format used across every Vantage email. - "date": func(t time.Time) string { return t.Format("2 January 2006") }, - // shortDate drops the year, for subject lines where it is obvious. - "shortDate": func(t time.Time) string { return t.Format("2 January") }, - "stamp": func(t time.Time) string { return t.Format("2006-01-02 15:04:05 MST") }, - "hours": func(d time.Duration) int { return int(d.Hours()) }, - "upper": strings.ToUpper, -} diff --git a/shared/mail/sender.go b/shared/mail/sender.go deleted file mode 100644 index 5c7e991..0000000 --- a/shared/mail/sender.go +++ /dev/null @@ -1,241 +0,0 @@ -// Package mail is the one email system for every Vantage service. -// -// It owns three things that used to exist in three copies: the SMTP -// conversation (including the 465-implicit-TLS case that net/smtp gets wrong), -// the RFC 5322 envelope, and the rendered look of a Vantage email. Callers see -// only typed Send* methods — nobody outside this package builds a subject line, -// a MIME part or a colour. -package mail - -import ( - "crypto/rand" - "crypto/tls" - "encoding/hex" - "fmt" - "mime" - "mime/multipart" - "net" - "net/smtp" - "net/textproto" - "os" - "strings" - "time" -) - -// timeout bounds the whole SMTP conversation. Without it a mail server that -// accepts the connection and then stalls holds an HTTP request open until the -// client gives up — and admin's signup rollback runs on that request's context. -const timeout = 15 * time.Second - -// Sender is a configured SMTP destination. It is a value, not a singleton: -// server/internal/notify builds one per notification channel from data in -// Mongo, while admin and sitesvc build one at boot. -type Sender struct { - Host string - Port string - From string - Username string - Password string - - // PublicURL is the browser origin used to build links in messages that - // carry one (verification, invitations). Empty is fine for senders that - // never send those, such as a monitor notification channel. - PublicURL string -} - -// FromEnv reads the standard SMTP_* variables. Used by services configured -// straight from the environment; admin builds its Sender from its own config -// struct instead. -func FromEnv() Sender { - return Sender{ - Host: os.Getenv("SMTP_HOST"), - Port: envOr("SMTP_PORT", "587"), - Username: os.Getenv("SMTP_USERNAME"), - Password: os.Getenv("SMTP_PASSWORD"), - From: os.Getenv("SMTP_FROM"), - } -} - -func envOr(key, fallback string) string { - if v := os.Getenv(key); v != "" { - return v - } - return fallback -} - -// Enabled reports whether this Sender can deliver anything at all. Callers -// check it to degrade politely rather than logging a failure per message. -func (s Sender) Enabled() bool { return s.Host != "" && s.From != "" } - -// message is one rendered email, ready to be put on the wire. -type message struct { - To string // one address, or a comma-separated list - ReplyTo string - Subject string - HTML string - Text string -} - -// sendTemplate renders name against data and delivers the result. -func (s Sender) sendTemplate(to, replyTo, name string, data any) error { - m, err := render(name, data) - if err != nil { - return fmt.Errorf("mail: render %s: %w", name, err) - } - m.To = to - m.ReplyTo = replyTo - return s.send(m) -} - -// send delivers one message. -// -// Port 465 is implicit TLS: the server expects a TLS handshake immediately, so -// the connection is wrapped BEFORE any SMTP is spoken. Every other port gets -// plaintext then STARTTLS if offered. net/smtp.SendMail only does the latter, -// which is why it fails against a 465 mail server — that bug silently stopped -// every admin email from being delivered once already. -func (s Sender) send(m message) error { - if !s.Enabled() { - return fmt.Errorf("smtp: not configured") - } - rcpts := recipients(m.To) - if len(rcpts) == 0 { - return fmt.Errorf("smtp: no recipient") - } - - addr := net.JoinHostPort(s.Host, s.Port) - conn, err := net.DialTimeout("tcp", addr, timeout) - if err != nil { - return fmt.Errorf("smtp: dial %s: %w", addr, err) - } - _ = conn.SetDeadline(time.Now().Add(timeout)) - - if s.Port == "465" { - conn = tls.Client(conn, &tls.Config{ServerName: s.Host}) - } - - client, err := smtp.NewClient(conn, s.Host) - if err != nil { - conn.Close() - return fmt.Errorf("smtp: client: %w", err) - } - defer client.Close() - - if s.Port != "465" { - if ok, _ := client.Extension("STARTTLS"); ok { - if err := client.StartTLS(&tls.Config{ServerName: s.Host}); err != nil { - return fmt.Errorf("smtp: starttls: %w", err) - } - } - } - - if s.Username != "" { - if err := client.Auth(smtp.PlainAuth("", s.Username, s.Password, s.Host)); err != nil { - return fmt.Errorf("smtp: auth: %w", err) - } - } - - if err := client.Mail(s.From); err != nil { - return fmt.Errorf("smtp: mail from: %w", err) - } - for _, rcpt := range rcpts { - if err := client.Rcpt(rcpt); err != nil { - return fmt.Errorf("smtp: rcpt %s: %w", rcpt, err) - } - } - - body, err := s.envelope(m) - if err != nil { - return fmt.Errorf("smtp: build message: %w", err) - } - - w, err := client.Data() - if err != nil { - return fmt.Errorf("smtp: data: %w", err) - } - if _, err := w.Write(body); err != nil { - return fmt.Errorf("smtp: write: %w", err) - } - if err := w.Close(); err != nil { - return fmt.Errorf("smtp: close data: %w", err) - } - return client.Quit() -} - -func recipients(to string) []string { - parts := strings.Split(to, ",") - out := make([]string, 0, len(parts)) - for _, p := range parts { - if p = strings.TrimSpace(p); p != "" { - out = append(out, p) - } - } - return out -} - -// envelope builds the RFC 5322 message as multipart/alternative. -// -// Date and Message-ID are not decoration: a message without them is scored as -// spam by most filters, which is its own way of "the email never arrived". A -// text part is sent beside every HTML one for the same reason, and because a -// client that refuses HTML should not receive a blank message. Header values -// are stripped of CR and LF so a crafted instance name cannot inject headers. -func (s Sender) envelope(m message) ([]byte, error) { - var parts strings.Builder - w := multipart.NewWriter(&parts) - - textPart, err := w.CreatePart(textproto.MIMEHeader{ - "Content-Type": {"text/plain; charset=utf-8"}, - }) - if err != nil { - return nil, err - } - if _, err := textPart.Write([]byte(m.Text)); err != nil { - return nil, err - } - - htmlPart, err := w.CreatePart(textproto.MIMEHeader{ - "Content-Type": {"text/html; charset=utf-8"}, - }) - if err != nil { - return nil, err - } - if _, err := htmlPart.Write([]byte(m.HTML)); err != nil { - return nil, err - } - - if err := w.Close(); err != nil { - return nil, err - } - - var b strings.Builder - b.WriteString("From: " + sanitizeHeader(s.From) + "\r\n") - b.WriteString("To: " + sanitizeHeader(m.To) + "\r\n") - if m.ReplyTo != "" { - b.WriteString("Reply-To: " + sanitizeHeader(m.ReplyTo) + "\r\n") - } - b.WriteString("Date: " + time.Now().Format(time.RFC1123Z) + "\r\n") - b.WriteString("Message-ID: " + messageID(s.From) + "\r\n") - b.WriteString("Subject: " + mime.QEncoding.Encode("utf-8", sanitizeHeader(m.Subject)) + "\r\n") - b.WriteString("MIME-Version: 1.0\r\n") - b.WriteString("Content-Type: multipart/alternative; boundary=" + w.Boundary() + "\r\n") - b.WriteString("\r\n") - b.WriteString(parts.String()) - return []byte(b.String()), nil -} - -func messageID(from string) string { - domain := "vantage.local" - if at := strings.LastIndex(from, "@"); at >= 0 && at < len(from)-1 { - domain = strings.Trim(from[at+1:], "<> ") - } - var buf [16]byte - if _, err := rand.Read(buf[:]); err != nil { - return fmt.Sprintf("<%d@%s>", time.Now().UnixNano(), domain) - } - return fmt.Sprintf("<%s@%s>", hex.EncodeToString(buf[:]), domain) -} - -func sanitizeHeader(v string) string { - return strings.NewReplacer("\r", " ", "\n", " ").Replace(v) -} diff --git a/shared/mail/templates/cancelled.html.tmpl b/shared/mail/templates/cancelled.html.tmpl deleted file mode 100644 index 56b4d8d..0000000 --- a/shared/mail/templates/cancelled.html.tmpl +++ /dev/null @@ -1,6 +0,0 @@ -{{define "pill"}}{{template "chip" (dict "label" "Cancelled" "tone" "pend")}}{{end}} -{{define "title"}}Your subscription is cancelled{{end}} -{{define "body"}} -{{template "lead" (printf "Your subscription for %s is cancelled." .InstanceName)}} -{{template "p" "Your instance keeps working until the current licence expires. After that, monitors keep running but changes are disabled."}} -{{end}} diff --git a/shared/mail/templates/cancelled.txt.tmpl b/shared/mail/templates/cancelled.txt.tmpl deleted file mode 100644 index 95c387c..0000000 --- a/shared/mail/templates/cancelled.txt.tmpl +++ /dev/null @@ -1,7 +0,0 @@ -{{define "subject"}}Your Vantage subscription is cancelled{{end}} -{{define "pill"}}{{template "chip" (dict "label" "Cancelled" "tone" "pend")}}{{end}} -{{define "title"}}Your subscription is cancelled{{end}} -{{define "body"}} -{{template "lead" (printf "Your subscription for %s is cancelled." .InstanceName)}} -{{template "p" "Your instance keeps working until the current licence expires. After that, monitors keep running but changes are disabled."}} -{{end}} diff --git a/shared/mail/templates/contact.html.tmpl b/shared/mail/templates/contact.html.tmpl deleted file mode 100644 index 165077d..0000000 --- a/shared/mail/templates/contact.html.tmpl +++ /dev/null @@ -1,11 +0,0 @@ -{{define "title"}}New contact enquiry{{end}} -{{define "body"}} -{{template "lead" "Someone has used the contact form on the Vantage site."}} -{{template "rows" (list - (dict "k" "Name" "v" .Name) - (dict "k" "Email" "v" .Email) - (dict "k" "Servers" "v" .Servers) - (dict "k" "Topic" "v" .Topic) - (dict "k" "Received" "v" .Received))}} -{{template "note" .Message}} -{{end}} diff --git a/shared/mail/templates/contact.txt.tmpl b/shared/mail/templates/contact.txt.tmpl deleted file mode 100644 index c076c97..0000000 --- a/shared/mail/templates/contact.txt.tmpl +++ /dev/null @@ -1,13 +0,0 @@ -{{define "subject"}}[Vantage] {{.Topic}} {{.Email}}{{end}} -{{define "title"}}New contact enquiry{{end}} -{{define "body"}} -{{template "lead" "Someone has used the contact form on the Vantage site."}} -{{template "rows" (list - (dict "k" "Name" "v" .Name) - (dict "k" "Email" "v" .Email) - (dict "k" "Servers" "v" .Servers) - (dict "k" "Topic" "v" .Topic) - (dict "k" "Received" "v" .Received))}} -Message: -{{template "note" .Message}} -{{end}} diff --git a/shared/mail/templates/deletionwarning.html.tmpl b/shared/mail/templates/deletionwarning.html.tmpl deleted file mode 100644 index d1ebf3b..0000000 --- a/shared/mail/templates/deletionwarning.html.tmpl +++ /dev/null @@ -1,7 +0,0 @@ -{{define "pill"}}{{template "chip" (dict "label" "Deletion scheduled" "tone" "down")}}{{end}} -{{define "title"}}{{.InstanceName}} will be deleted {{.When}}{{end}} -{{define "body"}} -{{template "lead" (printf "%s and everything in it will be deleted %s, on %s." .InstanceName .When (date .DeleteOn))}} -{{template "p" "This cannot be undone. Renew it to keep it:"}} -{{template "button" (dict "label" "Keep this instance" "url" .PortalURL)}} -{{end}} diff --git a/shared/mail/templates/deletionwarning.txt.tmpl b/shared/mail/templates/deletionwarning.txt.tmpl deleted file mode 100644 index 72b8ece..0000000 --- a/shared/mail/templates/deletionwarning.txt.tmpl +++ /dev/null @@ -1,8 +0,0 @@ -{{define "subject"}}{{.InstanceName}} will be deleted {{.When}}{{end}} -{{define "pill"}}{{template "chip" (dict "label" "Deletion scheduled" "tone" "down")}}{{end}} -{{define "title"}}{{.InstanceName}} will be deleted {{.When}}{{end}} -{{define "body"}} -{{template "lead" (printf "%s and everything in it will be deleted %s, on %s." .InstanceName .When (date .DeleteOn))}} -{{template "p" "This cannot be undone. Renew it to keep it:"}} -{{template "button" (dict "label" "Keep this instance" "url" .PortalURL)}} -{{end}} diff --git a/shared/mail/templates/expired.html.tmpl b/shared/mail/templates/expired.html.tmpl deleted file mode 100644 index 9ed09e3..0000000 --- a/shared/mail/templates/expired.html.tmpl +++ /dev/null @@ -1,8 +0,0 @@ -{{define "pill"}}{{template "chip" (dict "label" "Read-only" "tone" "down")}}{{end}} -{{define "title"}}{{.InstanceName}} is now read-only{{end}} -{{define "body"}} -{{template "lead" (printf "%s's Free licence has expired." .InstanceName)}} -{{template "p" "Your servers and monitors keep running and your agents keep their keys, but changes are disabled."}} -{{template "button" (dict "label" "Renew now" "url" .PortalURL)}} -{{if not .DeleteOn.IsZero}}{{template "p" (printf "If it is not renewed, the instance and everything in it will be deleted on %s." (date .DeleteOn))}}{{end}} -{{end}} diff --git a/shared/mail/templates/expired.txt.tmpl b/shared/mail/templates/expired.txt.tmpl deleted file mode 100644 index 2eba94d..0000000 --- a/shared/mail/templates/expired.txt.tmpl +++ /dev/null @@ -1,9 +0,0 @@ -{{define "subject"}}{{.InstanceName}} is now read-only{{end}} -{{define "pill"}}{{template "chip" (dict "label" "Read-only" "tone" "down")}}{{end}} -{{define "title"}}{{.InstanceName}} is now read-only{{end}} -{{define "body"}} -{{template "lead" (printf "%s's Free licence has expired." .InstanceName)}} -{{template "p" "Your servers and monitors keep running and your agents keep their keys, but changes are disabled."}} -{{template "button" (dict "label" "Renew now" "url" .PortalURL)}} -{{if not .DeleteOn.IsZero}}{{template "p" (printf "If it is not renewed, the instance and everything in it will be deleted on %s." (date .DeleteOn))}}{{end}} -{{end}} diff --git a/shared/mail/templates/expiring.html.tmpl b/shared/mail/templates/expiring.html.tmpl deleted file mode 100644 index ec04b05..0000000 --- a/shared/mail/templates/expiring.html.tmpl +++ /dev/null @@ -1,7 +0,0 @@ -{{define "pill"}}{{template "chip" (dict "label" "Expiring soon" "tone" "pend")}}{{end}} -{{define "title"}}{{.InstanceName}} expires on {{shortDate .Expires}}{{end}} -{{define "body"}} -{{template "lead" (printf "%s's Free licence runs out on %s." .InstanceName (date .Expires))}} -{{template "button" (dict "label" "Renew in one click" "url" .PortalURL)}} -{{template "p" "If you do nothing, the instance keeps running but stops accepting changes."}} -{{end}} diff --git a/shared/mail/templates/expiring.txt.tmpl b/shared/mail/templates/expiring.txt.tmpl deleted file mode 100644 index 9bbedeb..0000000 --- a/shared/mail/templates/expiring.txt.tmpl +++ /dev/null @@ -1,8 +0,0 @@ -{{define "subject"}}{{.InstanceName}} expires on {{shortDate .Expires}}{{end}} -{{define "pill"}}{{template "chip" (dict "label" "Expiring soon" "tone" "pend")}}{{end}} -{{define "title"}}{{.InstanceName}} expires on {{shortDate .Expires}}{{end}} -{{define "body"}} -{{template "lead" (printf "%s's Free licence runs out on %s." .InstanceName (date .Expires))}} -{{template "button" (dict "label" "Renew in one click" "url" .PortalURL)}} -{{template "p" "If you do nothing, the instance keeps running but stops accepting changes."}} -{{end}} diff --git a/shared/mail/templates/instanceready.html.tmpl b/shared/mail/templates/instanceready.html.tmpl deleted file mode 100644 index 7e7fd21..0000000 --- a/shared/mail/templates/instanceready.html.tmpl +++ /dev/null @@ -1,8 +0,0 @@ -{{define "pill"}}{{template "chip" (dict "label" "Ready" "tone" "up")}}{{end}} -{{define "title"}}{{.InstanceName}} is ready{{end}} -{{define "body"}} -{{template "lead" (printf "%s is provisioned and waiting for you." .InstanceName)}} -{{if .LoginURL}}{{template "button" (dict "label" "Sign in" "url" .LoginURL)}}{{end}} -{{template "p" (printf "Your Free licence runs until %s. We will email you before then so you can renew it in one click." (date .Expires))}} -{{template "p" "Sign in with the same email address and password you use for your Vantage account. Changing your Vantage HQ password changes it here too."}} -{{end}} diff --git a/shared/mail/templates/instanceready.txt.tmpl b/shared/mail/templates/instanceready.txt.tmpl deleted file mode 100644 index 2ff3888..0000000 --- a/shared/mail/templates/instanceready.txt.tmpl +++ /dev/null @@ -1,9 +0,0 @@ -{{define "subject"}}{{.InstanceName}} is ready{{end}} -{{define "pill"}}{{template "chip" (dict "label" "Ready" "tone" "up")}}{{end}} -{{define "title"}}{{.InstanceName}} is ready{{end}} -{{define "body"}} -{{template "lead" (printf "%s is provisioned and waiting for you." .InstanceName)}} -{{if .LoginURL}}{{template "button" (dict "label" "Sign in" "url" .LoginURL)}}{{end}} -{{template "p" (printf "Your Free licence runs until %s. We will email you before then so you can renew it in one click." (date .Expires))}} -{{template "p" "Sign in with the same email address and password you use for your Vantage account. Changing your Vantage HQ password changes it here too."}} -{{end}} diff --git a/shared/mail/templates/invite.html.tmpl b/shared/mail/templates/invite.html.tmpl deleted file mode 100644 index 5f292da..0000000 --- a/shared/mail/templates/invite.html.tmpl +++ /dev/null @@ -1,7 +0,0 @@ -{{define "title"}}You have been invited to {{.AccountName}}{{end}} -{{define "body"}} -{{template "lead" (printf "You have been invited to join %s on Vantage." .AccountName)}} -{{template "p" "Set your own password and finish joining:"}} -{{template "button" (dict "label" "Set password and join" "url" .Link)}} -{{template "p" "This link expires in 24 hours. If you were not expecting this, ignore it — nothing happens until you open the link."}} -{{end}} diff --git a/shared/mail/templates/invite.txt.tmpl b/shared/mail/templates/invite.txt.tmpl deleted file mode 100644 index fa5a7a9..0000000 --- a/shared/mail/templates/invite.txt.tmpl +++ /dev/null @@ -1,8 +0,0 @@ -{{define "subject"}}You have been invited to {{.AccountName}} on Vantage{{end}} -{{define "title"}}You have been invited to {{.AccountName}}{{end}} -{{define "body"}} -{{template "lead" (printf "You have been invited to join %s on Vantage." .AccountName)}} -{{template "p" "Set your own password and finish joining:"}} -{{template "button" (dict "label" "Set password and join" "url" .Link)}} -{{template "p" "This link expires in 24 hours. If you were not expecting this, ignore it — nothing happens until you open the link."}} -{{end}} diff --git a/shared/mail/templates/layout.html.tmpl b/shared/mail/templates/layout.html.tmpl deleted file mode 100644 index 84d1f83..0000000 --- a/shared/mail/templates/layout.html.tmpl +++ /dev/null @@ -1,121 +0,0 @@ -{{- /* - The Vantage email shell. - - Every colour in the email system lives in this file and nowhere else, in the - same way no component in web/, site/ or adminsite/ carries a hex. The values - are web/app/globals.css's tokens — an email is read before the recipient - clicks through to the control plane, so the two should not look like - different products. They are written as literal hex here because email - clients support neither var() nor a reliable prefers-color-scheme, so the - usual token indirection is not available: when you change a token in the - three globals.css files, change it here too. - - --ground #071628 --rule #1e3855 - --panel #0d2138 --accent #5b9be8 - --panel-2 #102842 --up #4fb484 - --ink #e4ecf6 --down #e2705a - --ink-2 #9fb3ca --pend #d6a63f - --ink-3 #71879f --well #04101f - - Layout is tables and inline styles throughout, which is not a stylistic - choice — it is the only thing Outlook renders predictably. - - A message file overrides "title", "pill" and "body"; the empty defaults below - exist so that a message needing no pill does not have to define one. -*/ -}} -{{- define "title"}}{{end -}} -{{- define "pill"}}{{end -}} -{{- define "body"}}{{end -}} - -{{- /* p renders one paragraph of body copy. */ -}} -{{- define "p" -}} -

{{.}}

-{{- end -}} - -{{- /* lead is the first paragraph: same size, brighter, sets the subject. */ -}} -{{- define "lead" -}} -

{{.}}

-{{- end -}} - -{{- /* button takes dict "label" "…" "url" "…". - - The bare URL is printed underneath on purpose: a plain-text-preferring - client, a stripped-styles inbox and a forwarded message all lose the - anchor, and a verification email whose link cannot be reached is a - support ticket. */ -}} -{{- define "button" -}} - - - - -
- {{.label}} -
-

- Or paste this into your browser:
- {{.url}} -

-{{- end -}} - -{{- /* well shows machine output — a licence blob, an install ID. Mirrors - web/'s --well surface, the floor beneath the ground. */ -}} -{{- define "well" -}} -
{{.}}
-{{- end -}} - -{{- /* note is a quoted callout, used for a free-text message we did not - write ourselves. */ -}} -{{- define "note" -}} -

{{.}}

-{{- end -}} - -{{- /* rows takes a list of dict "k" "…" "v" "…". */ -}} -{{- define "rows" -}} - - {{- range .}} - - - - - {{- end}} -
{{.k}}{{.v}}
-{{- end -}} - -{{- /* chip takes dict "label" "…" "tone" "up|down|pend|accent". Tone is - never the only signal: the label spells the state out. */ -}} -{{- define "chip" -}} -{{- $fg := "#5b9be8"}}{{if eq .tone "up"}}{{$fg = "#4fb484"}}{{else if eq .tone "down"}}{{$fg = "#e2705a"}}{{else if eq .tone "pend"}}{{$fg = "#d6a63f"}}{{end -}} -{{.label}} -{{- end -}} - - - - - - - - -
- - - - - - - - - - - -
- Vantage -
- {{template "pill" .}} -

{{template "title" .}}

- {{template "body" .}} -
-

Sent by Vantage · infrastructure control plane

-
-
- - diff --git a/shared/mail/templates/layout.txt.tmpl b/shared/mail/templates/layout.txt.tmpl deleted file mode 100644 index 2d28593..0000000 --- a/shared/mail/templates/layout.txt.tmpl +++ /dev/null @@ -1,46 +0,0 @@ -{{- /* - The plain-text counterpart of layout.html.tmpl. - - It defines the same helper names — p, lead, button, well, note, rows, chip — - so a message's txt file reads as the same document as its html one, and a - helper added on one side is obvious by its absence on the other. - - "subject" is defined here rather than in the HTML file: html/template would - escape an ampersand in an instance name, and mail clients show subject lines - verbatim. -*/ -}} -{{- define "subject"}}Vantage{{end -}} -{{- define "title"}}{{end -}} -{{- define "pill"}}{{end -}} -{{- define "body"}}{{end -}} - -{{- define "p"}}{{.}} - -{{end -}} -{{- define "lead"}}{{.}} - -{{end -}} -{{- define "button"}}{{.label}}: - -{{.url}} - -{{end -}} -{{- define "well"}}{{.}} - -{{end -}} -{{- define "note"}}{{.}} - -{{end -}} -{{- define "rows"}}{{range .}}{{.k}}: {{.v}} -{{end}} -{{end -}} -{{- define "chip"}}[{{upper .label}}] - -{{end -}} -VANTAGE -{{template "pill" .}} -{{template "title" .}} - -{{template "body" .}} --- -Sent by Vantage · infrastructure control plane diff --git a/shared/mail/templates/license.html.tmpl b/shared/mail/templates/license.html.tmpl deleted file mode 100644 index 4af696a..0000000 --- a/shared/mail/templates/license.html.tmpl +++ /dev/null @@ -1,7 +0,0 @@ -{{define "title"}}Your licence key{{end}} -{{define "body"}} -{{template "lead" (printf "Your licence for %s is below." .InstanceName)}} -{{template "p" "Paste it into Settings → Licence on your Vantage install:"}} -{{template "well" .Blob}} -{{template "p" "The licence is signed public data, not a secret — it is useless on any instance other than the one it names."}} -{{end}} diff --git a/shared/mail/templates/license.txt.tmpl b/shared/mail/templates/license.txt.tmpl deleted file mode 100644 index c52b5e6..0000000 --- a/shared/mail/templates/license.txt.tmpl +++ /dev/null @@ -1,8 +0,0 @@ -{{define "subject"}}Your Vantage licence key{{end}} -{{define "title"}}Your licence key{{end}} -{{define "body"}} -{{template "lead" (printf "Your licence for %s is below." .InstanceName)}} -{{template "p" "Paste it into Settings > Licence on your Vantage install:"}} -{{template "well" .Blob}} -{{template "p" "The licence is signed public data, not a secret — it is useless on any instance other than the one it names."}} -{{end}} diff --git a/shared/mail/templates/monitoralert.html.tmpl b/shared/mail/templates/monitoralert.html.tmpl deleted file mode 100644 index 84ee42f..0000000 --- a/shared/mail/templates/monitoralert.html.tmpl +++ /dev/null @@ -1,10 +0,0 @@ -{{define "pill"}}{{if .Down}}{{template "chip" (dict "label" "Down" "tone" "down")}}{{else}}{{template "chip" (dict "label" "Recovered" "tone" "up")}}{{end}}{{end}} -{{define "title"}}{{.MonitorName}}{{end}} -{{define "body"}} -{{template "p" (printf "%s check" .Type)}} -{{if .Message}}{{template "note" .Message}}{{end}} -{{template "rows" (list - (dict "k" "Status" "v" (printf "%s → %s" .OldStatus .NewStatus)) - (dict "k" "Type" "v" .Type) - (dict "k" "Time" "v" (stamp .Time)))}} -{{end}} diff --git a/shared/mail/templates/monitoralert.txt.tmpl b/shared/mail/templates/monitoralert.txt.tmpl deleted file mode 100644 index 34d480a..0000000 --- a/shared/mail/templates/monitoralert.txt.tmpl +++ /dev/null @@ -1,11 +0,0 @@ -{{define "subject"}}[Vantage] {{.MonitorName}} ({{.Type}}) {{if .Down}}is DOWN{{else}}recovered{{end}}{{if .Message}}: {{.Message}}{{end}}{{end}} -{{define "pill"}}{{if .Down}}{{template "chip" (dict "label" "Down" "tone" "down")}}{{else}}{{template "chip" (dict "label" "Recovered" "tone" "up")}}{{end}}{{end}} -{{define "title"}}{{.MonitorName}}{{end}} -{{define "body"}} -{{template "p" (printf "%s check" .Type)}} -{{if .Message}}{{template "note" .Message}}{{end}} -{{template "rows" (list - (dict "k" "Status" "v" (printf "%s -> %s" .OldStatus .NewStatus)) - (dict "k" "Type" "v" .Type) - (dict "k" "Time" "v" (stamp .Time)))}} -{{end}} diff --git a/shared/mail/templates/pastdue.html.tmpl b/shared/mail/templates/pastdue.html.tmpl deleted file mode 100644 index 611f485..0000000 --- a/shared/mail/templates/pastdue.html.tmpl +++ /dev/null @@ -1,7 +0,0 @@ -{{define "pill"}}{{template "chip" (dict "label" "Payment failed" "tone" "pend")}}{{end}} -{{define "title"}}Payment failed for {{.InstanceName}}{{end}} -{{define "body"}} -{{template "lead" (printf "A payment for %s failed." .InstanceName)}} -{{template "p" "Your instance is unaffected while the card is retried. Update your payment method from the billing portal."}} -{{if .PortalURL}}{{template "button" (dict "label" "Open billing portal" "url" .PortalURL)}}{{end}} -{{end}} diff --git a/shared/mail/templates/pastdue.txt.tmpl b/shared/mail/templates/pastdue.txt.tmpl deleted file mode 100644 index c3319b8..0000000 --- a/shared/mail/templates/pastdue.txt.tmpl +++ /dev/null @@ -1,8 +0,0 @@ -{{define "subject"}}Payment failed for your Vantage subscription{{end}} -{{define "pill"}}{{template "chip" (dict "label" "Payment failed" "tone" "pend")}}{{end}} -{{define "title"}}Payment failed for {{.InstanceName}}{{end}} -{{define "body"}} -{{template "lead" (printf "A payment for %s failed." .InstanceName)}} -{{template "p" "Your instance is unaffected while the card is retried. Update your payment method from the billing portal."}} -{{if .PortalURL}}{{template "button" (dict "label" "Open billing portal" "url" .PortalURL)}}{{end}} -{{end}} diff --git a/shared/mail/templates/renewed.html.tmpl b/shared/mail/templates/renewed.html.tmpl deleted file mode 100644 index 3f2e0a5..0000000 --- a/shared/mail/templates/renewed.html.tmpl +++ /dev/null @@ -1,6 +0,0 @@ -{{define "pill"}}{{template "chip" (dict "label" "Renewed" "tone" "up")}}{{end}} -{{define "title"}}{{.InstanceName}} is renewed{{end}} -{{define "body"}} -{{template "lead" (printf "Your Free licence for %s now runs until %s." .InstanceName (date .Expires))}} -{{template "p" "Nothing else changes — your servers, agents and monitors carry on as they were."}} -{{end}} diff --git a/shared/mail/templates/renewed.txt.tmpl b/shared/mail/templates/renewed.txt.tmpl deleted file mode 100644 index bddaad1..0000000 --- a/shared/mail/templates/renewed.txt.tmpl +++ /dev/null @@ -1,7 +0,0 @@ -{{define "subject"}}{{.InstanceName}} renewed{{end}} -{{define "pill"}}{{template "chip" (dict "label" "Renewed" "tone" "up")}}{{end}} -{{define "title"}}{{.InstanceName}} is renewed{{end}} -{{define "body"}} -{{template "lead" (printf "Your Free licence for %s now runs until %s." .InstanceName (date .Expires))}} -{{template "p" "Nothing else changes — your servers, agents and monitors carry on as they were."}} -{{end}} diff --git a/shared/mail/templates/verification.html.tmpl b/shared/mail/templates/verification.html.tmpl deleted file mode 100644 index d22c4f2..0000000 --- a/shared/mail/templates/verification.html.tmpl +++ /dev/null @@ -1,7 +0,0 @@ -{{define "title"}}Confirm your email address{{end}} -{{define "body"}} -{{template "lead" "Confirm this address to finish setting up your Vantage account."}} -{{template "button" (dict "label" "Confirm email address" "url" .Link)}} -{{template "p" (printf "The link works once and expires in %d hours." .TTLHours)}} -{{template "p" "If you did not request this, ignore this email — nothing happens until the link is opened."}} -{{end}} diff --git a/shared/mail/templates/verification.txt.tmpl b/shared/mail/templates/verification.txt.tmpl deleted file mode 100644 index b71edf9..0000000 --- a/shared/mail/templates/verification.txt.tmpl +++ /dev/null @@ -1,8 +0,0 @@ -{{define "subject"}}Verify your Vantage account{{end}} -{{define "title"}}Confirm your email address{{end}} -{{define "body"}} -{{template "lead" "Confirm this address to finish setting up your Vantage account."}} -{{template "button" (dict "label" "Confirm email address" "url" .Link)}} -{{template "p" (printf "The link works once and expires in %d hours." .TTLHours)}} -{{template "p" "If you did not request this, ignore this email — nothing happens until the link is opened."}} -{{end}} diff --git a/shared/mail/templates/vuln_digest.html.tmpl b/shared/mail/templates/vuln_digest.html.tmpl deleted file mode 100644 index 9d2679e..0000000 --- a/shared/mail/templates/vuln_digest.html.tmpl +++ /dev/null @@ -1,13 +0,0 @@ -{{define "title"}}New vulnerabilities detected{{end}} -{{define "pill"}}{{template "chip" (dict "label" (upper .TopSeverity) "tone" "down")}}{{end}} -{{define "body"}} -{{template "lead" .Summary}} -{{template "rows" (list - (dict "k" "Instance" "v" .InstanceName) - (dict "k" "New findings" "v" .Count))}} -{{range .Rows}} -{{if .FixedIn}}{{template "well" (printf "%s (%s) — %s on %s, fixed in %s" .CVEID .Severity .PackageName .ServerName .FixedIn)}}{{else}}{{template "well" (printf "%s (%s) — %s on %s, no fix published" .CVEID .Severity .PackageName .ServerName)}}{{end}} -{{end}} -{{if .More}}{{template "p" (printf "…and %d more." .More)}}{{end}} -{{template "note" (printf "Scanned against a vulnerability database pulled %s ago." .DBAge)}} -{{end}} diff --git a/shared/mail/templates/vuln_digest.txt.tmpl b/shared/mail/templates/vuln_digest.txt.tmpl deleted file mode 100644 index 086c803..0000000 --- a/shared/mail/templates/vuln_digest.txt.tmpl +++ /dev/null @@ -1,12 +0,0 @@ -{{define "subject"}}{{.Count}} new {{if eq .Count 1}}vulnerability{{else}}vulnerabilities{{end}} on {{.InstanceName}}{{end}} -{{define "title"}}New vulnerabilities detected{{end}} -{{define "pill"}}{{.TopSeverity}}{{end}} -{{define "body"}} -{{template "lead" .Summary}} - -{{range .Rows}}- {{.CVEID}} ({{.Severity}}) — {{.PackageName}} on {{.ServerName}}{{if .FixedIn}}, fixed in {{.FixedIn}}{{else}}, no fix published{{end}} -{{end}} -{{if .More}}...and {{.More}} more.{{end}} - -Scanned against vulnerability database pulled {{.DBAge}} ago. -{{end}} \ No newline at end of file diff --git a/shared/mail/vuln.go b/shared/mail/vuln.go deleted file mode 100644 index a82215d..0000000 --- a/shared/mail/vuln.go +++ /dev/null @@ -1,41 +0,0 @@ -package mail - -// VulnDigestRow is one newly opened finding as the digest shows it. -// -// It lives here rather than in server/ so the templates and the caller agree on -// the fields without server's model package leaking into shared. -type VulnDigestRow struct { - CVEID string - Severity string - PackageName string - ServerName string - // FixedIn empty means no vendor fix has been published, which the template - // says explicitly rather than leaving blank — it is a real state, not - // missing data. - FixedIn string -} - -// VulnDigest is one batch of newly opened findings. -// -// One message per rule per scan, never one per finding: a database refresh can -// open several hundred at once, and one message each would rate-limit the -// webhook or get the channel muted. -type VulnDigest struct { - InstanceName string - // Count is every newly opened finding in the batch, which may exceed - // len(Rows) — Rows is capped and More carries the remainder. - Count int - TopSeverity string - Summary string - Rows []VulnDigestRow - More int - // DBAge is pre-formatted by the caller. A digest scanned against a - // three-week-old database must say so rather than quietly imply freshness. - DBAge string -} - -// SendVulnDigest delivers one digest to an SMTP notification channel's -// recipients, which may be a comma-separated list. -func (s Sender) SendVulnDigest(to string, d VulnDigest) error { - return s.sendTemplate(to, "", "vuln_digest", d) -} diff --git a/shared/models/instance.go b/shared/models/instance.go deleted file mode 100644 index 9f4c244..0000000 --- a/shared/models/instance.go +++ /dev/null @@ -1,34 +0,0 @@ -// Package models holds the MongoDB documents written by more than one Vantage -// service. Documents only the control plane touches stay in -// server/internal/models. -package models - -import ( - "time" - - "go.mongodb.org/mongo-driver/v2/bson" -) - -// Instance is one deployment of Vantage: its own subdomain, users, servers, -// keys, workflows, monitors and secrets. It is the unit a licence attaches to. -// -// A paying customer may hold several. That grouping is called an Account and -// lives only in the admin control plane — this service never sees it. -type Instance struct { - ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"` - InstanceID string `bson:"instance_id" json:"instance_id"` - Name string `bson:"name" json:"name"` - Slug string `bson:"slug" json:"slug"` - CreatedAt time.Time `bson:"created_at" json:"created_at"` - - // LicenseBlob is the authoritative licence. LicenseTier and LicenseExpiry - // are a denormalised cache for listing and for the admin service's queries, - // rewritten from the verified payload every time a blob is accepted. - // Nothing reads them for enforcement. - // - // The blob is json:"-" because there is no reason to spray it through API - // responses. It is signed public data, not a secret. - LicenseBlob string `bson:"license_blob,omitempty" json:"-"` - LicenseTier string `bson:"license_tier,omitempty" json:"license_tier,omitempty"` - LicenseExpiry *time.Time `bson:"license_expiry,omitempty" json:"license_expiry,omitempty"` -} diff --git a/shared/models/settings.go b/shared/models/settings.go deleted file mode 100644 index e9b6dad..0000000 --- a/shared/models/settings.go +++ /dev/null @@ -1,73 +0,0 @@ -package models - -import ( - "time" - - "go.mongodb.org/mongo-driver/v2/bson" -) - -// AlertSettings no longer carries a webhook URL or SMTP configuration of its -// own. Agent-offline alerts are delivered through notification channels, the -// same destinations monitors use, so there is one place to configure a -// destination and one place to test it. -type AlertSettings struct { - OfflineThresholdMinutes int `bson:"offline_threshold_minutes" json:"offline_threshold_minutes"` - OfflineChannelIDs []string `bson:"offline_channel_ids" json:"offline_channel_ids"` -} - -type SecretsSettings struct { - ReadTokenHash string `bson:"read_token_hash,omitempty" json:"-"` - ReadTokenSet bool `bson:"-" json:"read_token_set"` - RotatedAt time.Time `bson:"rotated_at,omitempty" json:"rotated_at,omitempty"` -} - -type Settings struct { - ID bson.ObjectID `bson:"_id,omitempty" json:"-"` - InstanceID string `bson:"instance_id" json:"instance_id"` - Alerts AlertSettings `bson:"alerts" json:"alerts"` - Secrets SecretsSettings `bson:"secrets" json:"secrets"` - - WorkflowLogRetentionDays *int `bson:"workflow_log_retention_days,omitempty" json:"workflow_log_retention_days,omitempty"` - - // LocalLoginEnabled is a pointer because it is absent on every settings - // document written before this feature existed, and a plain bool would read - // absent as disabled — turning off password login for the entire fleet at - // upgrade. Nil means enabled. - LocalLoginEnabled *bool `bson:"local_login_enabled,omitempty" json:"local_login_enabled,omitempty"` - - // VulnFindingRetentionDays is a pointer for the same reason - // WorkflowLogRetentionDays is: absent must mean the default, not zero. - // Nil is 90 days, 0 is forever. Only "fixed" findings are ever swept. - VulnFindingRetentionDays *int `bson:"vuln_finding_retention_days,omitempty" json:"vuln_finding_retention_days,omitempty"` - - // APITokenMaxDays caps how long a newly created API token may live. - // - // A pointer for the same reason the retention fields are: absent must mean - // the default, and the default here is no cap at all — never-expire tokens - // are allowed until an instance decides otherwise, so an upgrade changes - // nothing. Nil or 0 is no cap. A positive value refuses both a longer - // expiry and a token with no expiry. - // - // It is a policy on issuance, not on use: raising or lowering it never - // invalidates a token that already exists. - APITokenMaxDays *int `bson:"api_token_max_days,omitempty" json:"api_token_max_days,omitempty"` -} - -// LocalLoginEnabled reads the setting with its absent-means-on default. Every -// caller must go through this rather than dereferencing the field. -func LocalLoginEnabled(s *Settings) bool { - if s == nil || s.LocalLoginEnabled == nil { - return true - } - return *s.LocalLoginEnabled -} - -// APITokenMaxDays reads the token lifetime cap with its absent-means-uncapped -// default. 0 means no cap. Every caller must go through this rather than -// dereferencing the field. -func APITokenMaxDays(s *Settings) int { - if s == nil || s.APITokenMaxDays == nil || *s.APITokenMaxDays < 0 { - return 0 - } - return *s.APITokenMaxDays -} diff --git a/shared/models/user.go b/shared/models/user.go deleted file mode 100644 index 56aecba..0000000 --- a/shared/models/user.go +++ /dev/null @@ -1,47 +0,0 @@ -package models - -import ( - "time" - - "go.mongodb.org/mongo-driver/v2/bson" -) - -const ( - RoleOwner = "owner" - RoleAdmin = "admin" - RoleMember = "member" -) - -func ValidRole(role string) bool { - switch role { - case RoleOwner, RoleAdmin, RoleMember: - return true - } - return false -} - -// Auth sources. A user's auth_source says who owns the row. -const ( - AuthLocal = "local" - AuthOIDC = "oidc" - // AuthHQ marks a user projected from a Vantage HQ account. Its role, - // password and existence are owned by HQ, and the instance API refuses to - // change any of them locally — a role editable in two places is a role with - // two answers. - AuthHQ = "hq" -) - -type User struct { - ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"` - UserID string `bson:"user_id" json:"user_id"` - InstanceID string `bson:"instance_id" json:"instance_id"` - Email string `bson:"email" json:"email"` - PasswordHash string `bson:"password_hash,omitempty" json:"-"` - Role string `bson:"role" json:"role"` - AuthSource string `bson:"auth_source" json:"auth_source"` - // HQUserID is the customer_users.user_id this row was projected from, - // absent on locally-created users. - HQUserID string `bson:"hq_user_id,omitempty" json:"hq_user_id,omitempty"` - CreatedAt time.Time `bson:"created_at" json:"created_at"` - LastLogin *time.Time `bson:"last_login,omitempty" json:"last_login,omitempty"` -} diff --git a/shared/provision/instance.go b/shared/provision/instance.go deleted file mode 100644 index 96be785..0000000 --- a/shared/provision/instance.go +++ /dev/null @@ -1,189 +0,0 @@ -package provision - -import ( - "context" - "errors" - "fmt" - "time" - - "gitea.hostxtra.co.uk/vantage/vantage-shared/models" - "github.com/google/uuid" - "go.mongodb.org/mongo-driver/v2/bson" - "go.mongodb.org/mongo-driver/v2/mongo" -) - -// ErrNameRejected wraps every reason a name cannot become an instance. -var ErrNameRejected = errors.New("organisation name rejected") - -const maxSlugAttempts = 50 - -// CreateInstance inserts an instance under the first free slug derived from name, -// with a freshly generated ID. -func CreateInstance(ctx context.Context, db *mongo.Database, name string) (*models.Instance, error) { - return CreateInstanceWithID(ctx, db, uuid.NewString(), name) -} - -// CreateInstanceWithID inserts an instance under the first free slug derived from -// name, using a caller-supplied instance ID. -// -// A caller-supplied ID exists for the paid-cloud flow: a placeholder row is -// created before payment and provisioning happens on the confirmed-payment -// webhook. Provisioning with the placeholder's own ID keeps the id stable, so -// the subscription's custom_data never points at a rewritten row and later -// webhooks still resolve it. If an instance with this ID already exists — a -// webhook retried after a partial provision — it is returned as-is rather than -// duplicated. -// -// The count-then-insert loop is racy on its own. It is safe only because -// instances.slug carries a unique index: a lost race surfaces as a duplicate-key -// error, which we treat as "that slug is taken" and retry. Do not remove the -// duplicate-key branch, and do not remove the index. -func CreateInstanceWithID(ctx context.Context, db *mongo.Database, instanceID, name string) (*models.Instance, error) { - // Idempotency: a retried provision finds its own instance already present. - var existing models.Instance - if err := db.Collection("instances").FindOne(ctx, - bson.M{"instance_id": instanceID}).Decode(&existing); err == nil { - return &existing, nil - } - - base, err := BaseSlug(name) - if err != nil { - return nil, fmt.Errorf("%w: %s", ErrNameRejected, err.Error()) - } - - for attempt := 1; attempt <= maxSlugAttempts; attempt++ { - slug := NextSlug(base, attempt) - - n, err := db.Collection("instances").CountDocuments(ctx, bson.M{"slug": slug}) - if err != nil { - return nil, err - } - if n > 0 { - continue - } - - inst := models.Instance{ - InstanceID: instanceID, - Name: name, - Slug: slug, - CreatedAt: time.Now().UTC(), - } - if _, err := db.Collection("instances").InsertOne(ctx, inst); err != nil { - if mongo.IsDuplicateKeyError(err) { - continue // lost the race; try the next slug - } - return nil, err - } - return &inst, nil - } - return nil, fmt.Errorf("%w: could not find a free slug for %q", ErrNameRejected, name) -} - -// ErrSlugTaken means the slug a new name derives to already belongs to another -// instance. -// -// Rename refuses rather than appending a counter the way creation does. Creation -// appends because the customer is waiting on an instance and any free slug will -// do; a rename is a request for one specific host, and silently landing them on -// "acme-2" answers a question they did not ask. -var ErrSlugTaken = errors.New("slug taken") - -// RenameSlug derives the slug a rename to name would move an instance to, given -// the slug it holds now. -// -// It returns the current slug unchanged when the name still derives to it, so a -// cosmetic edit — capitalisation, punctuation, a trailing "Ltd." — is not a move -// and cannot collide with the instance's own slug. -func RenameSlug(name, currentSlug string) (string, error) { - base, err := BaseSlug(name) - if err != nil { - return "", fmt.Errorf("%w: %s", ErrNameRejected, err.Error()) - } - if base == currentSlug { - return currentSlug, nil - } - return base, nil -} - -// RenameInstance changes an instance's name and re-derives its slug from it. -// -// It returns the name and slug the control plane held BEFORE the write, and -// those are the only correct values to unwind with. The caller's own copy of the -// instance may be stale, and admin's copy stores slug with `omitempty`, so an -// unwind driven from there can write an empty slug — which either mis-restores -// the tenant host or trips the unique index against every other slugless row. -// -// The count-then-update is racy on its own, and is safe for the same reason -// CreateInstanceWithID's loop is: instances.slug carries a unique index, so a -// lost race surfaces as a duplicate-key error. Unlike creation there is nothing -// to retry with — the caller asked for one specific name — so it becomes -// ErrSlugTaken. Do not remove the duplicate-key branch, and do not remove the -// index. -func RenameInstance(ctx context.Context, db *mongo.Database, instanceID, name string) (inst *models.Instance, prevName, prevSlug string, err error) { - var cur models.Instance - if err := db.Collection("instances").FindOne(ctx, - bson.M{"instance_id": instanceID}).Decode(&cur); err != nil { - return nil, "", "", err - } - prevName, prevSlug = cur.Name, cur.Slug - - slug, err := RenameSlug(name, cur.Slug) - if err != nil { - return nil, prevName, prevSlug, err - } - - if slug != cur.Slug { - n, err := db.Collection("instances").CountDocuments(ctx, bson.M{ - "slug": slug, - "instance_id": bson.M{"$ne": instanceID}, - }) - if err != nil { - return nil, prevName, prevSlug, err - } - if n > 0 { - return nil, prevName, prevSlug, fmt.Errorf("%w: %s", ErrSlugTaken, slug) - } - } - - if _, err := db.Collection("instances").UpdateOne(ctx, - bson.M{"instance_id": instanceID}, - bson.M{"$set": bson.M{"name": name, "slug": slug}}); err != nil { - if mongo.IsDuplicateKeyError(err) { - return nil, prevName, prevSlug, fmt.Errorf("%w: %s", ErrSlugTaken, slug) - } - return nil, prevName, prevSlug, err - } - - cur.Name = name - cur.Slug = slug - return &cur, prevName, prevSlug, nil -} - -// RestoreInstanceIdentity writes an exact name and slug back, unwinding a rename -// whose caller-side bookkeeping then failed. -// -// It derives nothing. The values being restored may include a creation-time -// collision suffix that no name derives to, so re-running RenameInstance with the -// old name would not reproduce them. -func RestoreInstanceIdentity(ctx context.Context, db *mongo.Database, instanceID, name, slug string) error { - _, err := db.Collection("instances").UpdateOne(ctx, - bson.M{"instance_id": instanceID}, - bson.M{"$set": bson.M{"name": name, "slug": slug}}) - return err -} - -// RollbackInstance deletes an instance that has no users. -// -// It refuses an instance that has users. Rollback exists to clean up a -// half-finished signup, and an instance with users is not half-finished. -func RollbackInstance(ctx context.Context, db *mongo.Database, instanceID string) error { - n, err := db.Collection("users").CountDocuments(ctx, bson.M{"instance_id": instanceID}) - if err != nil { - return err - } - if n > 0 { - return fmt.Errorf("refusing to roll back instance %s: it has %d user(s)", instanceID, n) - } - _, err = db.Collection("instances").DeleteOne(ctx, bson.M{"instance_id": instanceID}) - return err -} diff --git a/shared/provision/slug.go b/shared/provision/slug.go deleted file mode 100644 index 9656448..0000000 --- a/shared/provision/slug.go +++ /dev/null @@ -1,59 +0,0 @@ -// Package provision holds the tenant creation rules shared by the control -// plane and sitesvc. -// -// These rules used to be duplicated: the control plane owned one copy and -// sitesvc mirrored it by hand. The copies had already drifted — sitesvc retried -// on a lost slug race while the control plane returned an error. This package -// is the single definition; neither service may reimplement any of it. -package provision - -import ( - "fmt" - "regexp" - "strings" -) - -const ( - MinSlugLength = 3 - MaxSlugLength = 40 -) - -var slugStrip = regexp.MustCompile(`[^a-z0-9]+`) - -// ReservedSlugs are subdomain labels the platform needs for itself. -var ReservedSlugs = map[string]bool{ - "www": true, "api": true, "app": true, "admin": true, "auth": true, - "install": true, "static": true, "_next": true, "default": true, -} - -// Slugify lowercases a name and collapses every run of non-alphanumeric -// characters into a single hyphen, trimming hyphens from both ends. -func Slugify(name string) string { - s := strings.ToLower(name) - s = slugStrip.ReplaceAllString(s, "-") - return strings.Trim(s, "-") -} - -// BaseSlug turns a name into a validated slug stem, or explains why it cannot. -func BaseSlug(name string) (string, error) { - base := Slugify(name) - if len(base) < MinSlugLength { - return "", fmt.Errorf("organisation name too short (slug must be at least %d characters)", MinSlugLength) - } - if len(base) > MaxSlugLength { - base = base[:MaxSlugLength] - } - if ReservedSlugs[base] { - return "", fmt.Errorf("that organisation name is reserved") - } - return base, nil -} - -// NextSlug returns the candidate slug for a given attempt. Attempt 1 is the -// base itself; later attempts append a counter. -func NextSlug(base string, attempt int) string { - if attempt < 2 { - return base - } - return fmt.Sprintf("%s-%d", base, attempt) -} diff --git a/shared/provision/user.go b/shared/provision/user.go deleted file mode 100644 index c0da2e7..0000000 --- a/shared/provision/user.go +++ /dev/null @@ -1,65 +0,0 @@ -package provision - -import ( - "context" - "errors" - "fmt" - "strings" - "time" - - "gitea.hostxtra.co.uk/vantage/vantage-shared/models" - "github.com/google/uuid" - "go.mongodb.org/mongo-driver/v2/mongo" - "golang.org/x/crypto/bcrypt" -) - -// BcryptCost is the work factor for every password hash Vantage writes. -// Changing it changes nothing about existing hashes, which carry their own cost. -const BcryptCost = 12 - -// ErrEmailTaken is returned when the unique index on users.email rejects an insert. -var ErrEmailTaken = errors.New("email already registered") - -// CreateUser hashes password and inserts the user. An empty password leaves the -// hash empty, which is how OIDC users are stored. -func CreateUser(ctx context.Context, db *mongo.Database, instanceID, email, password, role, authSource string) (*models.User, error) { - var hash string - if password != "" { - b, err := bcrypt.GenerateFromPassword([]byte(password), BcryptCost) - if err != nil { - return nil, err - } - hash = string(b) - } - return CreateUserWithHash(ctx, db, instanceID, email, hash, role, authSource) -} - -// CreateUserWithHash inserts a user whose password was already hashed -// elsewhere. sitesvc hashes at signup and only holds the hash by the time the -// verification link is opened. -func CreateUserWithHash(ctx context.Context, db *mongo.Database, instanceID, email, passwordHash, role, authSource string) (*models.User, error) { - email = strings.ToLower(strings.TrimSpace(email)) - if email == "" { - return nil, fmt.Errorf("email required") - } - if !models.ValidRole(role) { - return nil, fmt.Errorf("invalid role %q", role) - } - - u := &models.User{ - UserID: uuid.NewString(), - InstanceID: instanceID, - Email: email, - PasswordHash: passwordHash, - Role: role, - AuthSource: authSource, - CreatedAt: time.Now().UTC(), - } - if _, err := db.Collection("users").InsertOne(ctx, u); err != nil { - if mongo.IsDuplicateKeyError(err) { - return nil, ErrEmailTaken - } - return nil, err - } - return u, nil -} diff --git a/sitesvc/.dockerignore b/sitesvc/.dockerignore new file mode 100644 index 0000000..4c49bd7 --- /dev/null +++ b/sitesvc/.dockerignore @@ -0,0 +1 @@ +.env diff --git a/sitesvc/Dockerfile b/sitesvc/Dockerfile index f39689b..3f72358 100644 --- a/sitesvc/Dockerfile +++ b/sitesvc/Dockerfile @@ -1,16 +1,23 @@ -# Context is the repository root; sitesvc depends on the shared module. +# Context is sitesvc/ itself. It used to be the repository root, so that +# shared/ could be copied in beside it; shared is now the private module +# gitea.hostxtra.co.uk/vantage/vantage-shared, fetched like any other +# dependency. The credential for it arrives as a BuildKit secret rather than a +# build arg, which would be baked into this stage's layer history. FROM golang:1.26-alpine AS builder WORKDIR /src -COPY shared/go.mod shared/go.sum ./shared/ -COPY sitesvc/go.mod sitesvc/go.sum ./sitesvc/ -RUN cd sitesvc && go mod download +ENV GOPRIVATE=gitea.hostxtra.co.uk/* +RUN apk add --no-cache git -COPY shared/ ./shared/ -COPY sitesvc/ ./sitesvc/ +COPY go.mod go.sum ./ +RUN --mount=type=secret,id=netrc,target=/root/.netrc \ + go mod download -RUN cd sitesvc && CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/sitesvc ./cmd +COPY . . + +RUN --mount=type=secret,id=netrc,target=/root/.netrc \ + CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/sitesvc ./cmd FROM alpine:3.20 AS runner diff --git a/sitesvc/go.mod b/sitesvc/go.mod index 6e8fb77..feec6f5 100644 --- a/sitesvc/go.mod +++ b/sitesvc/go.mod @@ -4,6 +4,4 @@ go 1.26 require github.com/joho/godotenv v1.5.1 -require gitea.hostxtra.co.uk/vantage/vantage-shared v0.0.0 - -replace gitea.hostxtra.co.uk/vantage/vantage-shared => ../shared +require gitea.hostxtra.co.uk/vantage/vantage-shared v0.1.0 diff --git a/sitesvc/go.sum b/sitesvc/go.sum index d61b19e..ea9204c 100644 --- a/sitesvc/go.sum +++ b/sitesvc/go.sum @@ -1,2 +1,4 @@ +gitea.hostxtra.co.uk/vantage/vantage-shared v0.1.0 h1:H6PCb8JHucrRiqPe9kGOhXUjBD66tKFHCP3qz5TjdZc= +gitea.hostxtra.co.uk/vantage/vantage-shared v0.1.0/go.mod h1:dWjeOFLltQ8sv9Pnn1xRxGfWGgqa2fkG0esuaJLoPXQ= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= diff --git a/vantagectl/.dockerignore b/vantagectl/.dockerignore new file mode 100644 index 0000000..7de6554 --- /dev/null +++ b/vantagectl/.dockerignore @@ -0,0 +1,5 @@ +# The build context is this directory now, so local build output would +# otherwise be shipped into the builder stage on every build. +dist +.env +*.lic diff --git a/vantagectl/Dockerfile b/vantagectl/Dockerfile index 437c1ad..c17e5cb 100644 --- a/vantagectl/Dockerfile +++ b/vantagectl/Dockerfile @@ -1,21 +1,26 @@ # Build stage # -# Context is the repository root, not vantagectl/, because vantagectl depends on -# the shared module through a replace directive. +# Context is vantagectl/ itself. It used to be the repository root, so that +# shared/ could be copied in beside it; shared is now the private module +# gitea.hostxtra.co.uk/vantage/vantage-shared, fetched like any other +# dependency. The credential for it arrives as a BuildKit secret rather than a +# build arg, which would be baked into this stage's layer history. FROM golang:1.26 AS builder WORKDIR /src -# Manifests first so the dependency layer caches independently of source edits. -COPY shared/go.mod shared/go.sum ./shared/ -COPY vantagectl/go.mod vantagectl/go.sum ./vantagectl/ -RUN cd vantagectl && go mod download +ENV GOPRIVATE=gitea.hostxtra.co.uk/* -COPY shared/ ./shared/ -COPY vantagectl/ ./vantagectl/ +# Manifests first so the dependency layer caches independently of source edits. +COPY go.mod go.sum ./ +RUN --mount=type=secret,id=netrc,target=/root/.netrc \ + go mod download + +COPY . . ARG VERSION=dev -RUN cd vantagectl && CGO_ENABLED=0 GOOS=linux go build \ +RUN --mount=type=secret,id=netrc,target=/root/.netrc \ + CGO_ENABLED=0 GOOS=linux go build \ -ldflags="-s -w -X main.Version=${VERSION}" -o /vantagectl . # Staged so the scratch image below can have a /tmp. It cannot mkdir one diff --git a/vantagectl/go.mod b/vantagectl/go.mod index 3269aa8..52ebce7 100644 --- a/vantagectl/go.mod +++ b/vantagectl/go.mod @@ -2,10 +2,8 @@ module gitea.hostxtra.co.uk/mrhid6/vantage/vantagectl go 1.26 -replace gitea.hostxtra.co.uk/vantage/vantage-shared => ../shared - require ( - gitea.hostxtra.co.uk/vantage/vantage-shared v0.0.0-00010101000000-000000000000 + gitea.hostxtra.co.uk/vantage/vantage-shared v0.1.0 github.com/spf13/cobra v1.10.2 go.mongodb.org/mongo-driver/v2 v2.8.0 golang.org/x/term v0.45.0 diff --git a/vantagectl/go.sum b/vantagectl/go.sum index 258b7c5..880b1a5 100644 --- a/vantagectl/go.sum +++ b/vantagectl/go.sum @@ -1,3 +1,5 @@ +gitea.hostxtra.co.uk/vantage/vantage-shared v0.1.0 h1:H6PCb8JHucrRiqPe9kGOhXUjBD66tKFHCP3qz5TjdZc= +gitea.hostxtra.co.uk/vantage/vantage-shared v0.1.0/go.mod h1:dWjeOFLltQ8sv9Pnn1xRxGfWGgqa2fkG0esuaJLoPXQ= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=