refactor: consume vantage-shared as an external private module
Chart Release / chart (push) Successful in 19s
Server Deploy / deploy (push) Failing after 1m11s

shared/ is extracted to gitea.hostxtra.co.uk/vantage/vantage-shared and
pinned at v0.1.0 by server, agent, admin, sitesvc and vantagectl. The
replace directives and the ./shared entry in go.work are gone.

Every Go build now needs a credential for the private module: CI writes a
netrc per job from REGISTRY_USER + RELEASE_TOKEN and sets GOPRIVATE, and
the four Go Dockerfiles take it as a BuildKit secret rather than a build
arg, which would survive in the builder layer's history. RELEASE_TOKEN
needs read access to the vantage org.

admin, sitesvc and vantagectl now build from their own directory; only
server still needs the repository root, for default_steps/. The rebuild
triggers in server-deploy.yml lose their shared/ patterns, since a
service now moves when its own go.mod pin does.
This commit is contained in:
2026-09-08 07:42:58 +00:00
parent 5326639918
commit ee1f9f3b32
91 changed files with 251 additions and 5751 deletions
+21
View File
@@ -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:
+43 -23
View File
@@ -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
+25 -3
View File
@@ -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"
+74 -36
View File
@@ -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 |
+16 -8
View File
@@ -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
+1 -3
View File
@@ -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
+2
View File
@@ -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=
+3 -5
View File
@@ -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
+6 -4
View File
@@ -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=
-1
View File
@@ -4,7 +4,6 @@ use (
./admin
./agent
./server
./shared
./sitesvc
./vantagectl
)
+3
View File
@@ -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=
+14 -8
View File
@@ -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
+1 -3
View File
@@ -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
+2
View File
@@ -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=
-253
View File
@@ -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) }
-216
View File
@@ -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)
}
}
}
-184
View File
@@ -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
}
-206
View File
@@ -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)
}
-57
View File
@@ -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
}
-61
View File
@@ -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)
}
}
-102
View File
@@ -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"}
}
-103
View File
@@ -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)
}
}
-44
View File
@@ -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
}
-393
View File
@@ -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
}
-439
View File
@@ -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)
}
-197
View File
@@ -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
}
-156
View File
@@ -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)
}
}
-178
View File
@@ -1,178 +0,0 @@
// Command lkctl issues and inspects Vantage licences by hand.
//
// lkctl keypair
// lkctl issue --instance-id=<uuid> --instance-name="Acme" --tier=professional --term=1y
// lkctl inspect <blob-or-file>
//
// 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-or-file>")
}
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)
}
-67
View File
@@ -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
}
-79
View File
@@ -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")
}
}
-25
View File
@@ -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
)
-66
View File
@@ -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=
-19
View File
@@ -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"
}
-685
View File
@@ -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})
}
-101
View File
@@ -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)
}
-99
View File
@@ -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
}
-24
View File
@@ -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
}
-139
View File
@@ -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
}
-129
View File
@@ -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"}
}
-43
View File
@@ -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
}
-132
View File
@@ -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
}
-37
View File
@@ -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),
})
}
-18
View File
@@ -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})
}
-27
View File
@@ -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),
})
}
-73
View File
@@ -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})
}
-26
View File
@@ -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)
}
-121
View File
@@ -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 "&amp;" 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,
}
-241
View File
@@ -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)
}
@@ -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}}
-7
View File
@@ -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}}
-11
View File
@@ -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}}
-13
View File
@@ -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}}
@@ -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}}
@@ -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}}
-8
View File
@@ -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}}
-9
View File
@@ -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}}
-7
View File
@@ -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}}
-8
View File
@@ -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}}
@@ -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}}
@@ -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}}
-7
View File
@@ -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}}
-8
View File
@@ -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}}
-121
View File
@@ -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" -}}
<p style="margin:0 0 16px;color:#9fb3ca;font-size:14px;line-height:1.6;">{{.}}</p>
{{- end -}}
{{- /* lead is the first paragraph: same size, brighter, sets the subject. */ -}}
{{- define "lead" -}}
<p style="margin:0 0 16px;color:#e4ecf6;font-size:15px;line-height:1.6;">{{.}}</p>
{{- 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" -}}
<table role="presentation" cellpadding="0" cellspacing="0" style="margin:4px 0 16px;">
<tr>
<td style="border-radius:4px;background:#5b9be8;">
<a href="{{.url}}" style="display:inline-block;padding:10px 20px;color:#04101f;font-size:14px;font-weight:600;text-decoration:none;">{{.label}}</a>
</td>
</tr>
</table>
<p style="margin:0 0 20px;color:#71879f;font-size:12px;line-height:1.5;word-break:break-all;">
Or paste this into your browser:<br>
<a href="{{.url}}" style="color:#5b9be8;text-decoration:none;">{{.url}}</a>
</p>
{{- end -}}
{{- /* well shows machine output — a licence blob, an install ID. Mirrors
web/'s --well surface, the floor beneath the ground. */ -}}
{{- define "well" -}}
<pre style="margin:0 0 20px;padding:14px;background:#04101f;border:1px solid #1e3855;border-radius:4px;color:#9fb3ca;font-family:ui-monospace,'Cascadia Mono','SF Mono',Menlo,Consolas,monospace;font-size:12px;line-height:1.5;white-space:pre-wrap;word-break:break-all;">{{.}}</pre>
{{- end -}}
{{- /* note is a quoted callout, used for a free-text message we did not
write ourselves. */ -}}
{{- define "note" -}}
<p style="margin:0 0 20px;padding:12px 14px;background:#102842;border:1px solid #1e3855;border-radius:4px;color:#e4ecf6;font-size:13px;line-height:1.6;">{{.}}</p>
{{- end -}}
{{- /* rows takes a list of dict "k" "…" "v" "…". */ -}}
{{- define "rows" -}}
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:4px 0 8px;border-top:1px solid #1e3855;">
{{- range .}}
<tr>
<td style="padding:9px 0;border-bottom:1px solid #172c44;color:#71879f;font-size:13px;width:130px;vertical-align:top;">{{.k}}</td>
<td style="padding:9px 0;border-bottom:1px solid #172c44;color:#e4ecf6;font-size:13px;font-weight:500;">{{.v}}</td>
</tr>
{{- end}}
</table>
{{- 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 -}}
<span style="display:inline-block;margin:0 0 12px;padding:4px 11px;border:1px solid {{$fg}};border-radius:9999px;color:{{$fg}};font-size:11px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;">{{.label}}</span>
{{- end -}}
<!DOCTYPE html>
<html>
<body style="margin:0;padding:0;background:#071628;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#071628;padding:32px 12px;">
<tr>
<td align="center">
<table role="presentation" width="520" cellpadding="0" cellspacing="0" style="max-width:520px;width:100%;background:#0d2138;border:1px solid #1e3855;border-radius:4px;overflow:hidden;font-family:ui-sans-serif,system-ui,-apple-system,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;">
<tr><td style="height:3px;background:#5b9be8;"></td></tr>
<tr>
<td style="padding:26px 28px 8px;">
<span style="font-size:16px;font-weight:700;letter-spacing:-.01em;color:#7fb2f0;">Vantage</span>
</td>
</tr>
<tr>
<td style="padding:10px 28px 26px;">
{{template "pill" .}}
<h1 style="margin:2px 0 14px;font-size:20px;font-weight:700;line-height:1.3;color:#e4ecf6;">{{template "title" .}}</h1>
{{template "body" .}}
</td>
</tr>
<tr>
<td style="padding:14px 28px;border-top:1px solid #1e3855;background:#04101f;">
<p style="margin:0;color:#71879f;font-size:12px;line-height:1.5;">Sent by Vantage · infrastructure control plane</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
-46
View File
@@ -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
-7
View File
@@ -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}}
-8
View File
@@ -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}}
@@ -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}}
@@ -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}}
-7
View File
@@ -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}}
-8
View File
@@ -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}}
-6
View File
@@ -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}}
-7
View File
@@ -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}}
@@ -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}}
@@ -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}}
@@ -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}}
@@ -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}}
-41
View File
@@ -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)
}
-34
View File
@@ -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"`
}
-73
View File
@@ -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
}
-47
View File
@@ -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"`
}
-189
View File
@@ -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
}
-59
View File
@@ -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)
}
-65
View File
@@ -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
}
+1
View File
@@ -0,0 +1 @@
.env
+14 -7
View File
@@ -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
+1 -3
View File
@@ -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
+2
View File
@@ -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=
+5
View File
@@ -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
+14 -9
View File
@@ -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
+1 -3
View File
@@ -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
+2
View File
@@ -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=