refactor: consume vantage-shared as an external private module
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:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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=
|
||||
|
||||
@@ -4,7 +4,6 @@ use (
|
||||
./admin
|
||||
./agent
|
||||
./server
|
||||
./shared
|
||||
./sitesvc
|
||||
./vantagectl
|
||||
)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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) }
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
package mail
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"fmt"
|
||||
htmltmpl "html/template"
|
||||
"io/fs"
|
||||
"strings"
|
||||
texttmpl "text/template"
|
||||
"time"
|
||||
)
|
||||
|
||||
//go:embed templates
|
||||
var files embed.FS
|
||||
|
||||
// A message is rendered from three files: the shared layout, which owns every
|
||||
// colour and every piece of chrome, and the message's own html/txt pair, which
|
||||
// owns only its subject and its content. There is one template set per message
|
||||
// rather than one big set, because each message defines "subject" and "body"
|
||||
// under the same names and they would otherwise collide.
|
||||
type set struct {
|
||||
html *htmltmpl.Template
|
||||
text *texttmpl.Template
|
||||
}
|
||||
|
||||
var sets = map[string]set{}
|
||||
|
||||
func init() {
|
||||
entries, err := fs.Glob(files, "templates/*.html.tmpl")
|
||||
if err != nil {
|
||||
panic("mail: glob templates: " + err.Error())
|
||||
}
|
||||
for _, e := range entries {
|
||||
name := strings.TrimSuffix(strings.TrimPrefix(e, "templates/"), ".html.tmpl")
|
||||
if name == "layout" {
|
||||
continue
|
||||
}
|
||||
h, err := htmltmpl.New("layout.html.tmpl").Funcs(htmltmpl.FuncMap(funcs)).
|
||||
ParseFS(files, "templates/layout.html.tmpl", e)
|
||||
if err != nil {
|
||||
panic("mail: parse " + e + ": " + err.Error())
|
||||
}
|
||||
t, err := texttmpl.New("layout.txt.tmpl").Funcs(texttmpl.FuncMap(funcs)).
|
||||
ParseFS(files, "templates/layout.txt.tmpl", "templates/"+name+".txt.tmpl")
|
||||
if err != nil {
|
||||
panic("mail: parse " + name + ".txt.tmpl: " + err.Error())
|
||||
}
|
||||
sets[name] = set{html: h, text: t}
|
||||
}
|
||||
}
|
||||
|
||||
// render produces the subject and both bodies for one message.
|
||||
//
|
||||
// The subject comes from the text set, not the HTML one: html/template would
|
||||
// escape an ampersand in an instance name into "&" and mail clients show
|
||||
// subjects verbatim.
|
||||
func render(name string, data any) (message, error) {
|
||||
s, ok := sets[name]
|
||||
if !ok {
|
||||
return message{}, fmt.Errorf("no such template")
|
||||
}
|
||||
|
||||
var subject, text, html strings.Builder
|
||||
if err := s.text.ExecuteTemplate(&subject, "subject", data); err != nil {
|
||||
return message{}, err
|
||||
}
|
||||
if err := s.text.Execute(&text, data); err != nil {
|
||||
return message{}, err
|
||||
}
|
||||
if err := s.html.Execute(&html, data); err != nil {
|
||||
return message{}, err
|
||||
}
|
||||
|
||||
return message{
|
||||
Subject: strings.TrimSpace(subject.String()),
|
||||
Text: normaliseText(text.String()),
|
||||
HTML: html.String(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// normaliseText gives the plain part CRLF line endings and collapses the blank
|
||||
// runs that fall out of templating whitespace.
|
||||
func normaliseText(s string) string {
|
||||
s = strings.ReplaceAll(s, "\r\n", "\n")
|
||||
for strings.Contains(s, "\n\n\n") {
|
||||
s = strings.ReplaceAll(s, "\n\n\n", "\n\n")
|
||||
}
|
||||
s = strings.TrimSpace(s) + "\n"
|
||||
return strings.ReplaceAll(s, "\n", "\r\n")
|
||||
}
|
||||
|
||||
// funcs are shared by both template flavours. They exist so that a message
|
||||
// template never formats a date or builds a structure itself — two templates
|
||||
// formatting the same date two ways is exactly the drift this package removes.
|
||||
var funcs = map[string]any{
|
||||
// dict builds a map for the layout's helper templates, which take more
|
||||
// than one argument. Go templates have no literal for this.
|
||||
"dict": func(kv ...any) (map[string]any, error) {
|
||||
if len(kv)%2 != 0 {
|
||||
return nil, fmt.Errorf("dict: odd argument count")
|
||||
}
|
||||
m := make(map[string]any, len(kv)/2)
|
||||
for i := 0; i < len(kv); i += 2 {
|
||||
k, ok := kv[i].(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("dict: key %d is not a string", i)
|
||||
}
|
||||
m[k] = kv[i+1]
|
||||
}
|
||||
return m, nil
|
||||
},
|
||||
"list": func(v ...any) []any { return v },
|
||||
|
||||
// date is the one long-date format used across every Vantage email.
|
||||
"date": func(t time.Time) string { return t.Format("2 January 2006") },
|
||||
// shortDate drops the year, for subject lines where it is obvious.
|
||||
"shortDate": func(t time.Time) string { return t.Format("2 January") },
|
||||
"stamp": func(t time.Time) string { return t.Format("2006-01-02 15:04:05 MST") },
|
||||
"hours": func(d time.Duration) int { return int(d.Hours()) },
|
||||
"upper": strings.ToUpper,
|
||||
}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
.env
|
||||
+14
-7
@@ -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
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
Reference in New Issue
Block a user