Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
165114471f | ||
|
|
de78688093 | ||
|
|
bbf9f72fd3 | ||
|
|
978b665aa6 | ||
|
|
1fe608f531 | ||
|
|
1e1546cb60 | ||
|
|
119d8694d1 | ||
|
|
8d43c689f5 | ||
|
|
05f10ed3c9 | ||
|
|
c0bec3737b | ||
|
|
59d147fe4d | ||
|
|
9e38a01e3d | ||
|
|
20a302f84a | ||
|
|
ba2e263d00 | ||
|
|
a000703199 | ||
|
|
8fcda63742 | ||
|
|
3363ac9dad | ||
|
|
a7e338b171 | ||
|
|
bc79daab48 | ||
|
|
d3d8dba3ff | ||
|
|
6d047e25ab | ||
|
|
ed4c39650c | ||
|
|
7b8fa4a8a0 | ||
|
|
8a02c35ec9 | ||
|
|
487de34a50 | ||
|
|
0424547dd4 |
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Bash|Grep",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "C:/Python314/Scripts/graphify.EXE hook-guard search"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Read|Glob",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "C:/Python314/Scripts/graphify.EXE hook-guard read"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
graphify-out/graph.json merge=graphify
|
||||
@@ -0,0 +1,149 @@
|
||||
name: Chart Release
|
||||
|
||||
on:
|
||||
# Every push that touches the chart is validated. Publishing is separate and
|
||||
# deliberate: a chart version is immutable in the registry once pushed, so
|
||||
# it must come from a tag someone chose, not from whatever landed on main.
|
||||
# No `paths` filter on push, deliberately. A paths filter applies to tag
|
||||
# pushes too, so tagging a commit that happened not to touch the chart
|
||||
# would skip the publish entirely — a release that silently does nothing.
|
||||
# Validation is seconds of helm rendering; running it on every push to main
|
||||
# is cheaper than that failure mode.
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- "chart/v*"
|
||||
pull_request:
|
||||
paths:
|
||||
- "deploy/chart/**"
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
CHART_DIR: deploy/chart/vantage
|
||||
HELM_VERSION: v3.16.3
|
||||
|
||||
jobs:
|
||||
chart:
|
||||
runs-on: ubuntu-docker
|
||||
container: alpine:3.21
|
||||
steps:
|
||||
# git for actions/checkout, curl for both the Helm download and the
|
||||
# registry upload, tar because the Helm tarball is not self-extracting.
|
||||
- name: Setup
|
||||
run: apk add --no-cache bash curl git tar nodejs npm
|
||||
|
||||
- name: Install Helm
|
||||
run: |
|
||||
set -eu
|
||||
curl -fsSL "https://get.helm.sh/helm-${HELM_VERSION}-linux-amd64.tar.gz" \
|
||||
| tar -xz -C /tmp linux-amd64/helm
|
||||
mv /tmp/linux-amd64/helm /usr/local/bin/helm
|
||||
helm version --short
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Lint
|
||||
run: helm lint "$CHART_DIR"
|
||||
|
||||
# Rendering is the real test. `helm lint` accepts a chart whose
|
||||
# templates fail to execute, and every guard in this chart is a
|
||||
# template `fail` that only fires during rendering.
|
||||
- name: Render default values
|
||||
run: helm template test "$CHART_DIR" > /dev/null
|
||||
|
||||
- name: Render a multi-replica install
|
||||
run: |
|
||||
helm template test "$CHART_DIR" \
|
||||
--set server.replicaCount=3 \
|
||||
--set web.replicaCount=3 > /dev/null
|
||||
|
||||
- name: Render against external Redis and MongoDB
|
||||
run: |
|
||||
helm template test "$CHART_DIR" \
|
||||
--set redis.enabled=false \
|
||||
--set redis.addr=redis.example.com:6379 \
|
||||
--set mongo.enabled=false \
|
||||
--set server.env.mongoUri=mongodb://mongo.example.com:27017/vantage > /dev/null
|
||||
|
||||
# The guards are load-bearing, so their absence is a regression the
|
||||
# same way a broken render is. Each of these must fail.
|
||||
- name: Check the guards still refuse bad values
|
||||
run: |
|
||||
set -eu
|
||||
|
||||
refuses() {
|
||||
desc="$1"; shift
|
||||
if helm template test "$CHART_DIR" "$@" > /dev/null 2>&1; then
|
||||
echo "GUARD MISSING: $desc was accepted"
|
||||
exit 1
|
||||
fi
|
||||
echo "ok: refused $desc"
|
||||
}
|
||||
|
||||
refuses "mongo disabled with an in-chart URI" \
|
||||
--set mongo.enabled=false
|
||||
refuses "redis disabled with no external address" \
|
||||
--set redis.enabled=false
|
||||
refuses "multiple replicas on a ReadWriteOnce volume" \
|
||||
--set server.replicaCount=2 --set server.persistence.enabled=true
|
||||
|
||||
- name: Read the chart version
|
||||
id: chart
|
||||
run: |
|
||||
set -eu
|
||||
VERSION="$(grep '^version:' "$CHART_DIR/Chart.yaml" | awk '{print $2}')"
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "chart version is $VERSION"
|
||||
|
||||
# Chart.yaml is the source of truth for the version; the tag only
|
||||
# says "publish this one". A mismatch is a mistake worth stopping
|
||||
# for — the alternative is stamping the tag over Chart.yaml, which
|
||||
# leaves the repository disagreeing with what was published.
|
||||
- name: Check the tag matches Chart.yaml
|
||||
if: startsWith(github.ref, 'refs/tags/chart/v')
|
||||
run: |
|
||||
set -eu
|
||||
TAG_VERSION="${GITHUB_REF_NAME#chart/v}"
|
||||
CHART_VERSION="${{ steps.chart.outputs.version }}"
|
||||
if [ "$TAG_VERSION" != "$CHART_VERSION" ]; then
|
||||
echo "tag chart/v$TAG_VERSION does not match Chart.yaml version $CHART_VERSION"
|
||||
echo "bump version: in $CHART_DIR/Chart.yaml, or retag."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Package
|
||||
run: |
|
||||
set -eu
|
||||
mkdir -p dist
|
||||
helm package "$CHART_DIR" --destination dist
|
||||
ls -l dist
|
||||
|
||||
- name: Publish to the Gitea chart registry
|
||||
if: startsWith(github.ref, 'refs/tags/chart/v')
|
||||
env:
|
||||
# github.server_url is this Gitea instance, so the registry
|
||||
# host needs no variable of its own and cannot drift from it.
|
||||
REGISTRY: ${{ github.server_url }}/api/packages/${{ github.repository_owner }}/helm/api/charts
|
||||
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
|
||||
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
CHART_VERSION: ${{ steps.chart.outputs.version }}
|
||||
run: |
|
||||
set -eu
|
||||
PKG="dist/vantage-${CHART_VERSION}.tgz"
|
||||
test -f "$PKG"
|
||||
|
||||
# --fail-with-body so an HTTP error is a failed step with the
|
||||
# server's explanation, rather than a green run that published
|
||||
# nothing. A repeated version is rejected by the registry;
|
||||
# that is the intended behaviour, not something to retry past.
|
||||
curl --fail-with-body -sS \
|
||||
--user "${REGISTRY_USER}:${REGISTRY_PASSWORD}" \
|
||||
-X POST \
|
||||
--upload-file "$PKG" \
|
||||
"$REGISTRY"
|
||||
|
||||
echo "published vantage ${CHART_VERSION}"
|
||||
echo " helm repo add vantage ${{ github.server_url }}/api/packages/${{ github.repository_owner }}/helm"
|
||||
echo " helm install vantage vantage/vantage --version ${CHART_VERSION}"
|
||||
@@ -150,6 +150,7 @@ jobs:
|
||||
--build-arg NEXT_PUBLIC_ADMIN_ENV="${{ vars.ADMIN_ENV }}" \
|
||||
--build-arg NEXT_PUBLIC_PADDLE_CLIENT_TOKEN="${{ vars.PADDLE_CLIENT_TOKEN }}" \
|
||||
--build-arg NEXT_PUBLIC_PADDLE_ENV="${{ vars.PADDLE_ENV }}" \
|
||||
--build-arg NEXT_PUBLIC_SITE_URL="${{ vars.SITE_URL }}" \
|
||||
-t "$IMAGE" \
|
||||
-f adminsite/Dockerfile adminsite/
|
||||
docker push "$IMAGE"
|
||||
|
||||
+4
-1
@@ -3,6 +3,7 @@ dist
|
||||
build
|
||||
.env
|
||||
.env.bck
|
||||
.env.live
|
||||
docs/*
|
||||
!docs/superpowers/
|
||||
.superpowers
|
||||
@@ -11,4 +12,6 @@ installer/*.msi
|
||||
installer/nssm.zip
|
||||
installer/checksums-msi.txt
|
||||
.next
|
||||
*.tsbuildinfo
|
||||
*.tsbuildinfo
|
||||
graphify-out
|
||||
docker-compose.live.yml
|
||||
@@ -77,6 +77,16 @@ func Run(ctx context.Context) error {
|
||||
}
|
||||
}
|
||||
|
||||
if lic.ExpiresAt.Add(reapAfter).Before(now) {
|
||||
if _, err := db.Admin("admin_instances").DeleteOne(ctx, bson.M{"instance_id": inst.InstanceID}); err != nil {
|
||||
log.Printf("lifecycle: delete instance %s: %v", inst.InstanceID, err)
|
||||
}
|
||||
|
||||
if _, err := db.Admin("licenses").DeleteMany(ctx, bson.M{"instance_id": inst.InstanceID}); err != nil {
|
||||
log.Printf("lifecycle: delete licenses for instance %s: %v", inst.InstanceID, err)
|
||||
}
|
||||
}
|
||||
|
||||
due := dueNotice(now, lic.ExpiresAt, inst.NoticesSent)
|
||||
if due == "" {
|
||||
continue
|
||||
|
||||
@@ -26,6 +26,11 @@ ENV NEXT_PUBLIC_PADDLE_CLIENT_TOKEN=$NEXT_PUBLIC_PADDLE_CLIENT_TOKEN
|
||||
ARG NEXT_PUBLIC_PADDLE_ENV=sandbox
|
||||
ENV NEXT_PUBLIC_PADDLE_ENV=$NEXT_PUBLIC_PADDLE_ENV
|
||||
|
||||
# Marketing site origin. Signup lives there (/start), not here; empty renders no
|
||||
# link at all rather than one that 404s.
|
||||
ARG NEXT_PUBLIC_SITE_URL=
|
||||
ENV NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL
|
||||
|
||||
RUN npm run build
|
||||
|
||||
FROM node:26-alpine AS runner
|
||||
|
||||
@@ -10,7 +10,7 @@ export const metadata: Metadata = {
|
||||
|
||||
/*
|
||||
* The masthead deliberately does NOT live here. It belongs to the authenticated
|
||||
* layouts, so /login, /signup, /verify and /accept-invite stop rendering a bar
|
||||
* layouts, so /login, /verify and /accept-invite stop rendering a bar
|
||||
* whose navigation and account menu they cannot use.
|
||||
*/
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { API_BASE, ApiError, NotConnected, api } from "@/lib/api";
|
||||
import { NotConnectedPanel } from "@/components/NotConnected";
|
||||
import { Button } from "@/components/Button";
|
||||
import { Field } from "@/components/Field";
|
||||
|
||||
const SITE_URL = (process.env.NEXT_PUBLIC_SITE_URL ?? "").replace(/\/$/, "");
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const [email, setEmail] = useState("");
|
||||
@@ -44,46 +45,75 @@ export default function LoginPage() {
|
||||
|
||||
return (
|
||||
<Main>
|
||||
<h1 className="text-3xl">Sign in</h1>
|
||||
<form onSubmit={submit} className="mt-6 grid gap-4">
|
||||
<Field
|
||||
label="Email"
|
||||
type="email"
|
||||
autoComplete="username"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
<Field
|
||||
label="Password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
error={error ?? undefined}
|
||||
/>
|
||||
<label className="flex items-center gap-2 text-[0.82rem] text-ink-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={staff}
|
||||
onChange={(e) => setStaff(e.target.checked)}
|
||||
{/* The masthead's lockup, unlinked: there is nowhere to go yet. */}
|
||||
<div className="mb-7 flex flex-col items-center gap-2 text-center">
|
||||
<span className="flex items-baseline gap-2 text-[1.5rem] font-extrabold tracking-[-0.02em]">
|
||||
Vantage
|
||||
<span className="font-mono text-[0.78rem] font-normal uppercase tracking-[0.14em] text-ink-3">
|
||||
HQ
|
||||
</span>
|
||||
</span>
|
||||
<h1 className="text-[1.16rem]">Sign in</h1>
|
||||
<p className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">
|
||||
Licences · instances · billing
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded border border-rule bg-panel p-6 shadow-[var(--shadow)]">
|
||||
<form onSubmit={submit} className="grid gap-4">
|
||||
<Field
|
||||
label="Email"
|
||||
type="email"
|
||||
autoComplete="username"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
I work at Vantage
|
||||
</label>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button type="submit" disabled={busy}>
|
||||
<Field
|
||||
label="Password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
error={error ?? undefined}
|
||||
/>
|
||||
<label className="flex items-center gap-2 text-[0.82rem] text-ink-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={staff}
|
||||
onChange={(e) => setStaff(e.target.checked)}
|
||||
className="accent-[var(--accent)]"
|
||||
/>
|
||||
I work at Vantage
|
||||
</label>
|
||||
<Button type="submit" disabled={busy} className="w-full justify-center">
|
||||
{busy ? "Signing in…" : "Sign in"}
|
||||
</Button>
|
||||
<Link href="/signup" className="text-[0.82rem] text-accent underline">
|
||||
Create an account for a self-hosted licence
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
</form>
|
||||
|
||||
{SITE_URL && (
|
||||
<>
|
||||
<div className="my-5 h-px bg-rule-soft" />
|
||||
|
||||
{/* Signup lives on the marketing site's /start, not here. */}
|
||||
<p className="text-center text-[0.82rem] text-ink-3">
|
||||
No account?{" "}
|
||||
<a href={`${SITE_URL}/start`} className="text-accent underline">
|
||||
Create one
|
||||
</a>
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Main>
|
||||
);
|
||||
}
|
||||
|
||||
function Main({ children }: { children: React.ReactNode }) {
|
||||
return <main className="mx-auto max-w-rail px-5 py-12">{children}</main>;
|
||||
return (
|
||||
<main className="mx-auto flex min-h-screen w-full max-w-[26rem] flex-col justify-center px-5 py-12">
|
||||
{children}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { ApiError, NotConnected, api } from "@/lib/api";
|
||||
import { Button } from "@/components/Button";
|
||||
import { Field } from "@/components/Field";
|
||||
|
||||
export default function SignupPage() {
|
||||
const [form, setForm] = useState({ name: "", email: "", password: "", website: "" });
|
||||
const [state, setState] = useState<"idle" | "busy" | "sent">("idle");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setState("busy");
|
||||
setError(null);
|
||||
try {
|
||||
await api.signup(form);
|
||||
setState("sent");
|
||||
} catch (err) {
|
||||
setState("idle");
|
||||
setError(
|
||||
err instanceof NotConnected
|
||||
? "The licensing service is not reachable from this page."
|
||||
: err instanceof ApiError
|
||||
? err.message
|
||||
: "Could not create the account. Try again.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-rail px-5 py-12">
|
||||
{state === "sent" ? (
|
||||
<div className="grid max-w-xl gap-3">
|
||||
<h1 className="text-3xl">Check your email</h1>
|
||||
<p className="text-ink-2">
|
||||
We sent a link to {form.email}. Open it to finish setting up your account —
|
||||
it expires in 24 hours. Nothing is created until you do.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<h1 className="text-3xl">Create an account</h1>
|
||||
<p className="mt-2 max-w-xl text-ink-2">
|
||||
For self-hosted licences. If you run on our cloud, sign in with the same
|
||||
details you use for your Vantage instance.
|
||||
</p>
|
||||
<form onSubmit={submit} className="mt-6 grid gap-4">
|
||||
<Field
|
||||
label="Organisation"
|
||||
required
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
/>
|
||||
<Field
|
||||
label="Email"
|
||||
type="email"
|
||||
required
|
||||
value={form.email}
|
||||
onChange={(e) => setForm({ ...form, email: e.target.value })}
|
||||
/>
|
||||
<Field
|
||||
label="Password"
|
||||
type="password"
|
||||
required
|
||||
minLength={12}
|
||||
hint="At least 12 characters."
|
||||
value={form.password}
|
||||
onChange={(e) => setForm({ ...form, password: e.target.value })}
|
||||
error={error ?? undefined}
|
||||
/>
|
||||
{/* Honeypot: off-screen, unlabelled for humans, irresistible to bots. */}
|
||||
<input
|
||||
type="text"
|
||||
name="website"
|
||||
tabIndex={-1}
|
||||
autoComplete="off"
|
||||
aria-hidden="true"
|
||||
value={form.website}
|
||||
onChange={(e) => setForm({ ...form, website: e.target.value })}
|
||||
className="absolute left-[-9999px] h-0 w-0"
|
||||
/>
|
||||
<Button type="submit" disabled={state === "busy"}>
|
||||
{state === "busy" ? "Creating…" : "Create account"}
|
||||
</Button>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -296,8 +296,6 @@ export const api = {
|
||||
staffLogin: (email: string, password: string) =>
|
||||
post<Session>("/auth/staff/login", { email, password }),
|
||||
logout: () => post<{ ok: boolean }>("/auth/logout"),
|
||||
signup: (payload: { name: string; email: string; password: string; website?: string }) =>
|
||||
post<{ pending: boolean }>("/auth/signup", payload),
|
||||
verify: (token: string) =>
|
||||
req<{ verified: boolean; needs_password?: boolean }>(
|
||||
`/auth/verify?token=${encodeURIComponent(token)}`,
|
||||
|
||||
@@ -151,3 +151,7 @@ func (c *Client) ReportChecks(serverID, agentToken string, results []pb.CheckRes
|
||||
func (c *Client) CommandStream(ctx context.Context) (pb.Vantage_CommandStreamClient, error) {
|
||||
return c.client.CommandStream(ctx)
|
||||
}
|
||||
|
||||
func (c *Client) ProxyStream(ctx context.Context) (pb.Vantage_ProxyStreamClient, error) {
|
||||
return c.client.ProxyStream(ctx)
|
||||
}
|
||||
|
||||
@@ -131,6 +131,32 @@ 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"`
|
||||
@@ -139,6 +165,7 @@ type ServerCommand struct {
|
||||
ApplyUpdates *ApplyUpdatesCmd `json:"apply_updates,omitempty"`
|
||||
RunStep *RunStepCmd `json:"run_step,omitempty"`
|
||||
CleanupWorkspace *CleanupWorkspaceCmd `json:"cleanup_workspace,omitempty"`
|
||||
OpenProxy *OpenProxyCmd `json:"open_proxy,omitempty"`
|
||||
}
|
||||
|
||||
|
||||
@@ -254,6 +281,51 @@ func (s *keyManagerCommandStreamServer) Recv() (*AgentMessage, error) {
|
||||
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
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -263,6 +335,7 @@ type VantageClient interface {
|
||||
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 UnimplementedVantageServer struct{}
|
||||
@@ -349,3 +422,12 @@ func (c *keyManagerClient) CommandStream(ctx context.Context, opts ...grpc.CallO
|
||||
}
|
||||
return &vantageCommandStreamClient{stream}, nil
|
||||
}
|
||||
|
||||
func (c *keyManagerClient) ProxyStream(ctx context.Context, opts ...grpc.CallOption) (Vantage_ProxyStreamClient, error) {
|
||||
desc := &grpc.StreamDesc{StreamName: "ProxyStream", ServerStreams: true, ClientStreams: true}
|
||||
stream, err := c.cc.NewStream(ctx, desc, "/vantage.v1.Vantage/ProxyStream", opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &vantageProxyStreamClient{stream}, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
// Package agentproxy relays a single TCP connection between a local service and
|
||||
// the control plane, so a control plane that cannot route to this host's network
|
||||
// can still open a console session.
|
||||
//
|
||||
// The dial host is hardcoded to loopback. The control plane supplies only a
|
||||
// port, and nothing in this package can be made to dial anywhere else.
|
||||
package agentproxy
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/grpc/pb"
|
||||
)
|
||||
|
||||
const (
|
||||
loopbackHost = "127.0.0.1"
|
||||
chunkSize = 32 * 1024
|
||||
dialTimeout = 10 * time.Second
|
||||
)
|
||||
|
||||
// Stream is the agent's half of a ProxyStream.
|
||||
type Stream interface {
|
||||
Send(*pb.ProxyClientMsg) error
|
||||
Recv() (*pb.ProxyServerMsg, error)
|
||||
CloseSend() error
|
||||
}
|
||||
|
||||
// Open dials the local port, announces itself on the stream, and relays until
|
||||
// either side ends. A refused dial is reported as an explicit close so the
|
||||
// operator sees a reason rather than a hang.
|
||||
func Open(stream Stream, serverID, agentToken, proxyID string, port uint32) error {
|
||||
conn, dialErr := net.DialTimeout("tcp",
|
||||
net.JoinHostPort(loopbackHost, strconv.Itoa(int(port))), dialTimeout)
|
||||
|
||||
if err := stream.Send(&pb.ProxyClientMsg{Open: &pb.ProxyOpen{
|
||||
ServerId: serverID,
|
||||
AgentToken: agentToken,
|
||||
ProxyId: proxyID,
|
||||
}}); err != nil {
|
||||
if conn != nil {
|
||||
_ = conn.Close()
|
||||
}
|
||||
return fmt.Errorf("send open: %w", err)
|
||||
}
|
||||
|
||||
if dialErr != nil {
|
||||
_ = stream.Send(&pb.ProxyClientMsg{Close: &pb.ProxyClose{
|
||||
Reason: "dial_refused: " + dialErr.Error(),
|
||||
}})
|
||||
_ = stream.CloseSend()
|
||||
return fmt.Errorf("dial 127.0.0.1:%d: %w", port, dialErr)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
return relay(conn, stream)
|
||||
}
|
||||
|
||||
func relay(conn net.Conn, stream Stream) error {
|
||||
errCh := make(chan error, 2)
|
||||
|
||||
// local service -> control plane
|
||||
go func() {
|
||||
buf := make([]byte, chunkSize)
|
||||
for {
|
||||
n, err := conn.Read(buf)
|
||||
if n > 0 {
|
||||
chunk := make([]byte, n)
|
||||
copy(chunk, buf[:n])
|
||||
if sendErr := stream.Send(&pb.ProxyClientMsg{Data: chunk}); sendErr != nil {
|
||||
errCh <- sendErr
|
||||
return
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
errCh <- err
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// control plane -> local service
|
||||
go func() {
|
||||
for {
|
||||
msg, err := stream.Recv()
|
||||
if err != nil {
|
||||
errCh <- err
|
||||
return
|
||||
}
|
||||
if msg.Close != nil {
|
||||
errCh <- fmt.Errorf("server closed relay: %s", msg.Close.Reason)
|
||||
return
|
||||
}
|
||||
if len(msg.Data) > 0 {
|
||||
if _, err := conn.Write(msg.Data); err != nil {
|
||||
errCh <- err
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
err := <-errCh
|
||||
_ = conn.Close()
|
||||
_ = stream.CloseSend()
|
||||
if errors.Is(err, io.EOF) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import (
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/inventory"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/keys"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/monitors"
|
||||
agentproxy "gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/proxy"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/updates"
|
||||
)
|
||||
|
||||
@@ -197,6 +198,9 @@ func connectAndHandleStream(ctx context.Context, cfg *config.Config) error {
|
||||
if cmd.CleanupWorkspace != nil {
|
||||
go handleCleanupWorkspace(cmd)
|
||||
}
|
||||
if cmd.OpenProxy != nil {
|
||||
go handleOpenProxy(ctx, cfg, cmd.OpenProxy)
|
||||
}
|
||||
if cmd.RunStep != nil {
|
||||
go func(rc *pb.RunStepCmd, cid string) {
|
||||
emit := func(seq uint64, data []byte) {
|
||||
@@ -326,6 +330,29 @@ func handleCleanupWorkspace(cmd *pb.ServerCommand) {
|
||||
log.Printf("removed run workspace %s (cmd=%s)", dir, cmd.CommandId)
|
||||
}
|
||||
|
||||
// handleOpenProxy relays one console connection. It uses its own gRPC
|
||||
// connection so console traffic never shares a stream with commands, key sync
|
||||
// or workflow output.
|
||||
func handleOpenProxy(ctx context.Context, cfg *config.Config, cmd *pb.OpenProxyCmd) {
|
||||
client, err := grpcclient.New(cfg.ServerURL, cfg.TLS)
|
||||
if err != nil {
|
||||
log.Printf("proxy %s: dial control plane: %v", cmd.ProxyId, err)
|
||||
return
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
stream, err := client.ProxyStream(ctx)
|
||||
if err != nil {
|
||||
log.Printf("proxy %s: open stream: %v", cmd.ProxyId, err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("proxy %s: relaying 127.0.0.1:%d", cmd.ProxyId, cmd.Port)
|
||||
if err := agentproxy.Open(stream, cfg.ServerID, cfg.AgentToken, cmd.ProxyId, cmd.Port); err != nil {
|
||||
log.Printf("proxy %s: %v", cmd.ProxyId, err)
|
||||
}
|
||||
}
|
||||
|
||||
func handleDeleteKey(cmd *pb.ServerCommand) {
|
||||
label := cmd.DeleteKey.Label
|
||||
keyPath := fmt.Sprintf("/root/.ssh/vantage_%s", strings.ReplaceAll(label, " ", "_"))
|
||||
|
||||
@@ -120,7 +120,7 @@ Upload a public key, assign it per server, revoke softly. The agent diffs desire
|
||||
|
||||
### Workflows
|
||||
|
||||
A library of reusable **steps** (bash or PowerShell scripts with declared inputs, outputs, and secret refs) composed into **workflows** targeting a set of servers. Running one snapshots the resolved steps into a `WorkflowRun`, then dispatches `RunStepCmd` over the agent command stream. Step stdout/stderr streams back as `StepOutputChunk` and is written to a log file on disk; the UI streams it live. Steps support `on_failure: stop|continue|retry`, per-run env passed between steps via `output_env`, and a per-run workspace directory the agent cleans up at the end.
|
||||
A library of reusable **steps** (bash or PowerShell scripts with declared inputs, outputs, and secret refs) composed into **workflows** targeting a set of servers. Running one snapshots the resolved steps into a `WorkflowRun`, then dispatches `RunStepCmd` over the agent command stream. Step stdout/stderr streams back as `StepOutputChunk` and is written to MongoDB (`workflow_log_lines`, one document per line); the UI streams it live. Steps support `on_failure: stop|continue|retry`, per-run env passed between steps via `output_env`, and a per-run workspace directory the agent cleans up at the end.
|
||||
|
||||
Default steps are seeded per org at boot (`SeedDefaultSteps`) from `VANTAGE_DEFAULT_STEPS_DIR`, which `server/Dockerfile` bakes to `/opt/default-steps` from the repo's `default_steps/`. Deliberately **not** under `/data` — that is a bind mount, so the library would be editable from the host. Adding a step there means committing a file and rebuilding, which is why `default_steps/` is in the `server` rebuild trigger. **Steps with `source: "default"` are read-only**: `UpdateStep`/`DeleteStep` refuse with `ErrDefaultStep` (409), because seeding rewrites them on every boot, so an edit would silently revert and a delete would come back. `web/` mirrors this — the step modal opens read-only, Delete is hidden, and the designer's per-step script override is `readOnly` for a default library step — but as elsewhere, the API is the boundary and the UI is the courtesy. Seeding writes straight to the collection rather than through `UpdateStep`, so the guard does not lock out the seeder. Logs are swept by retention (`workflow_log_retention_days`; nil = 30 days, 0 = forever).
|
||||
|
||||
@@ -138,7 +138,72 @@ Key/value pairs grouped by name, encrypted at rest with AES-256-GCM. Consumed tw
|
||||
|
||||
### Browser console
|
||||
|
||||
`POST /api/console/connect` mints a one-time session token; `GET /api/console/tunnel` upgrades to a WebSocket and proxies to **guacd** (Apache Guacamole daemon) using `github.com/wwt/guac`. SSH connections authenticate with a stored private key; RDP/VNC credentials are encrypted, single-use, and consumed when the tunnel opens.
|
||||
`POST /api/console/connect` mints a one-time session token; `GET /api/console/tunnel`
|
||||
upgrades to a WebSocket and proxies to **guacd** using `github.com/wwt/guac`.
|
||||
|
||||
guacd never dials the managed server. The server binds a single-use ephemeral
|
||||
listener, pushes `OpenProxyCmd` down the agent's command stream, and the agent
|
||||
opens a `ProxyStream` and relays the connection from its own **`127.0.0.1`** —
|
||||
the host is hardcoded agent-side, so the control plane can name only a port.
|
||||
This is what makes the console work on Vantage Cloud, where the customer's
|
||||
server is behind NAT on a private address. It also means the console now
|
||||
**requires a live agent** on every deployment: `consoleConnect` answers 409
|
||||
`agent_offline` rather than hanging.
|
||||
|
||||
SSH connections authenticate with a stored private key; RDP/VNC credentials are
|
||||
encrypted, single-use, and consumed when the tunnel opens. None of them reach
|
||||
the agent — the session is negotiated end-to-end between guacd and the target
|
||||
daemon, so the agent relays bytes it cannot read.
|
||||
|
||||
### Running more than one server replica
|
||||
|
||||
An agent's `CommandStream` terminates on exactly **one** server process. Every
|
||||
piece of coordination below exists because of that single fact: with several
|
||||
replicas, the process asked to do something to an agent is almost never the
|
||||
process holding that agent's stream.
|
||||
|
||||
`server/internal/bus` is the Redis message bus that closes the gap. It adds no
|
||||
infrastructure — Redis was already required for sessions — and it is **not
|
||||
optional on a single-replica deployment**: dispatch takes the bus path always,
|
||||
so the code running in production is the code running everywhere, rather than a
|
||||
rare cross-pod branch that only fails under load.
|
||||
|
||||
| Concern | How it crosses replicas |
|
||||
| ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Which pod owns an agent | `vantage:agent:<server_id>` holds the owner's node ID with a 30s TTL, renewed every 10s. `Dispatcher.IsConnected` is an `EXISTS` on it |
|
||||
| Sending a command | published to `vantage:cmd:<server_id>`; the owner pod acks on `vantage:ack:<command_id>`. **Request/ack, not a queue** — a command whose owner died must fail loudly (503) rather than queue |
|
||||
| Step results | the owner pod publishes to `vantage:res:<command_id>`; the pod driving the run subscribes **before** dispatching, or a fast agent answers into a channel nobody has joined |
|
||||
| Step output | never crosses. The dispatch envelope carries the secret mask list, so the owner pod masks and writes lines itself — unmasked bytes stay off the bus |
|
||||
| Console relay | the envelope asks the owner pod to bind the listener, and the ack returns **that pod's** address for guacd. The relay's failure reason comes back on `vantage:proxyend:<proxy_id>` |
|
||||
| Background jobs | `bus.RunAsLeader` — one Redis lock named `housekeeping` |
|
||||
|
||||
**Workflow logs are in MongoDB** (`workflow_log_lines`, one document per line,
|
||||
with a `workflow_log_seq` counter document per run/server). Two pods write the
|
||||
same log concurrently — the run's pod emits markers, the agent's pod emits
|
||||
output — so ordering only means anything if both draw sequence numbers from the
|
||||
same counter. `StepRun.log_offset` is that sequence number now, not a byte
|
||||
offset. Writes are batched (128 lines or 250ms) and capped: 8 KB per line,
|
||||
200k lines per server-run, after which one final `[vantage] log truncated`
|
||||
marker is written and the rest is dropped. Without that cap a `yes` in a step
|
||||
is a database incident. **Nothing writes to `/data` any more**, which is why
|
||||
`server.persistence` now defaults to off and `VANTAGE_WORKFLOW_LOG_DIR` is gone.
|
||||
|
||||
**The leader lock is not an optimisation.** N replicas each running the monitor
|
||||
scheduler means each check fires N times, each incident notification reaches the
|
||||
customer N times, and each hourly rollup is written N times; N reapers race to
|
||||
purge the same Free instance. `monitorsched`, `StartReaper`, `StartLogSweeper`,
|
||||
`StartAuditSweeper` and the offline sweep therefore all run inside one
|
||||
`RunAsLeader("housekeeping", …)` — one role, one lock. Each takes a context
|
||||
cancelled the instant leadership is lost, and must return when it is.
|
||||
|
||||
Redis rather than a Kubernetes `Lease` so Compose takes the identical path: one
|
||||
implementation to reason about, not two.
|
||||
|
||||
Two deployment requirements come with `replicaCount > 1`: every replica must
|
||||
share **one** Redis (a per-pod Redis partitions the bus and every agent looks
|
||||
offline to two thirds of the fleet), and `POD_IP` must be set — the chart does
|
||||
it from the downward API — because `PROXY_ADVERTISE_HOST` names the Service, and
|
||||
a Service cannot address the one pod holding a console listener.
|
||||
|
||||
### Inventory and OS updates
|
||||
|
||||
@@ -315,6 +380,7 @@ Key-state polling stays on the 30s `SyncKeys` interval. Full message definitions
|
||||
Unauthenticated:
|
||||
|
||||
```
|
||||
GET /healthz /readyz # liveness / readiness probes
|
||||
GET /install /install.ps1 # dynamic agent install scripts
|
||||
GET /update /update.ps1
|
||||
GET /auth/bootstrap-status
|
||||
@@ -421,7 +487,7 @@ Paddle is merchant of record; `admin/internal/paddle` is a thin REST client (no
|
||||
|
||||
## MongoDB Collections
|
||||
|
||||
`servers` · `keys` · `assignments` · `orgs` · `users` · `org_oidc` · `settings` · `secrets` · `workflows` · `workflow_steps` · `workflow_runs` · `monitors` · `incidents` · `monitor_rollups` · `notification_channels` · `console_sessions` · `audit_logs` · `migrations`
|
||||
`servers` · `keys` · `assignments` · `orgs` · `users` · `org_oidc` · `settings` · `secrets` · `workflows` · `workflow_steps` · `workflow_runs` · `workflow_log_lines` · `workflow_log_seq` · `monitors` · `incidents` · `monitor_rollups` · `notification_channels` · `console_sessions` · `audit_logs` · `migrations`
|
||||
|
||||
Every document except `migrations` carries `org_id`. Struct definitions are the source of truth — see `server/internal/models/`.
|
||||
|
||||
@@ -433,6 +499,7 @@ Notes that are not obvious from the structs:
|
||||
- `assignments.revoked_at: null` means active. Revocation is soft, preserving audit history.
|
||||
- `workflow_runs.steps_snapshot` freezes the resolved steps so editing the library never rewrites history.
|
||||
- `console_sessions.token_consumed_at` is set atomically to enforce one-time use.
|
||||
- `workflow_log_lines` is keyed `(run_id, server_id, seq)` — the index is not an optimisation, every read is a range scan over it. `workflow_log_seq` holds one counter document per `run_id/server_id`, which is what lets two pods interleave into one ordered log. Neither carries `instance_id`: they are reached only through a run, and a run is already scoped.
|
||||
- `users.auth_source` is `local`, `oidc` or `hq`. An `hq` user was projected from a Vantage HQ account and carries `hq_user_id`; HQ owns its role, password and existence.
|
||||
|
||||
Admin's own database is separate and holds `accounts` · `admin_instances` · `licenses` · `subscriptions` · `plans` · `catalogue` · `entitlements` · `paddle_events` · `staff_users` · `customer_users` · `instance_members` · `admin_audit`. `paddle_events` is the webhook idempotency log, unique on `event_id`: an event is claimed there before processing, and a duplicate of a handled event is a 200 no-op. `instance_members` is unique on `(instance_id, customer_user_id)` — one person holds at most one user in one instance, which makes a grant idempotent-by-refusal rather than silently doubling a projection. It is an _index_ of the control-plane rows, not the authority (see "Grants project, they do not federate"). Admin has no migrations collection; `models.Backfill` runs on every boot and is idempotent by filtering on the absence of what it writes.
|
||||
@@ -514,11 +581,17 @@ Windows: MSI built by CI (WiX), or `installer/setup.ps1` registering the agent a
|
||||
| `GRPC_HOST` | **yes** | `host:port` agents dial. Boot fails without it — there is no safe default; falling back to the web host would hand agents a port that does not speak gRPC. |
|
||||
| `MONGO_URI` | no | default `mongodb://localhost:27017` |
|
||||
| `MONGO_DB` | no | default `vantage` |
|
||||
| `REDIS_USERNAME` | no | Redis 6+ ACL user. Leave empty for a legacy `requirepass` instance — go-redis then sends AUTH with one argument instead of two |
|
||||
| `REDIS_PASSWORD` | no | empty for an unauthenticated Redis |
|
||||
| `REDIS_ADDR` | no | default `localhost:6379` |
|
||||
| `KEY_ENCRYPTION_KEY` | yes in practice | 64-char hex (32 bytes) for AES-256-GCM. Required for private keys, secrets, OIDC secrets, RDP credentials. |
|
||||
| `GUACD_ADDR` | no | default `guacd:4822` |
|
||||
| `PROXY_ADVERTISE_HOST` | no | default `server`; the hostname guacd resolves the control plane by, handed to guacd as the relay's address. Wrong here and every console session fails at connect |
|
||||
| `PROXY_LISTEN_HOST` | no | default `0.0.0.0`; the interface the ephemeral relay listener binds |
|
||||
| `APP_ROOT_LABEL` | no | default `vantage`; wrong value disables the host/session org guard |
|
||||
| `VANTAGE_WORKFLOW_LOG_DIR` | no | where run logs are written |
|
||||
| `POD_IP` | no | this pod's own address, set by the Helm chart from the downward API. **Takes precedence over `PROXY_ADVERTISE_HOST`** — a console relay listener belongs to one replica, and a Service address names all of them |
|
||||
| `VANTAGE_MIGRATE_ONLY` | no | run schema setup (migrations, index builders, default-step seeding) and exit without serving. `GRPC_HOST` is not required in this mode. Set by the Helm chart's pre-upgrade Job |
|
||||
| `VANTAGE_SKIP_MIGRATIONS` | no | serve without running schema setup, on the assumption a Job already did. Set by the chart's Deployment whenever `server.migrationJob.enabled`. Unset under Compose, where one process still migrates and then serves |
|
||||
| `FREE_INSTANCE_REAP_AFTER` | no | duration past a Free licence's expiry before the instance and all its data are deleted. **Empty disables the reaper, and empty is the default.** Set to `336h` in `docker-compose.site.yml` only — a self-hosted deployment must never reap. Must match admin's value, which only names the date in warning emails |
|
||||
|
||||
**sitesvc** (`deploy/docker-compose.site.yml` only):
|
||||
@@ -537,7 +610,7 @@ Windows: MSI built by CI (WiX), or `installer/setup.ps1` registering the agent a
|
||||
|
||||
`docsite` is the odd one: a **static** build served by `nginx:alpine-slim`, not a Node runtime, and it listens on `80` rather than `3000`. It is reached at **`vantage.hostxtra.co.uk/docs`** — a path on the marketing host, routed by its own Nginx Proxy Manager location, which must sort **above** the catch-all forwarding to `site:3003` or Next answers the 404. A path and not a subdomain because `*.vantage.hostxtra.co.uk` is the per-tenant instance namespace and `APP_ROOT_LABEL` would read a `docs.` label as a tenant slug. NPM forwards the **full** path upstream — it does not strip `/docs` — so `DOCS_BASE_URL`, the proxy location and the directory the image copies the build into (`/usr/share/nginx/html/docs`) must all agree. When they do not, the HTML loads and every asset 404s.
|
||||
|
||||
`LICENSE_SIGNING_KEY` appears in **exactly one service in exactly one compose file**: `admin` in `docker-compose.site.yml`. It must never be added to `server`, and the self-hosted `docker-compose.yml` must never mention `admin` or `adminsite` at all. Admin uses an external Redis via `REDIS_ADDR`/`REDIS_USERNAME`/`REDIS_PASSWORD`; the base compose hardcodes `redis:6379` for `server`, so those variables reach admin only.
|
||||
`LICENSE_SIGNING_KEY` appears in **exactly one service in exactly one compose file**: `admin` in `docker-compose.site.yml`. It must never be added to `server`, and the self-hosted `docker-compose.yml` must never mention `admin` or `adminsite` at all. Admin uses an external Redis via `REDIS_ADDR`/`REDIS_USERNAME`/`REDIS_PASSWORD`. `server` now reads the same three, so a Kubernetes install can point at a managed Redis; the base compose still hardcodes an unauthenticated `redis:6379` for it, so in Docker those credentials remain admin's alone.
|
||||
|
||||
---
|
||||
|
||||
@@ -651,10 +724,24 @@ cd /opt/vantage && docker compose -f docker-compose.yml -f docker-compose.site.y
|
||||
|
||||
The gap this leaves: **changing a repo variable pushes no commit, so nothing rebuilds.** After editing `ADMIN_API_URL`, `HQ_URL` or `ADMIN_ENV`, run the workflow manually — that is what `workflow_dispatch` is there for. Base images also stop being refreshed on a service nobody touches; a periodic manual run covers that.
|
||||
|
||||
### `chart-release.yml` — validates on every chart change, publishes on `chart/v*` tags
|
||||
|
||||
Two jobs' worth of work in one, split by trigger. Any push or PR touching `deploy/chart/` lints the chart and renders it four ways: defaults, a multi-replica install, external Redis and MongoDB, and a set of values that **must be refused**. That last one is the point — every safety rail in this chart is a template `fail`, and `helm lint` happily accepts a chart whose templates never execute, so only rendering proves they still fire.
|
||||
|
||||
Publishing runs only on a `chart/v*` tag, to the Gitea Helm registry at `/api/packages/<owner>/helm/api/charts`. **`Chart.yaml` is the source of truth for the version**; the tag only selects which one to publish, and a tag that disagrees with `Chart.yaml` fails rather than stamping over it — the alternative leaves the repository disagreeing with what shipped. A version already in the registry is rejected by Gitea, which is intended: published chart versions are immutable.
|
||||
|
||||
The registry host comes from `github.server_url`, so it cannot drift from the instance the workflow is running on, and it reuses `REGISTRY_USER` / `REGISTRY_PASSWORD` — the same `write:packages` token the images use.
|
||||
|
||||
```bash
|
||||
helm repo add vantage https://gitea.hostxtra.co.uk/api/packages/mrhid6/helm
|
||||
helm install vantage vantage/vantage --version 0.1.0
|
||||
```
|
||||
|
||||
### Tagging
|
||||
|
||||
```bash
|
||||
git tag agent/v1.0.0 && git push origin agent/v1.0.0 # agent release
|
||||
git tag chart/v0.1.0 && git push origin chart/v0.1.0 # helm chart package
|
||||
git push origin main # server + web deploy
|
||||
```
|
||||
|
||||
@@ -669,6 +756,7 @@ git push origin main # server + web deploy
|
||||
| `API_URL` | **not** a CI variable | `web` reads it at **runtime**, from the container environment — `next.config.ts` is evaluated when `server.js` boots in standalone mode, and the rewrites it feeds are server-side, never browser-side. Default `http://localhost:8080`; compose sets `http://server:8080`. `NEXT_PUBLIC_API_URL` is still honoured as a fallback for existing deployments. |
|
||||
| `SITE_API_URL` | Variable | **browser-reachable** sitesvc URL, baked into the `site` image. Required — if empty, both forms report "not connected" and submit nowhere. Must also be in sitesvc's `SITE_ORIGIN`. |
|
||||
| `SITE_CONTACT_EMAIL` | Variable | optional; address shown when a form is misconfigured |
|
||||
| `SITE_URL` | Variable | browser URL of the marketing site, baked into `adminsite` so `/login` can point at `/start`. **Signup has no page in `adminsite` at all** — one signup form, on `site/`. Empty renders no link rather than one that 404s. |
|
||||
| `ADMIN_API_URL` | Variable | **browser-reachable** admin URL, baked into **both** the `adminsite` and `site` images — `site/start` posts account signups straight to admin. Same footgun as `SITE_API_URL`: wrong here and every request fails at runtime with the not-connected panel. |
|
||||
| `ADMIN_ENV` | Variable | `production` or `sandbox`; drives the persistent environment badge. Anything but `sandbox` reads as production. |
|
||||
| `HQ_URL` | Variable | optional; browser URL of the HQ portal, baked into `web` so an `hq`-sourced member links to where they are managed. Empty on self-hosted, which renders a plain label instead. |
|
||||
@@ -697,4 +785,15 @@ git push origin main # server + web deploy
|
||||
- **`org_id` on every document** — isolation enforced at the query layer, not by separate databases.
|
||||
- **root only** — manages `/root/.ssh/authorized_keys`; no per-user key management.
|
||||
- **Windows agents are second-class by design** — register, heartbeat, run steps, report inventory; no `authorized_keys` management.
|
||||
- **Both `server` and `web` scale horizontally** — see "Running more than one server replica" below. `web` holds nothing; `server` holds per-agent state that is routed between replicas over Redis rather than duplicated.
|
||||
- **Deletion lives in the control plane** — admin sends the warnings because it knows the billing address; the control plane performs the delete because it is the only service that knows which collections carry `instance_id`. Mirroring that list into admin would drift, and a drift there deletes the wrong rows.
|
||||
|
||||
## graphify
|
||||
|
||||
This project has a knowledge graph at graphify-out/ with god nodes, community structure, and cross-file relationships.
|
||||
|
||||
Rules:
|
||||
- For codebase questions, first run `graphify query "<question>"` when graphify-out/graph.json exists. Use `graphify path "<A>" "<B>"` for relationships and `graphify explain "<concept>"` for focused concepts. These return a scoped subgraph, usually much smaller than GRAPH_REPORT.md or raw grep output.
|
||||
- If graphify-out/wiki/index.md exists, use it for broad navigation instead of raw source browsing.
|
||||
- Read graphify-out/GRAPH_REPORT.md only for broad architecture review or when query/path/explain do not surface enough context.
|
||||
- After modifying code, run `graphify update .` to keep the graph current (AST-only, no API cost).
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
[Unit]
|
||||
Description=Vantage Agent
|
||||
Documentation=https://github.com/your-org/vantage
|
||||
After=network.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/local/bin/vantage-agent
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
User=root
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=vantage-agent
|
||||
|
||||
# Security hardening
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=false
|
||||
ProtectHome=false
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,6 @@
|
||||
apiVersion: v2
|
||||
name: vantage
|
||||
description: Helm chart for the Vantage stack (Redis, MongoDB, guacd, server, web)
|
||||
type: application
|
||||
version: 0.1.0
|
||||
appVersion: "1.0.0"
|
||||
@@ -0,0 +1,44 @@
|
||||
Vantage has been deployed as release "{{ .Release.Name }}" in namespace "{{ .Release.Namespace }}".
|
||||
|
||||
Services created:
|
||||
{{- if .Values.redis.enabled }}
|
||||
- {{ .Release.Name }}-redis (ClusterIP {{ .Values.redis.port }})
|
||||
{{- else }}
|
||||
- Redis: not deployed, using external {{ .Values.redis.addr }}
|
||||
{{- end }}
|
||||
{{- if .Values.mongo.enabled }}
|
||||
- {{ .Release.Name }}-mongo (ClusterIP {{ .Values.mongo.port }})
|
||||
{{- else }}
|
||||
- MongoDB: not deployed, using the external server.env.mongoUri
|
||||
{{- end }}
|
||||
- {{ .Release.Name }}-guacd ({{ .Values.guacd.service.type }} {{ .Values.guacd.service.port }})
|
||||
- {{ .Release.Name }}-server ({{ .Values.server.service.type }} http:{{ .Values.server.service.httpPort }} grpc:{{ .Values.server.service.grpcPort }})
|
||||
- {{ .Release.Name }}-web ({{ .Values.web.service.type }} {{ .Values.web.service.port }})
|
||||
|
||||
Scaling (server.replicaCount / web.replicaCount):
|
||||
- Both scale. Pin the image tags first — replicas on different builds serve
|
||||
mismatched web asset hashes, and mixed server versions share one bus.
|
||||
- server replicas route agent commands, step results and console relays to
|
||||
each other over Redis, so every replica must use the SAME Redis. Workflow
|
||||
logs are in MongoDB, not on a volume.
|
||||
- Background work (monitor scheduler, Free reaper, log and audit retention,
|
||||
the offline sweep) runs on one replica at a time under a Redis leader lock.
|
||||
- server.persistence must be off to scale past one replica on a ReadWriteOnce
|
||||
volume. Nothing writes to it any more.
|
||||
{{- if gt (int .Values.server.replicaCount) 1 }}
|
||||
- Console relays are reached by pod IP; guacd must be able to dial pod IPs
|
||||
directly (it can, inside the cluster network).
|
||||
{{- end }}
|
||||
{{- if .Values.server.migrationJob.enabled }}
|
||||
- Migrations run in the {{ .Release.Name }}-migrate Job before each upgrade;
|
||||
the pods skip them. Its logs are kept: kubectl logs job/{{ .Release.Name }}-migrate
|
||||
{{- end }}
|
||||
|
||||
By default the server/web/guacd services are ClusterIP only (no host port publishing,
|
||||
unlike the original docker-compose file). To expose them externally, set
|
||||
server.service.type / web.service.type / guacd.service.type to NodePort or LoadBalancer,
|
||||
or add an Ingress on top of the -web and -server services.
|
||||
|
||||
Quick access via port-forward, e.g.:
|
||||
kubectl port-forward svc/{{ .Release.Name }}-web {{ .Values.web.service.port }}:{{ .Values.web.service.port }}
|
||||
kubectl port-forward svc/{{ .Release.Name }}-server {{ .Values.server.service.httpPort }}:{{ .Values.server.service.httpPort }}
|
||||
@@ -0,0 +1,84 @@
|
||||
{{/*
|
||||
Common name helpers
|
||||
*/}}
|
||||
{{- define "vantage.fullname" -}}
|
||||
{{ .Release.Name }}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "vantage.labels" -}}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
vantage.server.env renders the server container's environment.
|
||||
|
||||
It lives here because two workloads need it identically: the Deployment and the
|
||||
pre-upgrade migration Job. A Job that connected to a different database than the
|
||||
pods it migrates for would be worse than no Job at all, so there is one copy and
|
||||
both read it.
|
||||
*/}}
|
||||
{{- define "vantage.server.env" -}}
|
||||
- name: MONGO_URI
|
||||
{{- $mongoUri := tpl .Values.server.env.mongoUri . }}
|
||||
{{- if and (not .Values.mongo.enabled) (contains (printf "%s-mongo" .Release.Name) $mongoUri) }}
|
||||
{{- fail "mongo.enabled is false, so server.env.mongoUri must point at an external MongoDB rather than the in-chart one" }}
|
||||
{{- end }}
|
||||
value: {{ $mongoUri | quote }}
|
||||
- name: REDIS_ADDR
|
||||
{{- if .Values.redis.enabled }}
|
||||
value: "{{ .Release.Name }}-redis:{{ .Values.redis.port }}"
|
||||
{{- else }}
|
||||
{{- if not .Values.redis.addr }}
|
||||
{{- fail "redis.enabled is false, so redis.addr must be set to an external Redis host:port" }}
|
||||
{{- end }}
|
||||
value: {{ .Values.redis.addr | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.redis.auth.existingSecret }}
|
||||
- name: REDIS_USERNAME
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ .Values.redis.auth.existingSecret }}
|
||||
key: {{ .Values.redis.auth.usernameKey }}
|
||||
optional: true
|
||||
- name: REDIS_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ .Values.redis.auth.existingSecret }}
|
||||
key: {{ .Values.redis.auth.passwordKey }}
|
||||
{{- else }}
|
||||
{{- if .Values.redis.auth.username }}
|
||||
- name: REDIS_USERNAME
|
||||
value: {{ .Values.redis.auth.username | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.redis.auth.password }}
|
||||
- name: REDIS_PASSWORD
|
||||
value: {{ .Values.redis.auth.password | quote }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
- name: GRPC_HOST
|
||||
value: {{ .Values.server.env.grpcHost | quote }}
|
||||
- name: GRPC_PORT
|
||||
value: {{ .Values.server.service.grpcPort | quote }}
|
||||
- name: HTTP_PORT
|
||||
value: {{ .Values.server.service.httpPort | quote }}
|
||||
- name: KEY_ENCRYPTION_KEY
|
||||
value: {{ .Values.server.env.keyEncryptionKey | quote }}
|
||||
- name: GUACD_ADDR
|
||||
value: "{{ .Release.Name }}-guacd:{{ .Values.guacd.service.port }}"
|
||||
- name: APP_ROOT_LABEL
|
||||
value: {{ .Values.server.env.appRootLabel | quote }}
|
||||
- name: PROXY_ADVERTISE_HOST
|
||||
value: {{ .Values.server.env.proxyAdvertiseHost | quote }}
|
||||
- name: PROXY_LISTEN_HOST
|
||||
value: {{ .Values.server.env.proxyListenHost | quote }}
|
||||
# The address guacd dials to reach a console relay. It must name one pod, not
|
||||
# the Service: the relay listener is bound by whichever pod holds that agent's
|
||||
# command stream, and a Service would send guacd to a different one. POD_IP
|
||||
# takes precedence over PROXY_ADVERTISE_HOST in the server for exactly this
|
||||
# reason, so the setting above stays meaningful only outside Kubernetes.
|
||||
- name: POD_IP
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: status.podIP
|
||||
{{- end -}}
|
||||
@@ -0,0 +1,44 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-guacd
|
||||
labels:
|
||||
{{- include "vantage.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: guacd
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/component: guacd
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/component: guacd
|
||||
spec:
|
||||
{{- if .Values.imagePullSecrets }}
|
||||
imagePullSecrets:
|
||||
{{- toYaml .Values.imagePullSecrets | nindent 8 }}
|
||||
{{- end }}
|
||||
containers:
|
||||
- name: guacd
|
||||
image: "{{ .Values.guacd.image.repository }}:{{ .Values.guacd.image.tag }}"
|
||||
ports:
|
||||
- containerPort: 4822
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-guacd
|
||||
labels:
|
||||
{{- include "vantage.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: guacd
|
||||
spec:
|
||||
type: {{ .Values.guacd.service.type }}
|
||||
selector:
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/component: guacd
|
||||
ports:
|
||||
- port: {{ .Values.guacd.service.port }}
|
||||
targetPort: 4822
|
||||
@@ -0,0 +1,68 @@
|
||||
{{- if .Values.server.migrationJob.enabled }}
|
||||
{{/*
|
||||
Schema setup, lifted out of the serving pods.
|
||||
|
||||
Every server process used to run migrations, index builders and default-step
|
||||
seeding at boot. With one replica that is fine. With two it is not: 0004 renames
|
||||
the orgs collection to instances, and a sibling reading it mid-rename is a
|
||||
corruption, not a retry.
|
||||
|
||||
A Helm hook Job runs it once, before any pod of the new version starts. The
|
||||
Deployment then sets VANTAGE_SKIP_MIGRATIONS, which is what makes the Job's
|
||||
existence load-bearing rather than decorative — if you disable the Job, the
|
||||
pods go back to migrating themselves and you must go back to one replica.
|
||||
|
||||
hook-weight orders this after the dependency waits; before-hook-creation deletes
|
||||
the previous Job so a repeat upgrade is not blocked by an immutable object. The
|
||||
Job is deliberately NOT deleted on success: its logs are the record of what the
|
||||
upgrade did to the database.
|
||||
*/}}
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-migrate
|
||||
labels:
|
||||
{{- include "vantage.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: migrate
|
||||
annotations:
|
||||
"helm.sh/hook": pre-install,pre-upgrade
|
||||
"helm.sh/hook-weight": "0"
|
||||
"helm.sh/hook-delete-policy": before-hook-creation
|
||||
spec:
|
||||
backoffLimit: {{ .Values.server.migrationJob.backoffLimit }}
|
||||
# A migration that has not finished in this long is stuck, and a stuck
|
||||
# migration should fail the upgrade rather than hold it open forever.
|
||||
activeDeadlineSeconds: {{ .Values.server.migrationJob.activeDeadlineSeconds }}
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/component: migrate
|
||||
spec:
|
||||
restartPolicy: Never
|
||||
{{- if .Values.imagePullSecrets }}
|
||||
imagePullSecrets:
|
||||
{{- toYaml .Values.imagePullSecrets | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- if .Values.mongo.enabled }}
|
||||
# Only Mongo. The Job never opens Redis, and waiting on a Redis this
|
||||
# chart may not even deploy would block an upgrade for no reason.
|
||||
initContainers:
|
||||
- name: wait-for-mongo
|
||||
image: busybox:1.36
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
until nc -z {{ .Release.Name }}-mongo {{ .Values.mongo.port }}; do
|
||||
echo "waiting for mongo..."; sleep 2;
|
||||
done
|
||||
{{- end }}
|
||||
containers:
|
||||
- name: migrate
|
||||
image: "{{ .Values.server.image.repository }}:{{ .Values.server.image.tag }}"
|
||||
env:
|
||||
{{- include "vantage.server.env" . | nindent 12 }}
|
||||
- name: VANTAGE_MIGRATE_ONLY
|
||||
value: "true"
|
||||
{{- end }}
|
||||
@@ -0,0 +1,92 @@
|
||||
{{- if .Values.mongo.enabled }}
|
||||
{{- if .Values.mongo.persistence.enabled }}
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-mongo-data
|
||||
labels:
|
||||
{{- include "vantage.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: mongo
|
||||
spec:
|
||||
accessModes:
|
||||
- {{ .Values.mongo.persistence.accessMode }}
|
||||
{{- if .Values.mongo.persistence.storageClass }}
|
||||
storageClassName: {{ .Values.mongo.persistence.storageClass }}
|
||||
{{- end }}
|
||||
resources:
|
||||
requests:
|
||||
storage: {{ .Values.mongo.persistence.size }}
|
||||
---
|
||||
{{- end }}
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-mongo
|
||||
labels:
|
||||
{{- include "vantage.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: mongo
|
||||
spec:
|
||||
replicas: 1
|
||||
strategy:
|
||||
type: Recreate
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/component: mongo
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/component: mongo
|
||||
spec:
|
||||
{{- if .Values.imagePullSecrets }}
|
||||
imagePullSecrets:
|
||||
{{- toYaml .Values.imagePullSecrets | nindent 8 }}
|
||||
{{- end }}
|
||||
containers:
|
||||
- name: mongo
|
||||
image: "{{ .Values.mongo.image.repository }}:{{ .Values.mongo.image.tag }}"
|
||||
ports:
|
||||
- containerPort: {{ .Values.mongo.port }}
|
||||
volumeMounts:
|
||||
- name: mongo-data
|
||||
mountPath: /data/db
|
||||
livenessProbe:
|
||||
exec:
|
||||
command: ["mongosh", "--quiet", "--eval", "db.adminCommand('ping')"]
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 5
|
||||
readinessProbe:
|
||||
exec:
|
||||
command: ["mongosh", "--quiet", "--eval", "db.adminCommand('ping')"]
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 5
|
||||
volumes:
|
||||
- name: mongo-data
|
||||
{{- if .Values.mongo.persistence.enabled }}
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ .Release.Name }}-mongo-data
|
||||
{{- else }}
|
||||
emptyDir: {}
|
||||
{{- end }}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-mongo
|
||||
labels:
|
||||
{{- include "vantage.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: mongo
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/component: mongo
|
||||
ports:
|
||||
- port: {{ .Values.mongo.port }}
|
||||
targetPort: {{ .Values.mongo.port }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,92 @@
|
||||
{{- if .Values.redis.enabled }}
|
||||
{{- if .Values.redis.persistence.enabled }}
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-redis-data
|
||||
labels:
|
||||
{{- include "vantage.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: redis
|
||||
spec:
|
||||
accessModes:
|
||||
- {{ .Values.redis.persistence.accessMode }}
|
||||
{{- if .Values.redis.persistence.storageClass }}
|
||||
storageClassName: {{ .Values.redis.persistence.storageClass }}
|
||||
{{- end }}
|
||||
resources:
|
||||
requests:
|
||||
storage: {{ .Values.redis.persistence.size }}
|
||||
---
|
||||
{{- end }}
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-redis
|
||||
labels:
|
||||
{{- include "vantage.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: redis
|
||||
spec:
|
||||
replicas: 1
|
||||
strategy:
|
||||
type: Recreate
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/component: redis
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/component: redis
|
||||
spec:
|
||||
{{- if .Values.imagePullSecrets }}
|
||||
imagePullSecrets:
|
||||
{{- toYaml .Values.imagePullSecrets | nindent 8 }}
|
||||
{{- end }}
|
||||
containers:
|
||||
- name: redis
|
||||
image: "{{ .Values.redis.image.repository }}:{{ .Values.redis.image.tag }}"
|
||||
ports:
|
||||
- containerPort: {{ .Values.redis.port }}
|
||||
volumeMounts:
|
||||
- name: redis-data
|
||||
mountPath: /data
|
||||
livenessProbe:
|
||||
exec:
|
||||
command: ["redis-cli", "ping"]
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 5
|
||||
readinessProbe:
|
||||
exec:
|
||||
command: ["redis-cli", "ping"]
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 5
|
||||
volumes:
|
||||
- name: redis-data
|
||||
{{- if .Values.redis.persistence.enabled }}
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ .Release.Name }}-redis-data
|
||||
{{- else }}
|
||||
emptyDir: {}
|
||||
{{- end }}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-redis
|
||||
labels:
|
||||
{{- include "vantage.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: redis
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/component: redis
|
||||
ports:
|
||||
- port: {{ .Values.redis.port }}
|
||||
targetPort: {{ .Values.redis.port }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,174 @@
|
||||
{{- if and .Values.server.persistence.enabled (not .Values.server.persistence.useHostPath) }}
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-server-data
|
||||
labels:
|
||||
{{- include "vantage.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: server
|
||||
spec:
|
||||
accessModes:
|
||||
- {{ .Values.server.persistence.accessMode }}
|
||||
{{- if .Values.server.persistence.storageClass }}
|
||||
storageClassName: {{ .Values.server.persistence.storageClass }}
|
||||
{{- end }}
|
||||
resources:
|
||||
requests:
|
||||
storage: {{ .Values.server.persistence.size }}
|
||||
---
|
||||
{{- end }}
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-server
|
||||
labels:
|
||||
{{- include "vantage.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: server
|
||||
spec:
|
||||
{{- $replicas := int .Values.server.replicaCount }}
|
||||
replicas: {{ $replicas }}
|
||||
{{- if and .Values.server.persistence.enabled (eq .Values.server.persistence.accessMode "ReadWriteOnce") }}
|
||||
# A ReadWriteOnce volume cannot be mounted by a second pod at all, and cannot
|
||||
# be handed to a new pod while the old one still holds it. Persistence is off
|
||||
# by default now that nothing writes to it; if it is on, replicas are capped
|
||||
# at one and updates go through Recreate.
|
||||
{{- if gt $replicas 1 }}
|
||||
{{- fail "server.persistence.enabled with a ReadWriteOnce volume cannot be combined with server.replicaCount > 1. Nothing in the server writes to that volume any more (workflow logs live in MongoDB); set server.persistence.enabled=false, or use a ReadWriteMany accessMode if you are keeping it for another reason." }}
|
||||
{{- end }}
|
||||
strategy:
|
||||
type: Recreate
|
||||
{{- end }}
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/component: server
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/component: server
|
||||
spec:
|
||||
{{- if .Values.imagePullSecrets }}
|
||||
imagePullSecrets:
|
||||
{{- toYaml .Values.imagePullSecrets | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- if or .Values.redis.enabled .Values.mongo.enabled }}
|
||||
# Wait for the dependencies this chart deploys to be reachable,
|
||||
# approximating compose's `depends_on: condition: service_healthy`. An
|
||||
# external Redis or Mongo is assumed to be up already — waiting on one
|
||||
# would only turn someone else's outage into a stuck pod.
|
||||
initContainers:
|
||||
{{- if .Values.redis.enabled }}
|
||||
- name: wait-for-redis
|
||||
image: busybox:1.36
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
until nc -z {{ .Release.Name }}-redis {{ .Values.redis.port }}; do
|
||||
echo "waiting for redis..."; sleep 2;
|
||||
done
|
||||
{{- end }}
|
||||
{{- if .Values.mongo.enabled }}
|
||||
- name: wait-for-mongo
|
||||
image: busybox:1.36
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
until nc -z {{ .Release.Name }}-mongo {{ .Values.mongo.port }}; do
|
||||
echo "waiting for mongo..."; sleep 2;
|
||||
done
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
containers:
|
||||
- name: server
|
||||
image: "{{ .Values.server.image.repository }}:{{ .Values.server.image.tag }}"
|
||||
ports:
|
||||
- containerPort: {{ .Values.server.service.httpPort }}
|
||||
- containerPort: {{ .Values.server.service.grpcPort }}
|
||||
env:
|
||||
{{- include "vantage.server.env" . | nindent 12 }}
|
||||
{{- if .Values.server.migrationJob.enabled }}
|
||||
# Schema setup ran in the pre-upgrade Job. Pods that repeated it
|
||||
# would race each other, and the rename migration is not a race
|
||||
# that tolerates a loser.
|
||||
- name: VANTAGE_SKIP_MIGRATIONS
|
||||
value: "true"
|
||||
{{- end }}
|
||||
# Liveness never touches Mongo or Redis: restarting every pod cannot
|
||||
# fix a database outage, and each restart drops every agent command
|
||||
# stream and console session it was carrying. Readiness does check
|
||||
# both, so a pod that cannot serve leaves the Service and stays up.
|
||||
startupProbe:
|
||||
httpGet:
|
||||
path: /healthz
|
||||
port: {{ .Values.server.service.httpPort }}
|
||||
periodSeconds: 5
|
||||
# Generous: without the migration Job this pod runs every migration
|
||||
# before it listens, and the rename has a ten-minute budget.
|
||||
failureThreshold: 150
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /healthz
|
||||
port: {{ .Values.server.service.httpPort }}
|
||||
periodSeconds: 20
|
||||
failureThreshold: 3
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /readyz
|
||||
port: {{ .Values.server.service.httpPort }}
|
||||
periodSeconds: 10
|
||||
failureThreshold: 3
|
||||
{{- if .Values.server.persistence.enabled }}
|
||||
# Nothing in the server writes here any more — workflow logs moved to
|
||||
# MongoDB so that every replica can read and write them. The mount
|
||||
# remains only so an operator upgrading from a file-log release can
|
||||
# still reach the old files before turning persistence off.
|
||||
volumeMounts:
|
||||
- name: server-data
|
||||
mountPath: /data
|
||||
volumes:
|
||||
- name: server-data
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ .Release.Name }}-server-data
|
||||
{{- end }}
|
||||
---
|
||||
{{- if gt (int .Values.server.replicaCount) 1 }}
|
||||
apiVersion: policy/v1
|
||||
kind: PodDisruptionBudget
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-server
|
||||
labels:
|
||||
{{- include "vantage.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: server
|
||||
spec:
|
||||
# Agents reconnect on their own, but a drain that took every replica at once
|
||||
# would disconnect every agent in the fleet simultaneously and stall every
|
||||
# workflow run in flight.
|
||||
minAvailable: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/component: server
|
||||
---
|
||||
{{- end }}
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-server
|
||||
labels:
|
||||
{{- include "vantage.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: server
|
||||
spec:
|
||||
type: {{ .Values.server.service.type }}
|
||||
selector:
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/component: server
|
||||
ports:
|
||||
- name: http
|
||||
port: {{ .Values.server.service.httpPort }}
|
||||
targetPort: {{ .Values.server.service.httpPort }}
|
||||
- name: grpc
|
||||
port: {{ .Values.server.service.grpcPort }}
|
||||
targetPort: {{ .Values.server.service.grpcPort }}
|
||||
@@ -0,0 +1,80 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-web
|
||||
labels:
|
||||
{{- include "vantage.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: web
|
||||
spec:
|
||||
# web holds no per-process state: sessions live in Redis and every request is
|
||||
# proxied to the server. It is the one component here that scales freely.
|
||||
replicas: {{ .Values.web.replicaCount }}
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/component: web
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/component: web
|
||||
spec:
|
||||
{{- if .Values.imagePullSecrets }}
|
||||
imagePullSecrets:
|
||||
{{- toYaml .Values.imagePullSecrets | nindent 8 }}
|
||||
{{- end }}
|
||||
initContainers:
|
||||
- name: wait-for-server
|
||||
image: busybox:1.36
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
until nc -z {{ .Release.Name }}-server {{ .Values.server.service.httpPort }}; do
|
||||
echo "waiting for server..."; sleep 2;
|
||||
done
|
||||
containers:
|
||||
- name: web
|
||||
image: "{{ .Values.web.image.repository }}:{{ .Values.web.image.tag }}"
|
||||
ports:
|
||||
- containerPort: {{ .Values.web.service.port }}
|
||||
env:
|
||||
- name: API_URL
|
||||
value: {{ tpl .Values.web.env.apiUrl . | quote }}
|
||||
# /healthz is served by this Next process; /api is rewritten to the
|
||||
# server, so a probe there would report the backend's health and keep
|
||||
# passing while this pod was wedged.
|
||||
startupProbe:
|
||||
httpGet:
|
||||
path: /healthz
|
||||
port: {{ .Values.web.service.port }}
|
||||
periodSeconds: 3
|
||||
failureThreshold: 20
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /healthz
|
||||
port: {{ .Values.web.service.port }}
|
||||
periodSeconds: 20
|
||||
failureThreshold: 3
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /healthz
|
||||
port: {{ .Values.web.service.port }}
|
||||
periodSeconds: 10
|
||||
failureThreshold: 3
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-web
|
||||
labels:
|
||||
{{- include "vantage.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: web
|
||||
spec:
|
||||
type: {{ .Values.web.service.type }}
|
||||
selector:
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/component: web
|
||||
ports:
|
||||
- port: {{ .Values.web.service.port }}
|
||||
targetPort: {{ .Values.web.service.port }}
|
||||
@@ -0,0 +1,111 @@
|
||||
# Default values for the vantage chart.
|
||||
|
||||
redis:
|
||||
# false deploys no Redis and points the server at `redis.addr` instead.
|
||||
enabled: true
|
||||
# Only read when enabled is false. host:port of an external Redis.
|
||||
addr: ""
|
||||
image:
|
||||
repository: redis
|
||||
tag: "8"
|
||||
persistence:
|
||||
enabled: true
|
||||
size: 1Gi
|
||||
storageClass: ""
|
||||
accessMode: ReadWriteOnce
|
||||
port: 6379
|
||||
# Both empty for an unauthenticated Redis. Redis 6+ ACL auth takes both; a
|
||||
# legacy `requirepass` instance takes the password alone and must leave the
|
||||
# username empty. Set existingSecret to keep the password out of values.
|
||||
auth:
|
||||
username: ""
|
||||
password: ""
|
||||
# Secret holding the credentials. When set, username/password above are
|
||||
# ignored and these keys are read from the secret instead.
|
||||
existingSecret: ""
|
||||
usernameKey: username
|
||||
passwordKey: password
|
||||
|
||||
mongo:
|
||||
# false deploys no MongoDB. server.env.mongoUri must then point at an
|
||||
# external one — the chart cannot guess it, and refuses to render without it.
|
||||
enabled: true
|
||||
image:
|
||||
repository: mongo
|
||||
tag: "7"
|
||||
persistence:
|
||||
enabled: true
|
||||
size: 5Gi
|
||||
storageClass: ""
|
||||
accessMode: ReadWriteOnce
|
||||
port: 27017
|
||||
|
||||
guacd:
|
||||
image:
|
||||
repository: docker.io/guacamole/guacd
|
||||
tag: "1.6.0"
|
||||
service:
|
||||
type: ClusterIP
|
||||
port: 4822
|
||||
|
||||
server:
|
||||
# Safe to raise. Agent commands, step results and console relays are routed
|
||||
# between replicas over Redis, workflow logs live in MongoDB, and the
|
||||
# background jobs (monitor scheduler, reaper, retention sweeps) run under a
|
||||
# Redis leader lock so exactly one replica performs them.
|
||||
#
|
||||
# Two requirements come with raising it: server.persistence.enabled must be
|
||||
# false (or the volume ReadWriteMany), and Redis must be shared by every
|
||||
# replica — the bus is not optional and a per-pod Redis would partition it.
|
||||
replicaCount: 1
|
||||
# Runs migrations, index builders and default-step seeding once, as a Helm
|
||||
# pre-install/pre-upgrade hook, instead of in every starting pod. Leave it
|
||||
# on for Kubernetes. Turning it off puts schema setup back in the pods.
|
||||
migrationJob:
|
||||
enabled: true
|
||||
backoffLimit: 0
|
||||
# 15 minutes: the instance rename alone carries a 10-minute budget.
|
||||
activeDeadlineSeconds: 900
|
||||
image:
|
||||
repository: gitea.hostxtra.co.uk/mrhid6/vantage/server
|
||||
tag: latest
|
||||
service:
|
||||
type: ClusterIP
|
||||
httpPort: 8080
|
||||
grpcPort: 9090
|
||||
env:
|
||||
mongoUri: "mongodb://{{ .Release.Name }}-mongo:27017/vantage"
|
||||
grpcHost: "{{ .Release.Name }}-server:9090"
|
||||
keyEncryptionKey: ""
|
||||
appRootLabel: vantage
|
||||
# Ignored under Kubernetes: the chart sets POD_IP from the downward API
|
||||
# and the server prefers it, because a console relay listener belongs to
|
||||
# one pod and a Service address cannot name one.
|
||||
proxyAdvertiseHost: "{{ .Release.Name }}-server"
|
||||
proxyListenHost: "0.0.0.0"
|
||||
# Off by default: nothing in the server writes to disk any more. Workflow
|
||||
# logs, the only thing that ever did, are in MongoDB so that every replica
|
||||
# can read and write them. Turn this on only to reach files left behind by
|
||||
# a release that predates that move — and note a ReadWriteOnce volume caps
|
||||
# replicaCount at 1 while it is on.
|
||||
persistence:
|
||||
enabled: false
|
||||
size: 1Gi
|
||||
storageClass: ""
|
||||
accessMode: ReadWriteOnce
|
||||
hostPath: /data
|
||||
|
||||
web:
|
||||
# Stateless — safe to raise. Pin web.image.tag when you do: replicas on
|
||||
# different builds serve mismatched chunk hashes and the UI 404s mid-session.
|
||||
replicaCount: 1
|
||||
image:
|
||||
repository: gitea.hostxtra.co.uk/mrhid6/vantage/web
|
||||
tag: latest
|
||||
service:
|
||||
type: ClusterIP
|
||||
port: 3000
|
||||
env:
|
||||
apiUrl: "http://{{ .Release.Name }}-server:8080"
|
||||
|
||||
imagePullSecrets: []
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
# host:port agents dial for gRPC. No default; boot fails without it.
|
||||
# Must be reachable from managed servers. Use the public host, port 9090.
|
||||
GRPC_HOST=192.168.1.250:9090
|
||||
GRPC_HOST=vantage.yourdomain.com:9090
|
||||
|
||||
# 64-char hex (32 bytes) for AES-256-GCM. Required for private keys,
|
||||
# secrets, OIDC secrets, RDP/VNC credentials.
|
||||
@@ -18,4 +18,3 @@ KEY_ENCRYPTION_KEY=
|
||||
MONGO_URI=mongodb://mongo:27017/vantage
|
||||
|
||||
# Where workflow run logs are written inside the server container.
|
||||
# VANTAGE_WORKFLOW_LOG_DIR=/data/workflow-logs
|
||||
@@ -45,9 +45,8 @@ services:
|
||||
GRPC_PORT: "9090"
|
||||
HTTP_PORT: "8080"
|
||||
KEY_ENCRYPTION_KEY: ${KEY_ENCRYPTION_KEY:-}
|
||||
VANTAGE_WORKFLOW_LOG_DIR: ${VANTAGE_WORKFLOW_LOG_DIR:-}
|
||||
GUACD_ADDR: guacd:4822
|
||||
APP_ROOT_LABEL: vantage
|
||||
PROXY_ADVERTISE_HOST: server
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,209 @@
|
||||
# Agent-relayed console proxy
|
||||
|
||||
Date: 2026-07-29
|
||||
Status: approved, not yet implemented
|
||||
|
||||
## Problem
|
||||
|
||||
`consoleTunnel` builds guacamole parameters from `srv.IPAddress` and hands them
|
||||
to guacd, which then dials the target itself. On a self-hosted deployment the
|
||||
control plane and the managed servers share a network, so that works. On Vantage
|
||||
Cloud they do not: guacd runs on the cloud host and the customer's server is on
|
||||
an RFC1918 address behind their NAT. Every cloud console session to a private
|
||||
address fails, for SSH, RDP and VNC alike.
|
||||
|
||||
Agents already hold an outbound gRPC connection to the control plane. The fix is
|
||||
to carry the console's TCP bytes over that existing path rather than asking guacd
|
||||
to route somewhere it cannot reach.
|
||||
|
||||
## Decisions
|
||||
|
||||
**Self-relay only.** The agent relays to its own host and nowhere else. It is
|
||||
never told a hostname; the host is hardcoded to `127.0.0.1` on the agent side and
|
||||
only the port comes from the server. A jump-host mode (reaching agentless devices
|
||||
through a neighbouring agent) was rejected: it would give an agent the power to
|
||||
dial arbitrary addresses on the customer's LAN, and the console today can only
|
||||
target servers that run an agent anyway.
|
||||
|
||||
**A dedicated bidirectional RPC, one stream per TCP connection.** Multiplexing
|
||||
console bytes onto the existing `CommandStream` was rejected — that stream
|
||||
already carries control commands and workflow stdout, and an RDP framebuffer
|
||||
would introduce head-of-line blocking against key sync and step output. A
|
||||
separate stream also gets connection lifetime, flow control and close semantics
|
||||
for free instead of needing a hand-rolled connection-ID demux.
|
||||
|
||||
**Always proxy, both deployments.** Direct dial is deleted rather than kept as a
|
||||
self-hosted fast path or a fallback. One code path means one tested code path,
|
||||
and the cloud path is the one no developer can reproduce locally. A
|
||||
try-direct-then-fall-back design was rejected outright: it puts a timeout in
|
||||
front of every private-network session and makes "which path did this session
|
||||
use" unanswerable from the audit log.
|
||||
|
||||
The cost is that the console now requires a live agent, where a self-hosted
|
||||
deployment could previously reach a server whose agent was down. In practice an
|
||||
offline agent almost always means an offline host, and the failure is now an
|
||||
immediate, explicit refusal instead of a hang.
|
||||
|
||||
## Architecture
|
||||
|
||||
Three parties rendezvous on a single `proxy_id`. Neither guacd nor the agent
|
||||
changes which direction it dials: guacd still makes an outbound TCP connection,
|
||||
the agent still only connects outbound to the control plane.
|
||||
|
||||
```
|
||||
consoleTunnel (server)
|
||||
1. proxy.Open(instance, server_id, port) -> proxy_id + ephemeral listener :N
|
||||
2. push OpenProxyCmd{proxy_id, port} down the existing CommandStream
|
||||
3. agent dials 127.0.0.1:port locally, then opens ProxyStream and sends
|
||||
ProxyOpen{server_id, agent_token, proxy_id}
|
||||
4. guacd dials PROXY_ADVERTISE_HOST:N (the params it was handed in step 1)
|
||||
5. registry holds both halves -> io.Copy in both directions
|
||||
6. either side EOFs -> close listener, close stream, drop the registry entry
|
||||
```
|
||||
|
||||
Steps 3 and 4 race, so a registry entry has two slots and starts piping when the
|
||||
second one arrives. Both waits share a single 10 second deadline; expiry closes
|
||||
everything and frees the entry.
|
||||
|
||||
The agent dials locally *before* opening the stream, so a refused connection
|
||||
arrives as an explicit `ProxyClose{reason}` rather than as a hang.
|
||||
|
||||
`BuildGuacParams` stops reading `srv.IPAddress` and takes the relay host and port
|
||||
instead. `IPAddress` remains in use for display and for monitors.
|
||||
|
||||
## Wire protocol
|
||||
|
||||
Additive only; no existing message changes shape.
|
||||
|
||||
```protobuf
|
||||
rpc ProxyStream(stream ProxyClientMsg) returns (stream ProxyServerMsg);
|
||||
|
||||
message OpenProxyCmd { // ServerCommand oneof field 8
|
||||
string proxy_id = 1;
|
||||
uint32 port = 2;
|
||||
}
|
||||
|
||||
message ProxyClientMsg {
|
||||
oneof payload {
|
||||
ProxyOpen open = 1; // first message only
|
||||
bytes data = 2;
|
||||
ProxyClose close = 3;
|
||||
}
|
||||
}
|
||||
message ProxyOpen { string server_id = 1; string agent_token = 2; string proxy_id = 3; }
|
||||
message ProxyServerMsg { oneof payload { bytes data = 1; ProxyClose close = 2; } }
|
||||
message ProxyClose { string reason = 1; }
|
||||
```
|
||||
|
||||
Two implementation facts about this repo shape the above. The `pb` packages are
|
||||
**hand-written Go, not protoc output** — `vantage.proto` is documentation, and
|
||||
both `server/internal/grpc/pb` and `agent/internal/grpc/pb` are edited by hand
|
||||
and kept in sync manually. And the registered codec is JSON, so a `bytes` field
|
||||
travels as a base64 string: roughly 33% overhead on relayed traffic. That is
|
||||
accepted rather than fixed here, because introducing a second codec for one RPC
|
||||
is a larger change than this feature warrants. Relay chunks are 32 KiB.
|
||||
|
||||
## Security
|
||||
|
||||
**The agent only ever dials `127.0.0.1`.** The port is the only field it takes
|
||||
from the server; the host is hardcoded agent-side. A compromised control plane
|
||||
cannot use an agent to reach anything else on the customer's network. This is the
|
||||
strongest property in the design and the reason self-relay was chosen.
|
||||
|
||||
**`proxy_id` is 32 random bytes, single-use and scoped.** On `ProxyOpen` the
|
||||
server checks three things together: the agent token hash matches that
|
||||
`server_id`, the `proxy_id` exists in the registry, and the entry's `server_id`
|
||||
and `instance_id` match the authenticated agent. Any mismatch closes the stream
|
||||
without revealing which check failed.
|
||||
|
||||
**The listener is the exposed surface and is narrowed four ways.** It binds an
|
||||
ephemeral port; it lives at most 10 seconds unclaimed; it accepts exactly one
|
||||
connection and closes immediately afterwards; and the accepted connection's
|
||||
remote address must resolve to a host named in `GUACD_ADDR`. Without that last
|
||||
check, any other container on the Docker network could claim the session during
|
||||
the window.
|
||||
|
||||
**Agent-offline is refused early.** `consoleConnect` checks
|
||||
`srv.Status == "active"` and returns 409 `agent_offline`, rather than letting the
|
||||
browser open a WebSocket that dies on a deadline.
|
||||
|
||||
**Audit.** `console.opened` gains the relay port and `proxy_id`. A relay that
|
||||
expires or is refused writes `console.proxy_failed` with a reason, so a failed
|
||||
console session stops being invisible.
|
||||
|
||||
Credentials are unchanged. Private keys and RDP passwords travel from the server
|
||||
to guacd inside the guacamole handshake and never reach the agent. The SSH and
|
||||
RDP sessions are negotiated end-to-end between guacd and the target daemon, so
|
||||
the agent relays bytes it cannot read.
|
||||
|
||||
## Components
|
||||
|
||||
New, server:
|
||||
|
||||
| Unit | Responsibility |
|
||||
| --- | --- |
|
||||
| `server/internal/proxy/registry.go` | `Open`, `AttachAgent`, `AttachTCP`, expiry sweep. Pure state — no net, no gRPC, testable alone |
|
||||
| `server/internal/proxy/session.go` | One relay: listener, deadline, the `io.Copy` pair, teardown-once |
|
||||
| `server/internal/grpc/proxystream.go` | The `ProxyStream` handler: authenticate, then hand the stream to the registry. No relay logic of its own |
|
||||
|
||||
New, agent:
|
||||
|
||||
| Unit | Responsibility |
|
||||
| --- | --- |
|
||||
| `agent/internal/proxy/proxy.go` | `Open(ctx, client, proxyID, port)` — dial loopback, open the stream, pump bytes. No build tags; Linux and Windows share it |
|
||||
|
||||
Changed:
|
||||
|
||||
- `proto/vantage/v1/vantage.proto`, and both generated pb trees
|
||||
- `server/internal/services/console.go` — `BuildGuacParams(srv, relayHost, relayPort, …)`
|
||||
- `server/internal/api/console.go` — offline pre-check in `consoleConnect`; open the relay before the guacd handshake in `consoleTunnel` and close it in `OnDisconnect`
|
||||
- `agent/internal/sync/sync.go` — handle `OpenProxyCmd`, one goroutine per proxy
|
||||
- `deploy/docker-compose.yml`, `deploy/docker-compose.site.yml` — `PROXY_ADVERTISE_HOST=server`
|
||||
|
||||
Two new optional environment variables on the server: `PROXY_ADVERTISE_HOST`
|
||||
(default `server`, the name guacd resolves the control plane by) and
|
||||
`PROXY_LISTEN_HOST` (default `0.0.0.0`).
|
||||
|
||||
Nothing new is opened on the customer's firewall — the relay rides the agent's
|
||||
existing outbound gRPC connection.
|
||||
`docsite/docs/reference/ports-and-networking.md` and
|
||||
`docsite/docs/vantage/browser-console.md` must say so, and must state the new
|
||||
requirement that the agent be online.
|
||||
|
||||
A secondary benefit beyond cloud: a VNC or RDP service bound only to `127.0.0.1`
|
||||
is now reachable, where a direct dial from guacd never could be.
|
||||
|
||||
## Failure modes
|
||||
|
||||
| Failure | Behaviour |
|
||||
| --- | --- |
|
||||
| Agent offline at connect | 409 `agent_offline` from `consoleConnect`, before any WebSocket is opened |
|
||||
| Agent never opens the stream | 10s deadline; listener closed; `console.proxy_failed{reason:"agent_timeout"}`; WebSocket closed with a message the UI surfaces |
|
||||
| Local dial refused (daemon down, wrong port) | `ProxyClose{reason}` relayed up as the same audit event, reason `dial_refused` |
|
||||
| guacd never dials | Same deadline path, reason `guacd_timeout` |
|
||||
| Bad token, unknown or foreign `proxy_id` | Stream closed with no detail leaked; `console.proxy_failed{reason:"rejected"}` |
|
||||
| Agent process dies mid-session | Stream EOF, relay torn down, console shows a disconnect |
|
||||
| CommandStream reconnects mid-session | No effect on live sessions — the relay is on its own stream. Only a new `OpenProxyCmd` needs the control stream |
|
||||
|
||||
Teardown is guarded by `sync.Once` on both sides: both `io.Copy` goroutines
|
||||
finish, and whichever finishes second must not double-close.
|
||||
|
||||
## Testing
|
||||
|
||||
Written test-first.
|
||||
|
||||
- `server/internal/proxy/registry_test.go` — the two halves pair in either
|
||||
order; expiry frees the entry; a second claim on a used `proxy_id` is
|
||||
rejected; a mismatched `instance_id` is rejected. No network.
|
||||
- `server/internal/proxy/session_test.go` — two `net.Pipe` halves; bytes flow
|
||||
both ways; EOF in each direction tears down; double-close is safe.
|
||||
- `server/internal/grpc/proxystream_test.go` — the authentication matrix: valid,
|
||||
wrong token, unknown `proxy_id`, `proxy_id` belonging to another instance.
|
||||
- `agent/internal/proxy` — a refused dial emits `ProxyClose`; the happy path
|
||||
echoes bytes.
|
||||
- End-to-end in `server`: a fake agent plus a `net.Listen` echo server, asserting
|
||||
bytes traverse listener → registry → stream → echo and back. This is the test
|
||||
that would have caught the original bug.
|
||||
|
||||
Manual verification, in this order: self-hosted SSH (proves no regression),
|
||||
cloud SSH to a private-network host, cloud RDP to a Windows agent.
|
||||
@@ -12,7 +12,7 @@ HQ portal.
|
||||
|
||||
A signed file. It carries the instance UUID it belongs to, the tier, the server
|
||||
allowance, feature toggles and an expiry. The control plane verifies the
|
||||
signature locally — checking a licence never contacts HQ, and a running instance
|
||||
signature locally checking a licence never contacts HQ, and a running instance
|
||||
does not need HQ to be reachable.
|
||||
|
||||
Signing happens in exactly one place, in HQ. The control plane can only verify.
|
||||
@@ -40,7 +40,7 @@ bound to that UUID and hands it back.
|
||||
:::info One Free per account, per deployment
|
||||
The limit is enforced per account **and** deployment, so a Free cloud instance
|
||||
does not stop you claiming Free on a self-hosted install. Both the friendly
|
||||
pre-check and the issuer apply the same rule — deliberately, because a
|
||||
pre-check and the issuer apply the same rule deliberately, because a
|
||||
pre-check stricter than the issuer would refuse something that would actually
|
||||
have worked.
|
||||
:::
|
||||
@@ -56,7 +56,7 @@ starts reporting the tier, allowance and expiry.
|
||||
:::warning Cloud instances cannot paste a licence
|
||||
On a cloud instance `POST /license` answers `409 cloud_managed`, and the UI
|
||||
hides the form entirely. A cloud licence is written directly by HQ. This is not
|
||||
a restriction the injection path has to work around — it writes to the database,
|
||||
a restriction the injection path has to work around it writes to the database,
|
||||
not through the endpoint.
|
||||
:::
|
||||
|
||||
@@ -65,7 +65,7 @@ not through the endpoint.
|
||||
Free licences are renewable from HQ within a renewal window near expiry;
|
||||
outside that window the renew call refuses. See [Free tier](../hq/free-tier.md).
|
||||
|
||||
Pasting a licence keeps working while the current one is expired — that endpoint
|
||||
Pasting a licence keeps working while the current one is expired that endpoint
|
||||
is exempt from the licence check, because it is the way out of degraded mode.
|
||||
|
||||
## Moving the install to new hardware
|
||||
|
||||
@@ -9,21 +9,21 @@ who operates it and how licensing, users and data lifecycle work.
|
||||
|
||||
## At a glance
|
||||
|
||||
| | Cloud | Self-hosted |
|
||||
| --- | --- | --- |
|
||||
| Who runs it | We do | You do |
|
||||
| Where you sign in | `<your-slug>.vantage.hostxtra.co.uk` | Your own hostname |
|
||||
| Database and backups | Ours | Yours |
|
||||
| Licence | Written for you when you buy or create the instance | Pasted in, or claimed from HQ |
|
||||
| Team members | Granted from HQ; the instance holds a projection | Created in the instance itself |
|
||||
| Free tier | Yes, one per account | Yes, one per account |
|
||||
| Expired Free instance | Eventually deleted, after warning | Never deleted |
|
||||
| | Cloud | Self-hosted |
|
||||
| --------------------- | --------------------------------------------------- | ------------------------------ |
|
||||
| Who runs it | We do | You do |
|
||||
| Where you sign in | `<your-slug>.vantage.hostxtra.co.uk` | Your own hostname |
|
||||
| Database and backups | Ours | Yours |
|
||||
| Licence | Written for you when you buy or create the instance | Pasted in, or claimed from HQ |
|
||||
| Team members | Granted from HQ; the instance holds a projection | Created in the instance itself |
|
||||
| Free tier | Yes, one per account | Yes, one per account |
|
||||
| Expired Free instance | Eventually deleted, after warning | Never deleted |
|
||||
|
||||
## Cloud
|
||||
|
||||
You create an instance from the HQ portal and it exists a few seconds later,
|
||||
already licensed. People you grant access to get a real user inside that
|
||||
instance — see [People and roles](../hq/people-and-roles.md) — but HQ owns their
|
||||
instance see [People and roles](../hq/people-and-roles.md) but HQ owns their
|
||||
password, role and existence.
|
||||
|
||||
:::info The instance does not phone home
|
||||
@@ -38,33 +38,27 @@ warning emails first. See [Free tier](../hq/free-tier.md).
|
||||
## Self-hosted
|
||||
|
||||
You run the Docker Compose stack on your own infrastructure. Nothing about the
|
||||
control plane requires an internet connection to HQ at runtime — a licence is a
|
||||
control plane requires an internet connection to HQ at runtime a licence is a
|
||||
signed file, verified locally.
|
||||
|
||||
Two ways to get one:
|
||||
|
||||
1. **Free** — link the install to an HQ account and claim it
|
||||
1. **Free** link the install to an HQ account and claim it
|
||||
([Claim a Free licence](./claim-free-licence.md)).
|
||||
2. **Paid** — buy from HQ, which creates a placeholder, then paste the install's
|
||||
2. **Paid** buy from HQ, which creates a placeholder, then paste the install's
|
||||
real instance UUID to bind and issue
|
||||
([Self-hosted instances](../hq/self-hosted-instances.md)).
|
||||
|
||||
Self-hosted users are local (or OIDC). There is no projection from HQ, and the
|
||||
three member endpoints in HQ refuse to touch a self-hosted instance at all.
|
||||
|
||||
:::warning Self-hosted instances are never deleted by us
|
||||
The reaper that removes expired Free cloud instances is disabled by default and
|
||||
must stay that way on a self-hosted install. See `FREE_INSTANCE_REAP_AFTER` in
|
||||
[Environment variables](../reference/environment-variables.md).
|
||||
:::
|
||||
|
||||
## Which should you pick
|
||||
|
||||
Pick cloud if you want the thing running now and do not want to own a MongoDB.
|
||||
Pick self-hosted if your policy requires the control plane inside your own
|
||||
network, or the servers you manage cannot reach the public internet.
|
||||
|
||||
Moving between them is a migration, not a switch — instances are bound to a
|
||||
Moving between them is a migration, not a switch instances are bound to a
|
||||
deployment at creation, and a licence binds to an instance UUID.
|
||||
|
||||
## Next
|
||||
|
||||
@@ -13,15 +13,15 @@ Open the control plane in a browser. Because no user exists, you land on
|
||||
|
||||
Fill in:
|
||||
|
||||
| Field | Notes |
|
||||
| --- | --- |
|
||||
| Organisation name | Display name. Shown throughout the UI |
|
||||
| Slug | Lowercase, used in the hostname on cloud. Some names are reserved |
|
||||
| Your name | |
|
||||
| Email | Becomes your sign-in identity |
|
||||
| Password | Stored bcrypt-hashed |
|
||||
| Field | Notes |
|
||||
| ----------------- | ----------------------------------------------------------------- |
|
||||
| Organisation name | Display name. Shown throughout the UI |
|
||||
| Slug | Lowercase, used in the hostname on cloud. Some names are reserved |
|
||||
| Your name | |
|
||||
| Email | Becomes your sign-in identity |
|
||||
| Password | Stored bcrypt-hashed |
|
||||
|
||||
Submitting creates the organisation and its **owner** — you.
|
||||
Submitting creates the organisation and its **owner** you.
|
||||
|
||||
:::warning Bootstrap works exactly once
|
||||
The endpoint is open only while the database has no users. As soon as the first
|
||||
@@ -43,30 +43,30 @@ nothing else.
|
||||
You land on the fleet dashboard, which is empty. The sidebar is the whole
|
||||
product:
|
||||
|
||||
| Section | What it does |
|
||||
| --- | --- |
|
||||
| Servers | The fleet — enrol, inspect, console, update |
|
||||
| Keys | SSH public keys and their assignments |
|
||||
| Workflows | Compose and run scripted work |
|
||||
| Steps | The reusable step library |
|
||||
| Monitors | HTTP, TCP, ICMP and TLS checks |
|
||||
| Secrets | The encrypted vault |
|
||||
| Audit | Every mutating action |
|
||||
| Settings | Members, SSO, alerts, retention, licence |
|
||||
| Section | What it does |
|
||||
| --------- | ----------------------------------------- |
|
||||
| Servers | The fleet enrol, inspect, console, update |
|
||||
| Keys | SSH public keys and their assignments |
|
||||
| Workflows | Compose and run scripted work |
|
||||
| Steps | The reusable step library |
|
||||
| Monitors | HTTP, TCP, ICMP and TLS checks |
|
||||
| Secrets | The encrypted vault |
|
||||
| Audit | Every mutating action |
|
||||
| Settings | Members, SSO, alerts, retention, licence |
|
||||
|
||||
## 4. Add the rest of your team
|
||||
|
||||
Go to **Settings → Access**. Add members with a role:
|
||||
|
||||
| Role | Can |
|
||||
| --- | --- |
|
||||
| `owner` | Everything, including billing-adjacent settings |
|
||||
| `admin` | Everything except owner-only settings |
|
||||
| `member` | Day-to-day work — servers, keys, workflows, monitors |
|
||||
| Role | Can |
|
||||
| -------- | -------------------------------------------------- |
|
||||
| `owner` | Everything, including billing-adjacent settings |
|
||||
| `admin` | Everything except owner-only settings |
|
||||
| `member` | Day-to-day work servers, keys, workflows, monitors |
|
||||
|
||||
Settings and organisation management require `owner` or `admin`.
|
||||
|
||||
If you would rather not manage passwords, configure OIDC instead — see
|
||||
If you would rather not manage passwords, configure OIDC instead see
|
||||
[Settings](../vantage/settings.md#single-sign-on-oidc). OIDC is configured per
|
||||
organisation, and the client secret is stored encrypted.
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ one-liner.
|
||||
:::warning The token is single-use and lives one hour
|
||||
It is the only credential in the flow, and it is spent the moment the agent
|
||||
calls `Register`. If you paste it somewhere and come back tomorrow, create a new
|
||||
enrolment instead — nothing is lost by doing so.
|
||||
enrolment instead nothing is lost by doing so.
|
||||
:::
|
||||
|
||||
## 2. Run the one-liner
|
||||
@@ -30,7 +30,7 @@ curl -fsSL "https://vantage.example.com/install?server_id=<id>&token=<token>" |
|
||||
|
||||
Run it as root. The script:
|
||||
|
||||
1. Detects architecture — `x86_64` and `aarch64` only; anything else exits.
|
||||
1. Detects architecture `x86_64` and `aarch64` only; anything else exits.
|
||||
2. Asks the Gitea API for the newest `agent/v*` release.
|
||||
3. Downloads the binary and `checksums.txt`, and **verifies the SHA-256**,
|
||||
aborting on a mismatch.
|
||||
@@ -52,14 +52,14 @@ MSI built by CI if you would rather deploy that.
|
||||
|
||||
:::info Windows agents are second-class on purpose
|
||||
They register, heartbeat, run workflow steps and report inventory. They do
|
||||
**not** manage `authorized_keys` — the key subsystem is Linux-only, and a
|
||||
**not** manage `authorized_keys` the key subsystem is Linux-only, and a
|
||||
Windows agent stops after the heartbeat portion of the poll.
|
||||
:::
|
||||
|
||||
## 3. Watch it come up
|
||||
|
||||
The server appears immediately as `pending`. Within one poll interval — 30
|
||||
seconds — it flips to `active`.
|
||||
The server appears immediately as `pending`. Within one poll interval 30
|
||||
seconds it flips to `active`.
|
||||
|
||||
On the machine:
|
||||
|
||||
@@ -72,10 +72,10 @@ What happens on that first run:
|
||||
|
||||
```
|
||||
1. Load /etc/vantage/config.yaml
|
||||
2. pre_reg_token present → Register() → save agent_token, clear pre_reg_token
|
||||
2. pre_reg_token present → register → save agent_token, clear pre_reg_token
|
||||
3. Reconnect with the permanent token
|
||||
4. Start: command stream · hourly update check · inventory · monitors
|
||||
5. Enter the SyncKeys poll loop
|
||||
5. Enter the key poll loop
|
||||
```
|
||||
|
||||
After registration the config no longer contains the pre-registration token; it
|
||||
@@ -87,19 +87,19 @@ SHA-256 of that token, never the token itself.
|
||||
Open the server's detail page. Within a minute or two you should see:
|
||||
|
||||
- Status `active`, with a recent last-seen timestamp.
|
||||
- Inventory — CPU, memory, swap, partitions, kernel. Metrics refresh every 30
|
||||
- Inventory CPU, memory, swap, partitions, kernel. Metrics refresh every 30
|
||||
seconds; the full static snapshot every 15 minutes.
|
||||
- Pending OS updates, checked hourly.
|
||||
|
||||
## If it does not appear
|
||||
|
||||
| Symptom | Cause |
|
||||
| --- | --- |
|
||||
| Script exits at "Unsupported architecture" | Not amd64 or arm64 |
|
||||
| "Checksum mismatch!" | Interrupted download, or a proxy rewriting the body. Re-run |
|
||||
| "Could not determine latest agent version" | The host cannot reach `gitea.hostxtra.co.uk`, or no `agent/v*` release exists |
|
||||
| Service runs, server stays `pending` | The machine cannot reach `GRPC_HOST`. Test it from that machine |
|
||||
| Registers once then goes `offline` | Reachable for `Register` but not for the poll — usually a firewall that permits the initial connection but drops the long-lived one |
|
||||
| Symptom | Cause |
|
||||
| ------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Script exits at "Unsupported architecture" | Not amd64 or arm64 |
|
||||
| "Checksum mismatch!" | Interrupted download, or a proxy rewriting the body. Re-run |
|
||||
| "Could not determine latest agent version" | The host cannot reach `gitea.hostxtra.co.uk`, or no `agent/v*` release exists |
|
||||
| Service runs, server stays `pending` | The machine cannot reach `GRPC_HOST`. Test it from that machine |
|
||||
| Registers once then goes `offline` | Reachable for `Register` but not for the poll usually a firewall that permits the initial connection but drops the long-lived one |
|
||||
|
||||
A server is marked `offline` when its last-seen time passes the threshold; that
|
||||
sweep runs every two minutes, so allow for it before concluding anything.
|
||||
|
||||
@@ -42,7 +42,7 @@ a way you did not intend.
|
||||
Create `/opt/vantage/.env`:
|
||||
|
||||
```bash
|
||||
# The host:port agents dial. NOT the web URL — this port speaks gRPC.
|
||||
# The host:port agents dial. NOT the web URL this port speaks gRPC.
|
||||
GRPC_HOST=vantage.example.com:9090
|
||||
|
||||
|
||||
@@ -50,7 +50,6 @@ GRPC_HOST=vantage.example.com:9090
|
||||
KEY_ENCRYPTION_KEY=
|
||||
|
||||
# Optional: where workflow run logs are written inside the container.
|
||||
VANTAGE_WORKFLOW_LOG_DIR=/data/workflow-logs
|
||||
```
|
||||
|
||||
Generate the encryption key:
|
||||
@@ -62,7 +61,7 @@ openssl rand -hex 32
|
||||
:::danger Keep the encryption key
|
||||
`KEY_ENCRYPTION_KEY` encrypts SSH private keys, vault secrets, OIDC client
|
||||
secrets and console credentials with AES-256-GCM. Lose it and every one of those
|
||||
becomes unreadable — there is no recovery path. Back it up somewhere other than
|
||||
becomes unreadable there is no recovery path. Back it up somewhere other than
|
||||
the server it protects, and never rotate it without a planned re-encryption.
|
||||
:::
|
||||
|
||||
@@ -109,12 +108,12 @@ Continue with [First login](./first-login.md).
|
||||
|
||||
## Verifying the install
|
||||
|
||||
| Check | Expected |
|
||||
| --- | --- |
|
||||
| `docker compose ps` | five services `running` |
|
||||
| Check | Expected |
|
||||
| ---------------------------------------------- | ------------------------------- |
|
||||
| `docker compose ps` | five services `running` |
|
||||
| `curl -s localhost:8080/auth/bootstrap-status` | JSON saying bootstrap is needed |
|
||||
| `nc -z your-host 9090` | open |
|
||||
| `docker compose logs server \| grep -i fatal` | nothing |
|
||||
| `nc -z your-host 9090` | open |
|
||||
| `docker compose logs server \| grep -i fatal` | nothing |
|
||||
|
||||
## Common install problems
|
||||
|
||||
@@ -131,7 +130,6 @@ More in [Troubleshooting](../reference/troubleshooting.md).
|
||||
|
||||
## What this install does not include
|
||||
|
||||
The marketing site, the public form service, the HQ portal and this
|
||||
documentation site are separate services in `deploy/docker-compose.site.yml`.
|
||||
A self-hosted install deliberately runs none of them, and in particular never
|
||||
holds the licence signing key.
|
||||
The website, the HQ portal and this documentation site are hosted by us and are
|
||||
not part of a self-hosted install. It deliberately runs none of them, and in
|
||||
particular never holds the licence signing key.
|
||||
|
||||
@@ -27,8 +27,8 @@ stores everything durable; Redis stores sessions and nothing else.
|
||||
|
||||
**The agent** is a single Go binary running as root on each managed server. It
|
||||
polls the control plane every 30 seconds for desired key state, and holds a
|
||||
bidirectional command stream so the server can push work — run a workflow step,
|
||||
generate a key, apply updates — without waiting for the next poll.
|
||||
bidirectional command stream so the server can push work run a workflow step,
|
||||
generate a key, apply updates without waiting for the next poll.
|
||||
|
||||
**The web UI** is the operator interface. Everything it does goes through the
|
||||
REST API, which is the actual security boundary; the UI only ever makes things
|
||||
@@ -46,18 +46,18 @@ could guess on its behalf.
|
||||
|
||||
## Two request patterns
|
||||
|
||||
| Pattern | Used for | Why |
|
||||
| --- | --- | --- |
|
||||
| Poll (`SyncKeys`, every 30s) | desired SSH key state | Key changes are not urgent, and polling survives a dropped connection with no reconnection logic |
|
||||
| Push (`CommandStream`) | workflow steps, key generation, updates, agent self-update | Clicking Run should not wait up to 30 seconds |
|
||||
| Pattern | Used for | Why |
|
||||
| ----------------------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
|
||||
| Poll, every 30s | desired SSH key state | Key changes are not urgent, and polling survives a dropped connection with no reconnection logic |
|
||||
| Push, over the command stream | workflow steps, key generation, updates, agent self-update | Clicking Run should not wait up to 30 seconds |
|
||||
|
||||
## Multi-tenancy
|
||||
|
||||
Every document in the database carries an instance ID, and every query is scoped
|
||||
by it. One deployment can therefore host many independent tenants. On a
|
||||
self-hosted install that mechanism is still there — you simply have one tenant.
|
||||
self-hosted install that mechanism is still there you simply have one tenant.
|
||||
|
||||
## Next
|
||||
|
||||
- [Cloud or self-hosted](./cloud-vs-self-hosted.md) — which one you want
|
||||
- [Self-hosted install](./self-hosted-install.md) — stand it up
|
||||
- [Cloud or self-hosted](./cloud-vs-self-hosted.md) which one you want
|
||||
- [Self-hosted install](./self-hosted-install.md) stand it up
|
||||
|
||||
@@ -12,17 +12,17 @@ behind your instances: your team, your instances, their licences and billing.
|
||||
One account holds many people and many instances. Everyone in it has an account
|
||||
role:
|
||||
|
||||
| Role | Can |
|
||||
| --- | --- |
|
||||
| `owner` | Everything, including billing |
|
||||
| `admin` | Invite people, create instances, grant instance access |
|
||||
| `member` | Read what the account holds |
|
||||
| Role | Can |
|
||||
| -------- | ------------------------------------------------------ |
|
||||
| `owner` | Everything, including billing |
|
||||
| `admin` | Invite people, create instances, grant instance access |
|
||||
| `member` | Read what the account holds |
|
||||
|
||||
Reading is open to any signed-in member. Every mutation except changing your own
|
||||
password requires `owner` or `admin`. Billing is owner-only.
|
||||
|
||||
The three words are the same as the control plane's roles, on purpose — but they
|
||||
are separate things. Your account role governs the portal; your role *inside* an
|
||||
The three words are the same as the control plane's roles, on purpose but they
|
||||
are separate things. Your account role governs the portal; your role _inside_ an
|
||||
instance governs that instance.
|
||||
|
||||
## Signing up
|
||||
@@ -37,7 +37,7 @@ you later create or link one.
|
||||
|
||||
:::info Verify before you can sign in
|
||||
An unverified account gets a distinct "check your email" message rather than a
|
||||
generic authentication failure — the address is already known to be yours, so
|
||||
generic authentication failure the address is already known to be yours, so
|
||||
there is nothing to protect by being vague.
|
||||
:::
|
||||
|
||||
@@ -45,7 +45,7 @@ Verification links are valid for **24 hours**. The token is 32 random bytes and
|
||||
only its SHA-256 hash is stored, so a leaked database yields no working links.
|
||||
|
||||
If the verification email cannot be sent, the signup is rolled back rather than
|
||||
left stranded — retry rather than assuming a half-created account is in the way.
|
||||
left stranded retry rather than assuming a half-created account is in the way.
|
||||
|
||||
## Signing in
|
||||
|
||||
@@ -54,11 +54,11 @@ signing in to HQ does not sign you in to an instance, and vice versa.
|
||||
|
||||
## What comes next
|
||||
|
||||
| You want | Go to |
|
||||
| --- | --- |
|
||||
| A Vantage instance we run | [Cloud instances](./cloud-instances.md) |
|
||||
| To license an install you run | [Self-hosted instances](./self-hosted-instances.md) |
|
||||
| To add colleagues | [People and roles](./people-and-roles.md) |
|
||||
| You want | Go to |
|
||||
| ------------------------------ | ------------------------------------------------------------- |
|
||||
| A Vantage instance we run | [Cloud instances](./cloud-instances.md) |
|
||||
| To license an install you run | [Self-hosted instances](./self-hosted-instances.md) |
|
||||
| To add colleagues | [People and roles](./people-and-roles.md) |
|
||||
| To understand tiers and limits | [Licensing and entitlements](./licensing-and-entitlements.md) |
|
||||
|
||||
## The portal layout
|
||||
|
||||
@@ -52,8 +52,8 @@ flowchart LR
|
||||
```
|
||||
|
||||
The webhook is the **only** issuing path for paid plans. It is signature
|
||||
verified, processed exactly once, and resolved from the subscription's *current*
|
||||
line items — so a webhook that arrives out of order still produces the right
|
||||
verified, processed exactly once, and resolved from the subscription's _current_
|
||||
line items so a webhook that arrives out of order still produces the right
|
||||
answer rather than replaying a stale state.
|
||||
|
||||
A licence is signed from **granted** only. A checkout you abandon changes
|
||||
@@ -65,7 +65,7 @@ Cancelling, or a payment going past due, takes **no immediate licence action**.
|
||||
Your licence runs to its grace-padded expiry and then lapses normally. There is
|
||||
no mid-term cut-off.
|
||||
|
||||
For a cloud Free instance, lapsing eventually leads to deletion — see
|
||||
For a cloud Free instance, lapsing eventually leads to deletion see
|
||||
[Free tier](./free-tier.md). Paid instances are not reaped.
|
||||
|
||||
## Renewals
|
||||
|
||||
@@ -15,7 +15,7 @@ A cloud instance is a Vantage control plane we run for you, reachable at
|
||||
|
||||
The instance is provisioned with you as its owner, and a Free licence is issued
|
||||
immediately. The owner user inside it gets your HQ password hash **copied**, not
|
||||
shared — see [People and roles](./people-and-roles.md).
|
||||
shared see [People and roles](./people-and-roles.md).
|
||||
|
||||
### Slugs
|
||||
|
||||
@@ -24,7 +24,7 @@ reserved. Pick something you can say on a phone call.
|
||||
|
||||
:::warning One Free instance per account, per deployment
|
||||
Creating a second Free cloud instance is refused. If you want another, it needs
|
||||
a paid plan — or free up the first.
|
||||
a paid plan or free up the first.
|
||||
:::
|
||||
|
||||
## Using it
|
||||
@@ -41,9 +41,9 @@ affect anyone signing in or any agent syncing.
|
||||
|
||||
Each instance on Overview is one record. Closed, it is a row. Open, it shows:
|
||||
|
||||
- **Licence contents** — tier, server allowance, features, expiry.
|
||||
- **Members** — who has access and with what instance role.
|
||||
- **Actions** — grant access, change configuration, renew.
|
||||
- **Licence contents** tier, server allowance, features, expiry.
|
||||
- **Members** who has access and with what instance role.
|
||||
- **Actions** grant access, change configuration, renew.
|
||||
|
||||
## Members
|
||||
|
||||
@@ -53,14 +53,14 @@ Granting access writes a real user into the instance. Covered fully in
|
||||
## Changing what it can do
|
||||
|
||||
Server allowance and per-instance features (browser console, single sign-on) are
|
||||
part of the instance's **entitlement**. Changing it goes through billing — see
|
||||
part of the instance's **entitlement**. Changing it goes through billing see
|
||||
[Licensing and entitlements](./licensing-and-entitlements.md) and
|
||||
[Billing](./billing.md).
|
||||
|
||||
## Renaming
|
||||
|
||||
The display name is free to change. The slug is the hostname and is not
|
||||
casually changed — ask support if you need it.
|
||||
casually changed ask support if you need it.
|
||||
|
||||
## What happens if the licence lapses
|
||||
|
||||
@@ -73,6 +73,6 @@ runs to its grace-padded expiry and then lapses.
|
||||
|
||||
## Deleting
|
||||
|
||||
Ask support. Deletion is performed by the control plane, not by HQ — the control
|
||||
Ask support. Deletion is performed by the control plane, not by HQ the control
|
||||
plane is the only service that knows which collections carry the instance ID,
|
||||
and duplicating that list into HQ would be a list that drifts.
|
||||
|
||||
@@ -4,18 +4,18 @@ title: Free tier
|
||||
sidebar_label: Free tier
|
||||
---
|
||||
|
||||
Free is a real tier in both deployments — not a trial that turns into nothing.
|
||||
Free is a real tier in both deployments not a trial that turns into nothing.
|
||||
|
||||
## What you get
|
||||
|
||||
| | Free |
|
||||
| --- | --- |
|
||||
| Servers | 3 |
|
||||
| Monitors | 3 |
|
||||
| Secret groups | 1 |
|
||||
| Notification channels | 1 |
|
||||
| Audit retention | 30 days |
|
||||
| Support | Community |
|
||||
| | Free |
|
||||
| --------------------- | --------- |
|
||||
| Servers | 3 |
|
||||
| Monitors | 3 |
|
||||
| Secret groups | 1 |
|
||||
| Notification channels | 1 |
|
||||
| Audit retention | 30 days |
|
||||
| Support | Community |
|
||||
|
||||
Browser console and single sign-on are not included; they are per-instance
|
||||
features on a paid plan.
|
||||
@@ -23,7 +23,7 @@ features on a paid plan.
|
||||
## One per account, per deployment
|
||||
|
||||
The limit is enforced per account **and** deployment. A Free cloud instance does
|
||||
not prevent a Free self-hosted one — they are separate slots.
|
||||
not prevent a Free self-hosted one they are separate slots.
|
||||
|
||||
## Free is outside Paddle
|
||||
|
||||
@@ -49,8 +49,7 @@ cloud instance can lapse and eventually be deleted without anyone noticing.
|
||||
## What happens when it lapses
|
||||
|
||||
**Self-hosted:** the instance goes into degraded mode after the grace period and
|
||||
stays that way. Nothing is deleted, ever — the reaper is disabled by default on
|
||||
a self-hosted install and must stay that way.
|
||||
stays that way. Nothing is deleted, ever.
|
||||
|
||||
**Cloud:** the instance goes into degraded mode, and after a further period the
|
||||
instance **and all its data are deleted**. Warning emails are sent first, naming
|
||||
@@ -69,7 +68,7 @@ instance ID.
|
||||
## Moving off Free
|
||||
|
||||
Change the instance's configuration to a paid tier and check out. Your data
|
||||
stays where it is — a tier change reissues a licence, it does not rebuild
|
||||
stays where it is a tier change reissues a licence, it does not rebuild
|
||||
anything.
|
||||
|
||||
## Relinks
|
||||
|
||||
@@ -10,30 +10,30 @@ A **licence** is a signed statement of what one instance may do. An
|
||||
## Tiers
|
||||
|
||||
Three tiers, in both deployments. The allowances are identical across cloud and
|
||||
self-hosted — what differs is the term on offer, not what you get.
|
||||
self-hosted what differs is the term on offer, not what you get.
|
||||
|
||||
| | Free | Professional | Enterprise |
|
||||
| --- | --- | --- | --- |
|
||||
| Servers (base) | 3 | 3 | 10 |
|
||||
| Monitors | 3 | unlimited | unlimited |
|
||||
| Secret groups | 1 | unlimited | unlimited |
|
||||
| Notification channels | 1 | unlimited | unlimited |
|
||||
| Audit retention | 30 days | 365 days | unlimited |
|
||||
| Support | Community | Email, 24×5 | Email and phone, 24×7 |
|
||||
| | Free | Professional | Enterprise |
|
||||
| --------------------- | --------- | ------------ | --------------------- |
|
||||
| Servers (base) | 3 | 3 | 10 |
|
||||
| Monitors | 3 | unlimited | unlimited |
|
||||
| Secret groups | 1 | unlimited | unlimited |
|
||||
| Notification channels | 1 | unlimited | unlimited |
|
||||
| Audit retention | 30 days | 365 days | unlimited |
|
||||
| Support | Community | Email, 24×5 | Email and phone, 24×7 |
|
||||
|
||||
The server count is **metered**: the base allowance comes with the tier, and you
|
||||
buy additional servers on top. That is why Professional shows a real number
|
||||
rather than "unlimited" — the number you actually have is the one in your
|
||||
rather than "unlimited" the number you actually have is the one in your
|
||||
entitlement.
|
||||
|
||||
## Features
|
||||
|
||||
Two are per-instance toggles rather than tier bundles:
|
||||
|
||||
| Feature | What it enables |
|
||||
| --- | --- |
|
||||
| `console` | The [browser console](../vantage/browser-console.md) |
|
||||
| `oidc` | Per-instance [single sign-on](../vantage/settings.md#single-sign-on-oidc) |
|
||||
| Feature | What it enables |
|
||||
| --------- | ------------------------------------------------------------------------- |
|
||||
| `console` | The [browser console](../vantage/browser-console.md) |
|
||||
| `oidc` | Per-instance [single sign-on](../vantage/settings.md#single-sign-on-oidc) |
|
||||
|
||||
No tier includes them by default; you enable them on the instances that need
|
||||
them.
|
||||
@@ -42,9 +42,9 @@ them.
|
||||
|
||||
Each instance has one entitlement row holding two configurations:
|
||||
|
||||
| | Meaning |
|
||||
| --- | --- |
|
||||
| **Desired** | What you last asked for |
|
||||
| | Meaning |
|
||||
| ----------- | ---------------------------- |
|
||||
| **Desired** | What you last asked for |
|
||||
| **Granted** | What a payment has confirmed |
|
||||
|
||||
Checkout is built from **desired**. A licence is only ever signed from
|
||||
@@ -80,7 +80,7 @@ signing happens only in HQ.
|
||||
Expiry is padded with a grace period. Past that, the instance goes into degraded
|
||||
mode: it keeps running and keeps your data, but stops letting you do everything.
|
||||
|
||||
The way out is a current licence — renew or purchase, then paste it (self-hosted)
|
||||
The way out is a current licence renew or purchase, then paste it (self-hosted)
|
||||
or let it be written for you (cloud).
|
||||
|
||||
## Server limits in practice
|
||||
|
||||
@@ -11,11 +11,11 @@ each **instance**.
|
||||
|
||||
**People** lists everyone in the account.
|
||||
|
||||
| Role | Can |
|
||||
| --- | --- |
|
||||
| `owner` | Everything, including billing |
|
||||
| `admin` | Invite, create instances, grant instance access |
|
||||
| `member` | Read |
|
||||
| Role | Can |
|
||||
| -------- | ----------------------------------------------- |
|
||||
| `owner` | Everything, including billing |
|
||||
| `admin` | Invite, create instances, grant instance access |
|
||||
| `member` | Read |
|
||||
|
||||
Owners and admins invite; billing is owner-only.
|
||||
|
||||
@@ -53,7 +53,7 @@ flowchart LR
|
||||
```
|
||||
|
||||
The instance authenticates that user exactly as it authenticates anyone else,
|
||||
with **no runtime dependency on HQ**. Revoking deletes the row — the control
|
||||
with **no runtime dependency on HQ**. Revoking deletes the row the control
|
||||
plane has no disabled state, and a row that exists is a row that can sign in.
|
||||
|
||||
### Granting
|
||||
|
||||
@@ -52,7 +52,7 @@ Rebuilding the host produces a new instance UUID, and the old licence no longer
|
||||
matches. **Relink** moves the licence to the new UUID and reissues.
|
||||
|
||||
The number of relinks per term is capped, and the portal shows how many you have
|
||||
left. This is not meant to obstruct disaster recovery — if you have exhausted
|
||||
left. This is not meant to obstruct disaster recovery if you have exhausted
|
||||
them for a real reason, ask support.
|
||||
|
||||
## Installing the licence
|
||||
@@ -60,13 +60,13 @@ them for a real reason, ask support.
|
||||
Paste it at **Settings → Licence** in your install. The instance verifies the
|
||||
signature and checks that the UUID matches its own.
|
||||
|
||||
Pasting works even while the current licence is expired — that endpoint is
|
||||
Pasting works even while the current licence is expired that endpoint is
|
||||
exempt from the licence check, because it is the route out of degraded mode.
|
||||
|
||||
## Keeping it current
|
||||
|
||||
Your install does not fetch licences. When a licence is reissued — renewal,
|
||||
configuration change, relink — download the new one from HQ and paste it in.
|
||||
Your install does not fetch licences. When a licence is reissued renewal,
|
||||
configuration change, relink download the new one from HQ and paste it in.
|
||||
|
||||
:::warning Nothing reminds your install
|
||||
The control plane knows only what its licence says. Expiry emails come from HQ,
|
||||
|
||||
@@ -17,17 +17,17 @@ firewall holes.
|
||||
|
||||
## Where to start
|
||||
|
||||
| If you want to | Read |
|
||||
| --- | --- |
|
||||
| Understand what the pieces are | [What is Vantage](./getting-started/what-is-vantage.md) |
|
||||
| Run it on your own hardware | [Self-hosted install](./getting-started/self-hosted-install.md) |
|
||||
| Enrol your first machine | [Add your first server](./getting-started/first-server.md) |
|
||||
| Manage your account, licence or billing | [Vantage HQ](./hq/accounts-and-signup.md) |
|
||||
| Look something up | [Reference](./reference/environment-variables.md) |
|
||||
| If you want to | Read |
|
||||
| --------------------------------------- | --------------------------------------------------------------- |
|
||||
| Understand what the pieces are | [What is Vantage](./getting-started/what-is-vantage.md) |
|
||||
| Run it on your own hardware | [Self-hosted install](./getting-started/self-hosted-install.md) |
|
||||
| Enrol your first machine | [Add your first server](./getting-started/first-server.md) |
|
||||
| Manage your account, licence or billing | [Vantage HQ](./hq/accounts-and-signup.md) |
|
||||
| Look something up | [Reference](./reference/environment-variables.md) |
|
||||
|
||||
## The two products
|
||||
|
||||
**Vantage** is the control plane — the thing you sign in to in order to manage
|
||||
**Vantage** is the control plane the thing you sign in to in order to manage
|
||||
servers. It runs either on your own infrastructure or as a cloud instance we
|
||||
run for you.
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ Each server's detail page shows the version it reported at its last sync.
|
||||
|
||||
## Updating from the UI
|
||||
|
||||
**Servers → *a server* → Update agent** pushes `UpdateAgentCmd` with a target
|
||||
**Servers → _a server_ → Update agent** pushes `UpdateAgentCmd` with a target
|
||||
version. The agent then:
|
||||
|
||||
1. Downloads the binary for its platform from the release.
|
||||
@@ -38,7 +38,7 @@ irm https://vantage.example.com/update.ps1 | iex
|
||||
```
|
||||
|
||||
It does the same download, checksum and replace, then restarts the service. Use
|
||||
this when the control plane cannot push — for example, when the machine is
|
||||
this when the control plane cannot push for example, when the machine is
|
||||
reachable but its command stream is not.
|
||||
|
||||
## Rolling out across a fleet
|
||||
@@ -57,20 +57,20 @@ Do one, confirm it returns to `active`, then do the rest.
|
||||
|
||||
## Version compatibility
|
||||
|
||||
The gRPC API is versioned to tolerate an agent older than the control plane. The
|
||||
reverse — an agent newer than the control plane — is not a case anyone tests.
|
||||
The agent API is versioned to tolerate an agent older than the control plane. The
|
||||
reverse an agent newer than the control plane is not a case anyone tests.
|
||||
Upgrade the control plane first.
|
||||
|
||||
Agents report their version on every `SyncKeys`, so a fleet running mixed
|
||||
Agents report their version on every poll, so a fleet running mixed
|
||||
versions is visible in the server list rather than something you have to go
|
||||
looking for.
|
||||
|
||||
## If an update fails
|
||||
|
||||
| Symptom | Cause |
|
||||
| --- | --- |
|
||||
| "Checksum mismatch" | Interrupted download, or a proxy rewriting the body. Retry |
|
||||
| Downloads nothing | The machine cannot reach `gitea.hostxtra.co.uk` |
|
||||
| Symptom | Cause |
|
||||
| --------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| "Checksum mismatch" | Interrupted download, or a proxy rewriting the body. Retry |
|
||||
| Downloads nothing | The machine cannot reach `gitea.hostxtra.co.uk` |
|
||||
| Service will not start afterwards | Wrong architecture binary, or the file was replaced while a different service manager held it. Reinstall with the install one-liner |
|
||||
|
||||
Reinstalling is always safe: the config file is left alone, so the agent comes
|
||||
|
||||
@@ -9,12 +9,12 @@ either one restores to something unusable.
|
||||
|
||||
## What holds what
|
||||
|
||||
| Store | Contents | Back up |
|
||||
| --- | --- | --- |
|
||||
| MongoDB | Everything durable — servers, keys, assignments, workflows, runs, monitors, incidents, secrets, settings, audit | **Yes** |
|
||||
| Redis | Sessions only | No. Losing it signs everyone out and nothing else |
|
||||
| `./data` bind mount | Workflow run logs | Optional |
|
||||
| `KEY_ENCRYPTION_KEY` | Not stored anywhere by the app | **Yes, separately** |
|
||||
| Store | Contents | Back up |
|
||||
| -------------------- | ------------------------------------------------------------------------------------------------------------- | ------------------------------------------------- |
|
||||
| MongoDB | Everything durable servers, keys, assignments, workflows, runs, monitors, incidents, secrets, settings, audit | **Yes** |
|
||||
| Redis | Sessions only | No. Losing it signs everyone out and nothing else |
|
||||
| `./data` bind mount | Workflow run logs | Optional |
|
||||
| `KEY_ENCRYPTION_KEY` | Not stored anywhere by the app | **Yes, separately** |
|
||||
|
||||
:::danger The database alone is not a backup
|
||||
Private keys, vault secrets, OIDC client secrets and console credentials are
|
||||
@@ -50,7 +50,7 @@ nothing writes during the restore.
|
||||
cp /opt/vantage/.env /secure-location/vantage.env
|
||||
```
|
||||
|
||||
Treat it as a credential in its own right — it holds the encryption key.
|
||||
Treat it as a credential in its own right it holds the encryption key.
|
||||
|
||||
## Run logs
|
||||
|
||||
@@ -69,17 +69,17 @@ What it does **not** do is reconcile the world. After a restore:
|
||||
- Agents reconnect with their existing tokens, since the token hashes are in the
|
||||
database.
|
||||
- If the restore is older than an enrolment, that server's token hash is missing
|
||||
and the agent will fail to authenticate — re-enrol it.
|
||||
and the agent will fail to authenticate re-enrol it.
|
||||
- The next agent poll rewrites `authorized_keys` to match the restored desired
|
||||
state, which may remove keys added since the backup.
|
||||
|
||||
## A workable schedule
|
||||
|
||||
| What | When |
|
||||
| --- | --- |
|
||||
| MongoDB dump | Nightly, retained per your policy |
|
||||
| Environment file | On change, held in a password manager or secret store |
|
||||
| Restore rehearsal | Occasionally, into a throwaway host |
|
||||
| What | When |
|
||||
| ----------------- | ----------------------------------------------------- |
|
||||
| MongoDB dump | Nightly, retained per your policy |
|
||||
| Environment file | On change, held in a password manager or secret store |
|
||||
| Restore rehearsal | Occasionally, into a throwaway host |
|
||||
|
||||
The rehearsal is the part that gets skipped and the part that finds the
|
||||
problems.
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
---
|
||||
id: ci-cd
|
||||
title: CI/CD
|
||||
sidebar_label: CI/CD
|
||||
---
|
||||
|
||||
Two Gitea Actions workflows: one releases agents, one builds images.
|
||||
|
||||
## Agent releases
|
||||
|
||||
Triggered by an `agent/v*` tag.
|
||||
|
||||
```bash
|
||||
git tag agent/v1.0.0 && git push origin agent/v1.0.0
|
||||
```
|
||||
|
||||
Builds `linux/amd64`, `linux/arm64` and `windows/amd64`, writes `checksums.txt`
|
||||
and creates a Gitea release. A second job on Windows packages the WiX MSI.
|
||||
|
||||
The install and update scripts read the newest `agent/v*` release from the Gitea
|
||||
API, so tagging is what makes a new agent available to every install.
|
||||
|
||||
## Image builds
|
||||
|
||||
Triggered on every push to `main`. Builds and pushes seven images: `server`,
|
||||
`web`, `site`, `sitesvc`, `admin`, `adminsite` and `docsite`.
|
||||
|
||||
:::warning Despite the name, this workflow does not deploy
|
||||
There is no SSH step. Rolling images out is a manual step on the host:
|
||||
|
||||
```bash
|
||||
cd /opt/vantage && \
|
||||
docker compose -f docker-compose.yml -f docker-compose.site.yml pull && \
|
||||
docker compose -f docker-compose.yml -f docker-compose.site.yml up -d --remove-orphans
|
||||
```
|
||||
:::
|
||||
|
||||
### Each image rebuilds only when its own inputs change
|
||||
|
||||
A `git diff` against the previous head decides. That is why the checkout uses
|
||||
`fetch-depth: 0` — a shallow clone has one commit and nothing to diff against.
|
||||
|
||||
| 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 |
|
||||
|
||||
`shared/` fans out to all three Go images because each of their Dockerfiles
|
||||
copies it from a root context. **If a fourth service ever imports `shared/`, it
|
||||
must be added to that list or it will ship stale.**
|
||||
|
||||
Everything rebuilds when there is no trustworthy base commit to diff against: a
|
||||
manual `workflow_dispatch`, a new branch, or a force-push whose old head is
|
||||
gone. Changing the workflow file itself also rebuilds everything, since a build
|
||||
argument is baked into each image.
|
||||
|
||||
### The gap: repository variables
|
||||
|
||||
:::danger Editing a repository variable pushes no commit, so nothing rebuilds
|
||||
Values like `ADMIN_API_URL`, `HQ_URL`, `ADMIN_ENV`, `PADDLE_ENV`,
|
||||
`PADDLE_CLIENT_TOKEN`, `DOCS_URL` and `DOCS_BASE_URL` are baked into images at
|
||||
build time. After editing one, run the workflow manually — that is what
|
||||
`workflow_dispatch` is for.
|
||||
|
||||
The symptom is a frontend that keeps using the old value with no error anywhere,
|
||||
which is a long afternoon if you do not know about this.
|
||||
:::
|
||||
|
||||
The same applies to base images: a service nobody touches stops being rebuilt on
|
||||
newer base layers. A periodic manual run covers it.
|
||||
|
||||
## Secrets and variables
|
||||
|
||||
| Name | Type | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `RELEASE_TOKEN` | Secret | Gitea API token, `write:release` |
|
||||
| `REGISTRY_USER` / `REGISTRY_PASSWORD` | Secret | Registry push credentials |
|
||||
| `PADDLE_API_KEY` | Secret | Read by admin at runtime |
|
||||
| `PADDLE_WEBHOOK_SECRET` | Secret | Webhook signature verification |
|
||||
| `GITEA_HOST` / `DOCKER_HOST` | Variable | Hosts used in tags and URLs |
|
||||
| `HQ_URL` | Variable | Baked into `web`; empty on self-hosted |
|
||||
| `SITE_API_URL` / `SITE_CONTACT_EMAIL` | Variable | Baked into `site` |
|
||||
| `ADMIN_API_URL` | Variable | Baked into `adminsite` **and** `site` |
|
||||
| `ADMIN_ENV` | Variable | Environment badge in the portal |
|
||||
| `PADDLE_ENV` | Variable | Baked into `adminsite`, read by `admin`. Must match on both sides |
|
||||
| `PADDLE_CLIENT_TOKEN` | Variable | Browser Paddle token for checkout |
|
||||
| `DOCS_URL` / `DOCS_BASE_URL` | Variable | Baked into `docsite` |
|
||||
|
||||
Anything marked "browser-reachable" must be an origin a browser can actually
|
||||
resolve — not an internal service name. Get it wrong and every request fails at
|
||||
runtime with a not-connected panel rather than at build time.
|
||||
|
||||
## The documentation site
|
||||
|
||||
`docsite/` builds to static files and is served by nginx under `/docs` on the
|
||||
marketing host, routed by its own proxy location.
|
||||
|
||||
`DOCS_BASE_URL` has to agree with three things at once: that proxy location, the
|
||||
directory the runtime image serves from, and the value baked into the build. When
|
||||
they disagree the page loads and every stylesheet and script 404s.
|
||||
@@ -5,7 +5,12 @@ sidebar_label: Upgrading
|
||||
---
|
||||
|
||||
Upgrading the control plane is a pull and a recreate. Agents are versioned and
|
||||
upgraded separately — see [Agent updates](./agent-updates.md).
|
||||
upgraded separately see [Agent updates](./agent-updates.md).
|
||||
|
||||
:::info Cloud instances upgrade themselves
|
||||
This page is for self-hosted installs. If your instance is hosted by us, there
|
||||
is nothing here for you to do.
|
||||
:::
|
||||
|
||||
## Upgrade
|
||||
|
||||
@@ -25,7 +30,7 @@ renamed or removed.
|
||||
2. **Indexes** are ensured. Auth and settings index builders are fatal on
|
||||
failure; secret and workflow ones only warn.
|
||||
3. **Default steps** are reseeded from the image, overwriting the `default`
|
||||
library — which is why those steps are read-only.
|
||||
library which is why those steps are read-only.
|
||||
|
||||
Watch it:
|
||||
|
||||
@@ -52,28 +57,15 @@ is the reason the backup is not optional.
|
||||
The stack is not designed for it. `docker compose up -d` recreates the server
|
||||
container, which is a short interruption:
|
||||
|
||||
- Agents reconnect on their own — they retry, and the poll loop is idempotent.
|
||||
- Agents reconnect on their own they retry, and the poll loop is idempotent.
|
||||
- Workflow runs in progress lose their command stream. Steps already dispatched
|
||||
finish on the agent, but their results have nowhere to go. **Do not upgrade
|
||||
during a run.**
|
||||
- Sessions survive, because they live in Redis rather than in the server.
|
||||
|
||||
## Upgrading the hosted deployment
|
||||
|
||||
Both Compose files, together:
|
||||
|
||||
```bash
|
||||
cd /opt/vantage
|
||||
docker compose -f docker-compose.yml -f docker-compose.site.yml pull
|
||||
docker compose -f docker-compose.yml -f docker-compose.site.yml up -d --remove-orphans
|
||||
```
|
||||
|
||||
CI builds and pushes images but does **not** deploy them; rolling out is this
|
||||
manual step. See [CI/CD](./ci-cd.md).
|
||||
|
||||
## After upgrading
|
||||
|
||||
- Confirm every service is `running`.
|
||||
- Confirm servers return to `active` within a couple of poll intervals.
|
||||
- Open a page that touches encryption — a secret group — to confirm
|
||||
- Open a page that touches encryption a secret group to confirm
|
||||
`KEY_ENCRYPTION_KEY` came through.
|
||||
|
||||
@@ -8,10 +8,10 @@ The agent reads no environment variables. Everything is in one YAML file.
|
||||
|
||||
## Location
|
||||
|
||||
| Platform | Path |
|
||||
| --- | --- |
|
||||
| Linux | `/etc/vantage/config.yaml` |
|
||||
| Windows | `%ProgramData%\vantage\config.yaml` |
|
||||
| Platform | Path |
|
||||
| -------- | ----------------------------------- |
|
||||
| Linux | `/etc/vantage/config.yaml` |
|
||||
| Windows | `%ProgramData%\vantage\config.yaml` |
|
||||
|
||||
Directory `0700`, file `0600`. The install script sets both.
|
||||
|
||||
@@ -26,17 +26,17 @@ poll_interval: 30s
|
||||
tls: true
|
||||
```
|
||||
|
||||
| Field | Meaning |
|
||||
| --- | --- |
|
||||
| `server_url` | `host:port` of the gRPC endpoint. Comes from the server's `GRPC_HOST` |
|
||||
| `server_id` | The identity issued when the enrolment was created |
|
||||
| `pre_reg_token` | Single-use, one hour. Cleared once registration succeeds |
|
||||
| `agent_token` | The permanent credential, written by the agent itself |
|
||||
| `poll_interval` | How often `SyncKeys` runs. Default `30s` |
|
||||
| `tls` | Whether to use TLS. Leave `true` |
|
||||
| Field | Meaning |
|
||||
| --------------- | --------------------------------------------------------------------- |
|
||||
| `server_url` | `host:port` of the gRPC endpoint. Comes from the server's `GRPC_HOST` |
|
||||
| `server_id` | The identity issued when the enrolment was created |
|
||||
| `pre_reg_token` | Single-use, one hour. Cleared once registration succeeds |
|
||||
| `agent_token` | The permanent credential, written by the agent itself |
|
||||
| `poll_interval` | How often the agent polls for key state. Default `30s` |
|
||||
| `tls` | Whether to use TLS. Leave `true` |
|
||||
|
||||
:::danger This file is the credential
|
||||
`agent_token` is plaintext here and nowhere else — the control plane holds only
|
||||
`agent_token` is plaintext here and nowhere else the control plane holds only
|
||||
its SHA-256. Anyone who can read this file can act as this agent. That is why
|
||||
it is `0600` and the directory is `0700`.
|
||||
:::
|
||||
@@ -45,21 +45,21 @@ it is `0600` and the directory is `0700`.
|
||||
|
||||
```
|
||||
1. Load the config
|
||||
2. pre_reg_token present → Register() → save agent_token,
|
||||
2. pre_reg_token present → register → save agent_token,
|
||||
clear pre_reg_token, reconnect
|
||||
3. Start goroutines: command stream · hourly update check ·
|
||||
inventory · monitors
|
||||
4. Enter the SyncKeys poll loop
|
||||
3. Start: command stream · hourly update check · inventory · monitors
|
||||
4. Enter the key poll loop
|
||||
```
|
||||
|
||||
## The poll loop
|
||||
|
||||
```
|
||||
1. SyncKeys(server_id, agent_token, agent_version)
|
||||
2. Non-Linux hosts stop here — Windows agents register and heartbeat only
|
||||
1. Ask the control plane for the desired key state, reporting the
|
||||
agent version
|
||||
2. Non-Linux hosts stop here Windows agents register and heartbeat only
|
||||
3. Diff the desired keys against /root/.ssh/authorized_keys;
|
||||
unchanged → write nothing
|
||||
4. Changed → write a temp file, os.Rename() over the real one, chmod 0600
|
||||
4. Changed → write a temp file, rename it over the real one, chmod 0600
|
||||
```
|
||||
|
||||
## Service management
|
||||
@@ -107,5 +107,5 @@ rm -rf /etc/vantage
|
||||
systemctl daemon-reload
|
||||
```
|
||||
|
||||
Keys already written to `authorized_keys` remain on disk — the agent is no
|
||||
Keys already written to `authorized_keys` remain on disk the agent is no
|
||||
longer running to remove them. Revoke first if that matters.
|
||||
|
||||
@@ -9,20 +9,20 @@ it is absent.
|
||||
|
||||
## Server
|
||||
|
||||
| Name | Required | Default | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| `GRPC_HOST` | **yes** | — | The `host:port` agents dial. Boot fails without it. There is deliberately no fallback to the web host: that would hand every agent a port that does not speak gRPC |
|
||||
| `MONGO_URI` | no | `mongodb://localhost:27017` | The database name is taken from the URI path, falling back to `vantage`. There is no separate `MONGO_DB` |
|
||||
| `REDIS_ADDR` | no | `localhost:6379` | Sessions only |
|
||||
| `KEY_ENCRYPTION_KEY` | yes in practice | — | 64 hex characters (32 bytes) for AES-256-GCM. Required for private keys, vault secrets, OIDC client secrets and console credentials |
|
||||
| `GITEA_HOST` | yes | `gitea.example.com` | Used to build the install scripts and agent download URLs. The default is a placeholder that will not resolve |
|
||||
| `GUACD_ADDR` | no | `guacd:4822` | The [browser console](../vantage/browser-console.md) daemon |
|
||||
| `APP_ROOT_LABEL` | no | `vantage` | The app root label for the host and session organisation guard |
|
||||
| `VANTAGE_WORKFLOW_LOG_DIR` | no | — | Where workflow run logs are written |
|
||||
| `VANTAGE_DEFAULT_STEPS_DIR` | no | baked into the image | Where the seeded step library is read from |
|
||||
| `VANTAGE_DEPLOYMENT` | no | self-hosted | Set to `cloud` on a cloud instance. Governs whether a licence may be pasted |
|
||||
| `VANTAGE_LICENSE` | no | — | A licence blob, used **only** when the instance has no stored one |
|
||||
| `FREE_INSTANCE_REAP_AFTER` | no | empty | How long past a Free licence's expiry before the instance and all its data are deleted |
|
||||
| Name | Required | Default | Notes |
|
||||
| -------------------------- | --------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `GRPC_HOST` | **yes** | | The `host:port` agents dial. Boot fails without it. There is deliberately no fallback to the web host: that would hand every agent a port that does not speak gRPC |
|
||||
| `MONGO_URI` | no | `mongodb://localhost:27017` | The database name is taken from the URI path, falling back to `vantage`. There is no separate `MONGO_DB` |
|
||||
| `REDIS_ADDR` | no | `localhost:6379` | Sessions, and the bus that routes agent commands between server replicas. Every replica must point at the **same** Redis |
|
||||
| `REDIS_USERNAME` | no | | Redis 6+ ACL user. Leave empty against a legacy `requirepass` instance, which authenticates with the password alone |
|
||||
| `REDIS_PASSWORD` | no | | Leave empty for an unauthenticated Redis. Both of these exist so an install can use a managed Redis rather than the bundled one |
|
||||
| `KEY_ENCRYPTION_KEY` | yes in practice | | 64 hex characters (32 bytes) for AES-256-GCM. Required for private keys, vault secrets, OIDC client secrets and console credentials |
|
||||
| `GITEA_HOST` | yes | `gitea.example.com` | Used to build the install scripts and agent download URLs. The default is a placeholder that will not resolve |
|
||||
| `GUACD_ADDR` | no | `guacd:4822` | The [browser console](../vantage/browser-console.md) daemon |
|
||||
| `PROXY_ADVERTISE_HOST` | no | `server` | The hostname **guacd** uses to reach the control plane's console relay. Wrong here and every console session fails at connect with guacd unable to resolve the relay |
|
||||
| `PROXY_LISTEN_HOST` | no | `0.0.0.0` | Interface the ephemeral relay listeners bind. Narrow it only if guacd shares a known interface |
|
||||
| `POD_IP` | no | | Kubernetes only, set by the Helm chart from the downward API. Overrides `PROXY_ADVERTISE_HOST`, because a console relay belongs to one replica and a Service address names all of them |
|
||||
| `APP_ROOT_LABEL` | no | `vantage` | The app root label for the host and session organisation guard |
|
||||
|
||||
:::danger `KEY_ENCRYPTION_KEY` has no recovery path
|
||||
It encrypts SSH private keys, vault secrets, OIDC client secrets and console
|
||||
@@ -30,11 +30,6 @@ credentials. Lose it and all of them are unreadable. Back it up separately from
|
||||
the database it protects.
|
||||
:::
|
||||
|
||||
:::warning `FREE_INSTANCE_REAP_AFTER` empty means disabled, and empty is the default
|
||||
That is the correct value for a self-hosted install, which must never reap. It
|
||||
is set only on the hosted deployment.
|
||||
:::
|
||||
|
||||
:::info A wrong `APP_ROOT_LABEL` fails quietly
|
||||
It does not error. It simply stops matching, and the host/session guard stops
|
||||
protecting anything.
|
||||
@@ -50,57 +45,3 @@ remap with Docker's port publishing instead.
|
||||
|
||||
The agent reads no environment variables. Everything is in its
|
||||
[config file](./agent-config.md).
|
||||
|
||||
## Hosted-only services
|
||||
|
||||
These run only on the hosted deployment, from
|
||||
`deploy/docker-compose.site.yml`. A self-hosted install runs none of them.
|
||||
|
||||
### sitesvc — the public contact form
|
||||
|
||||
| Name | Required | Notes |
|
||||
| --- | --- | --- |
|
||||
| `MONGO_URI` | yes | Must point at the control plane's database. Refuses to start against a database that has not run the instances migration. The database name is read from the URI path; a URI without one is refused rather than defaulted |
|
||||
| `SMTP_HOST`, `SMTP_FROM` | yes | Without them the contact form answers `503` rather than silently dropping messages |
|
||||
| `SMTP_TO` | no | Defaults to `support@hostxtra.co.uk` |
|
||||
| `SMTP_PORT` | no | Defaults to `587`; `465` uses implicit TLS |
|
||||
| `SMTP_USERNAME`, `SMTP_PASSWORD` | no | Auth is skipped when the username is empty |
|
||||
| `SITE_ORIGIN` | yes in practice | Comma-separated allowed origins. Unset refuses every cross-origin browser request |
|
||||
| `TRUST_PROXY` | no | Only `true` behind a proxy that overwrites `X-Forwarded-For`, or clients spoof past the rate limiter |
|
||||
|
||||
### admin — the licensing authority
|
||||
|
||||
| Name | Required | Notes |
|
||||
| --- | --- | --- |
|
||||
| `ADMIN_MONGO_URI` | yes | Admin's own database |
|
||||
| `CONTROL_MONGO_URI` | yes | The control plane's database, for licence injection and user projection |
|
||||
| `LICENSE_SIGNING_KEY` | yes | **The only service that ever holds this.** Never add it to the server, and never add admin to the self-hosted Compose file |
|
||||
| `REDIS_ADDR`, `REDIS_USERNAME`, `REDIS_PASSWORD` | yes | Admin uses an external Redis; the base Compose file hardcodes `redis:6379` for the server, so these reach admin only |
|
||||
| `ADMIN_ORIGIN` | yes | Comma-separated browser origins that call admin. See the warning below |
|
||||
| `PADDLE_API_KEY` | yes | Boot-required |
|
||||
| `PADDLE_WEBHOOK_SECRET` | yes | Boot-required. An unverified webhook endpoint is one anyone can issue licences through |
|
||||
| `PADDLE_ENV` | yes | `sandbox` or `production`. Selects which catalogue price IDs are served, and must match the value baked into the portal build |
|
||||
| `SMTP_*` | yes in practice | Account, licence and billing email |
|
||||
| `PUBLIC_URL`, `APP_LOGIN_URL` | yes in practice | Used in links inside emails |
|
||||
| `FREE_INSTANCE_REAP_AFTER` | yes | Must match the control plane's value. Admin only uses it to name the date in warning emails; the control plane performs the delete |
|
||||
|
||||
:::warning A missing `ADMIN_ORIGIN` entry produces no error anywhere
|
||||
The CORS layer simply omits the allow-origin header and still answers the
|
||||
preflight with `204`. The browser blocks the request and **admin logs nothing at
|
||||
all**. The symptom is a preflight failure on an endpoint that works perfectly
|
||||
under `curl`.
|
||||
:::
|
||||
|
||||
## Build-time variables
|
||||
|
||||
These are baked into frontend images at build time, not read at runtime.
|
||||
Changing one requires rebuilding that image — and because editing a CI variable
|
||||
pushes no commit, nothing rebuilds on its own. See [CI/CD](../operations/ci-cd.md).
|
||||
|
||||
| Name | Baked into |
|
||||
| --- | --- |
|
||||
| `HQ_URL` | `web` |
|
||||
| `SITE_API_URL`, `SITE_CONTACT_EMAIL` | `site` |
|
||||
| `ADMIN_API_URL` | `adminsite` **and** `site` |
|
||||
| `ADMIN_ENV`, `PADDLE_CLIENT_TOKEN`, `PADDLE_ENV` | `adminsite` |
|
||||
| `DOCS_URL`, `DOCS_BASE_URL` | `docsite` |
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
---
|
||||
id: grpc-api
|
||||
title: gRPC API
|
||||
sidebar_label: gRPC API
|
||||
---
|
||||
|
||||
The agent-facing API, on port `9090`, over TLS. Agents dial **out** to it;
|
||||
nothing dials an agent.
|
||||
|
||||
## Service
|
||||
|
||||
```protobuf
|
||||
service Vantage {
|
||||
rpc Register(RegisterRequest) returns (RegisterResponse);
|
||||
rpc SyncKeys(SyncRequest) returns (SyncResponse);
|
||||
rpc UploadGeneratedKey(UploadKeyRequest) returns (UploadKeyResponse);
|
||||
rpc ReportUpdates(ReportUpdatesRequest) returns (ReportUpdatesResponse);
|
||||
rpc ReportInventory(InventoryReport) returns (InventoryReportResponse);
|
||||
rpc SyncMonitors(SyncMonitorsRequest) returns (SyncMonitorsResponse);
|
||||
rpc ReportChecks(ReportChecksRequest) returns (ReportChecksResponse);
|
||||
rpc CommandStream(stream AgentMessage) returns (stream ServerCommand);
|
||||
}
|
||||
```
|
||||
|
||||
The full message definitions live in `proto/vantage/v1/vantage.proto`.
|
||||
|
||||
## Authentication
|
||||
|
||||
`Register` presents the single-use, one-hour pre-registration token and receives
|
||||
a permanent agent token. Every other call presents that agent token.
|
||||
|
||||
The control plane stores only the SHA-256 of the agent token. The plaintext
|
||||
exists in the agent's `0600` config file and nowhere else, so a token cannot be
|
||||
read back out of the control plane.
|
||||
|
||||
## Unary calls
|
||||
|
||||
| RPC | Direction | Frequency |
|
||||
| --- | --- | --- |
|
||||
| `Register` | once, at enrolment | once |
|
||||
| `SyncKeys` | agent asks for desired key state | every 30s (`poll_interval`) |
|
||||
| `UploadGeneratedKey` | agent returns a keypair it generated | on demand |
|
||||
| `ReportUpdates` | pending OS package updates | hourly |
|
||||
| `ReportInventory` | CPU, memory, disk, kernel | metrics 30s, static 15 min |
|
||||
| `SyncMonitors` | agent asks which checks it should run | periodically |
|
||||
| `ReportChecks` | agent returns check results | after each check cycle |
|
||||
|
||||
`SyncKeys` doubles as the heartbeat. A server that stops calling it is marked
|
||||
`offline` by a sweep that runs every two minutes.
|
||||
|
||||
## The command stream
|
||||
|
||||
`CommandStream` is the only streaming RPC and the only push path.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant A as Agent
|
||||
participant S as Server
|
||||
A->>S: AgentReady (authenticate)
|
||||
S-->>A: ServerCommand (RunStepCmd)
|
||||
A-->>S: StepOutputChunk (repeated)
|
||||
A-->>S: StepResult
|
||||
S-->>A: ServerCommand (CleanupWorkspaceCmd)
|
||||
A-->>S: CommandResult
|
||||
```
|
||||
|
||||
The agent authenticates once with `AgentReady`, then the server pushes commands
|
||||
and the agent replies with `CommandResult`, `StepResult` or `StepOutputChunk`.
|
||||
|
||||
### Commands
|
||||
|
||||
| Command | Effect |
|
||||
| --- | --- |
|
||||
| `GenerateKeyCmd` | Generate an SSH keypair on the machine |
|
||||
| `DeleteKeyCmd` | Remove a generated key by label |
|
||||
| `UpdateAgentCmd` | Download and replace the agent binary with a target version |
|
||||
| `ApplyUpdatesCmd` | Apply pending OS package updates |
|
||||
| `RunStepCmd` | Execute one workflow step |
|
||||
| `CleanupWorkspaceCmd` | Recursively remove the run's working directory |
|
||||
|
||||
## Why poll for keys and push for commands
|
||||
|
||||
A 30-second delay on a key change is fine, and polling needs no reconnection
|
||||
logic to survive a dropped link. Clicking **Run** on a workflow and waiting up
|
||||
to 30 seconds is not fine. Hence one of each.
|
||||
|
||||
## Network requirements
|
||||
|
||||
Every managed machine needs outbound TCP to `GRPC_HOST`. Nothing needs to reach
|
||||
the machine. Watch for middleboxes that permit the short `Register` call but
|
||||
drop the long-lived command stream — that failure looks like a server that
|
||||
registers and then goes offline.
|
||||
@@ -6,26 +6,14 @@ sidebar_label: Ports and networking
|
||||
|
||||
## Control plane ports
|
||||
|
||||
| Port | Service | Who connects | Expose publicly |
|
||||
| --- | --- | --- | --- |
|
||||
| `3000` | web | Browsers, via your reverse proxy | Yes, behind TLS |
|
||||
| `8080` | server REST | The web app | No |
|
||||
| `9090` | server gRPC | Agents | **Yes** |
|
||||
| `4822` | guacd | The server | No |
|
||||
| `27017` | MongoDB | The server | No |
|
||||
| `6379` | Redis | The server | No |
|
||||
|
||||
## Hosted-only ports
|
||||
|
||||
Only on the hosted deployment, from `deploy/docker-compose.site.yml`.
|
||||
|
||||
| Port | Service |
|
||||
| --- | --- |
|
||||
| `3003` | marketing site |
|
||||
| `3004` | HQ portal |
|
||||
| `3005` | this documentation site |
|
||||
| `8082` | sitesvc |
|
||||
| `8083` | admin |
|
||||
| Port | Service | Who connects | Expose publicly |
|
||||
| ------- | ----------- | -------------------------------- | --------------- |
|
||||
| `3000` | web | Browsers, via your reverse proxy | Yes, behind TLS |
|
||||
| `8080` | server REST | The web app | No |
|
||||
| `9090` | server gRPC | Agents | **Yes** |
|
||||
| `4822` | guacd | The server | No |
|
||||
| `27017` | MongoDB | The server | No |
|
||||
| `6379` | Redis | The server | No |
|
||||
|
||||
## Direction of travel
|
||||
|
||||
@@ -36,7 +24,8 @@ flowchart LR
|
||||
W --> S["server :8080"]
|
||||
A["Agent on a managed server"] -->|"gRPC/TLS :9090, outbound"| S
|
||||
S --> G["guacd :4822"]
|
||||
G -->|"SSH / RDP / VNC"| T["Target machine"]
|
||||
G -->|"relayed over the :9090 stream"| A
|
||||
A -->|"SSH / RDP / VNC, loopback"| T["Target machine (same host as agent)"]
|
||||
```
|
||||
|
||||
Two things are worth reading off that diagram.
|
||||
@@ -45,9 +34,13 @@ Two things are worth reading off that diagram.
|
||||
NAT is not an obstacle. The only requirement is that the machine can reach
|
||||
`GRPC_HOST`.
|
||||
|
||||
**The console does not use the agent.** guacd connects directly to the target on
|
||||
the protocol port. A machine reachable only by its agent — behind NAT, on a
|
||||
private subnet — cannot be consoled, even though every other feature works.
|
||||
**The console rides the agent's connection too.** guacd never dials the target
|
||||
directly; the server pushes a command down the agent's existing outbound gRPC
|
||||
stream on `9090`, and the agent relays the protocol traffic from its own
|
||||
loopback. No route from the control plane to the target's address is needed,
|
||||
and no new inbound port opens on the target — the same connection that carries
|
||||
key sync carries console traffic. This is what makes the console work for a
|
||||
machine behind NAT on a private subnet, as long as its agent is online.
|
||||
|
||||
## What to open
|
||||
|
||||
@@ -61,7 +54,9 @@ private subnet — cannot be consoled, even though every other feature works.
|
||||
- `gitea.hostxtra.co.uk`, for agent releases and version checks.
|
||||
- Anything a server-run [monitor](../vantage/monitors.md) checks.
|
||||
- SMTP, if you use an SMTP notification channel.
|
||||
- Protocol ports on machines you intend to console.
|
||||
|
||||
No route to the machines you intend to console is needed — that traffic rides
|
||||
the agent's existing outbound `9090` connection instead.
|
||||
|
||||
### Outbound from a managed machine
|
||||
|
||||
@@ -75,7 +70,7 @@ Terminate TLS for the web UI at your reverse proxy.
|
||||
|
||||
gRPC on `9090` is reached directly by agents with `tls: true`, so that port needs
|
||||
a valid certificate for the name in `GRPC_HOST`. If you proxy it, the proxy must
|
||||
speak HTTP/2 end to end — many do not by default, and the symptom is agents that
|
||||
speak HTTP/2 end to end many do not by default, and the symptom is agents that
|
||||
register and then fail to hold the command stream.
|
||||
|
||||
## Reverse proxy notes
|
||||
@@ -91,7 +86,7 @@ register and then fail to hold the command stream.
|
||||
|
||||
The control plane needs outbound access to fetch agent releases. Managed
|
||||
machines need it too, unless you distribute the agent binary yourself and write
|
||||
the config by hand — the install script's only job is to do those two things.
|
||||
the config by hand the install script's only job is to do those two things.
|
||||
|
||||
Licence verification is entirely local, so a licensed install works with no
|
||||
outbound access to HQ at all.
|
||||
|
||||
@@ -12,7 +12,7 @@ has no privileges it does not.
|
||||
Most endpoints take a session: an opaque 32-byte token in the `km_session`
|
||||
cookie, with the body in Redis for 24 hours.
|
||||
|
||||
One endpoint takes a bearer token instead — the External Secrets Operator read
|
||||
One endpoint takes a bearer token instead the External Secrets Operator read
|
||||
path.
|
||||
|
||||
## Unauthenticated
|
||||
@@ -119,14 +119,14 @@ GET,PUT /org/oidc (owner|admin)
|
||||
|
||||
## Notable refusals
|
||||
|
||||
| Endpoint | Condition | Status |
|
||||
| --- | --- | --- |
|
||||
| `POST /license` | deployment is `cloud` | `409 cloud_managed` |
|
||||
| `PUT,DELETE /steps/:id` | the step's source is `default` | `409` |
|
||||
| `PUT /org/users/:id/role`, `DELETE /org/users/:id` | the user's auth source is `hq` | `409` |
|
||||
| Endpoint | Condition | Status |
|
||||
| -------------------------------------------------- | ------------------------------ | ------------------- |
|
||||
| `POST /license` | deployment is `cloud` | `409 cloud_managed` |
|
||||
| `PUT,DELETE /steps/:id` | the step's source is `default` | `409` |
|
||||
| `PUT /org/users/:id/role`, `DELETE /org/users/:id` | the user's auth source is `hq` | `409` |
|
||||
|
||||
`POST /license` is exempt from the licence check, so pasting a valid licence
|
||||
works while the current one is expired — that is the way out of degraded mode.
|
||||
works while the current one is expired that is the way out of degraded mode.
|
||||
|
||||
## Multi-tenancy
|
||||
|
||||
@@ -143,7 +143,7 @@ session and does not need that distinction.
|
||||
|
||||
## Admin API
|
||||
|
||||
The HQ service has its own API, its own database and its own session cookie
|
||||
(`admin_session`) on port `8083`. It is documented in the
|
||||
[Vantage HQ](../hq/accounts-and-signup.md) section rather than here; the two
|
||||
services share no session and no authentication.
|
||||
Vantage HQ is a separate hosted service with its own API and its own session.
|
||||
Its behaviour is described in the [Vantage HQ](../hq/accounts-and-signup.md)
|
||||
section rather than here; the two services share no session and no
|
||||
authentication.
|
||||
|
||||
@@ -8,7 +8,7 @@ Symptoms, in the order people hit them.
|
||||
|
||||
## The server will not start
|
||||
|
||||
**Exits immediately on boot.** Almost always a missing `GRPC_HOST` — the server
|
||||
**Exits immediately on boot.** Almost always a missing `GRPC_HOST` the server
|
||||
refuses to start rather than guess a value that would break every agent later.
|
||||
|
||||
**Fails during index creation.** The auth and settings index builders are fatal
|
||||
@@ -47,11 +47,11 @@ Work through it in this order:
|
||||
4. Was the token already used or expired? It is single-use and lives one hour —
|
||||
create a fresh enrolment rather than reusing the old command.
|
||||
|
||||
| Symptom | Cause |
|
||||
| --- | --- |
|
||||
| Symptom | Cause |
|
||||
| --------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
|
||||
| Registers, then goes `offline` within minutes | Something permits the short `Register` call but drops the long-lived stream. Usually a proxy or idle-timeout middlebox |
|
||||
| Stays `pending` forever | Registration never happened. Token spent, or the endpoint unreachable |
|
||||
| Flaps between `active` and `offline` | Intermittent path, or a poll interval longer than the offline threshold |
|
||||
| Stays `pending` forever | Registration never happened. Token spent, or the endpoint unreachable |
|
||||
| Flaps between `active` and `offline` | Intermittent path, or a poll interval longer than the offline threshold |
|
||||
|
||||
Remember the offline sweep runs every two minutes, so status is never
|
||||
instantaneous.
|
||||
@@ -67,7 +67,7 @@ instantaneous.
|
||||
|
||||
## A workflow run fails or hangs
|
||||
|
||||
- **Hangs at dispatch.** The target's command stream is not connected — the
|
||||
- **Hangs at dispatch.** The target's command stream is not connected the
|
||||
server may be `offline`.
|
||||
- **Fails immediately with an interpreter error.** A bash step on a Windows
|
||||
target, or PowerShell on Linux.
|
||||
@@ -81,13 +81,13 @@ instantaneous.
|
||||
|
||||
## The console will not connect
|
||||
|
||||
| Symptom | Cause |
|
||||
| --- | --- |
|
||||
| Connects, then closes at once | guacd unreachable. Check `GUACD_ADDR` and that the container is running |
|
||||
| SSH rejects the key | The stored key has no private half, or is not on the target |
|
||||
| RDP fails on retry | Credentials are single-use and consumed at tunnel open — enter them again |
|
||||
| Hangs at "connecting" | The **control plane** cannot reach the target on the protocol port. The agent's reachability is irrelevant here |
|
||||
| Fails only in production | The reverse proxy is not forwarding WebSocket upgrade headers |
|
||||
| Symptom | Cause |
|
||||
| ----------------------------- | --------------------------------------------------------------------------------------------------------------- |
|
||||
| Connects, then closes at once | guacd unreachable. Check `GUACD_ADDR` and that the container is running |
|
||||
| SSH rejects the key | The stored key has no private half, or is not on the target |
|
||||
| RDP fails on retry | Credentials are single-use and consumed at tunnel open enter them again |
|
||||
| Hangs, then disconnects | The agent never claimed the relay, nothing is listening on the protocol port on the target's own loopback address, or guacd never dialled in time. Check the audit log for `console.proxy_failed` — its reason (`agent_timeout`, `dial_refused`, `guacd_timeout`, `rejected`) names which |
|
||||
| Fails only in production | The reverse proxy is not forwarding WebSocket upgrade headers |
|
||||
|
||||
## Monitors report down when the service is up
|
||||
|
||||
@@ -98,7 +98,7 @@ instantaneous.
|
||||
|
||||
## Notifications are not arriving
|
||||
|
||||
Use the channel **Test** button — it goes through the real delivery path, so a
|
||||
Use the channel **Test** button it goes through the real delivery path, so a
|
||||
test that arrives proves credentials, network path and destination.
|
||||
|
||||
If the test fails: a webhook returning 300 or above counts as a failure, SMTP
|
||||
@@ -107,23 +107,19 @@ needs `host`, `port`, `from` and `to`, and Telegram needs both `token` and
|
||||
|
||||
## Licence problems
|
||||
|
||||
| Symptom | Cause |
|
||||
| --- | --- |
|
||||
| `409 cloud_managed` when pasting | It is a cloud instance. Licences are written by HQ; there is nothing to paste |
|
||||
| Licence rejected as not matching | It is bound to a different instance UUID. Relink in HQ |
|
||||
| Instance degraded despite a valid-looking licence | It has expired past its grace period. Pasting still works — that endpoint stays available specifically so it can |
|
||||
| Cannot enrol another server | The server allowance is reached. Raise it in HQ or remove one |
|
||||
| Symptom | Cause |
|
||||
| ------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
|
||||
| `409 cloud_managed` when pasting | It is a cloud instance. Licences are written by HQ; there is nothing to paste |
|
||||
| Licence rejected as not matching | It is bound to a different instance UUID. Relink in HQ |
|
||||
| Instance degraded despite a valid-looking licence | It has expired past its grace period. Pasting still works that endpoint stays available specifically so it can |
|
||||
| Cannot enrol another server | The server allowance is reached. Raise it in HQ or remove one |
|
||||
|
||||
## HQ portal problems
|
||||
|
||||
**A request fails in the browser but works under `curl`.** The browser origin is
|
||||
missing from `ADMIN_ORIGIN`. This produces no log line at all in admin — the
|
||||
preflight is answered `204` without the allow-origin header, and the browser
|
||||
blocks the real request.
|
||||
|
||||
**A price or plan looks wrong after an edit.** Repository variables are baked
|
||||
into images at build time and editing one pushes no commit, so nothing rebuilds.
|
||||
Trigger the build manually. See [CI/CD](../operations/ci-cd.md).
|
||||
The portal is a hosted service, so problems with it are ours to fix rather than
|
||||
yours to configure. If a page fails to load, an action reports an error, or a
|
||||
plan or price looks wrong after a change, contact support with your instance
|
||||
UUID and roughly when it happened.
|
||||
|
||||
## Gathering information before asking for help
|
||||
|
||||
@@ -133,5 +129,5 @@ docker compose logs --tail=200 server
|
||||
journalctl -u vantage-agent --no-pager -n 200 # on the affected machine
|
||||
```
|
||||
|
||||
Include your instance UUID from **Settings → Licence** — it is the reference
|
||||
Include your instance UUID from **Settings → Licence** it is the reference
|
||||
support works from.
|
||||
|
||||
@@ -8,13 +8,13 @@ Every mutating API path writes an audit event. The log is at **Audit**.
|
||||
|
||||
## What an event carries
|
||||
|
||||
| Field | Meaning |
|
||||
| --- | --- |
|
||||
| Field | Meaning |
|
||||
| ------ | -------------------------------------------------------- |
|
||||
| Action | A dotted name, e.g. `server.created`, `settings.updated` |
|
||||
| Actor | Who did it |
|
||||
| Target | The object acted on |
|
||||
| Detail | A short human-readable note |
|
||||
| Time | When |
|
||||
| Actor | Who did it |
|
||||
| Target | The object acted on |
|
||||
| Detail | A short human-readable note |
|
||||
| Time | When |
|
||||
|
||||
## What is recorded
|
||||
|
||||
@@ -36,7 +36,7 @@ lookup.
|
||||
|
||||
## Retention
|
||||
|
||||
Audit events are not swept by the workflow log retention setting — that setting
|
||||
Audit events are not swept by the workflow log retention setting that setting
|
||||
governs run logs only. Audit history stays until the instance does.
|
||||
|
||||
:::warning It is a log, not a control
|
||||
|
||||
@@ -7,7 +7,7 @@ sidebar_label: Browser console
|
||||
An SSH, RDP or VNC session in a browser tab, with no client software and no
|
||||
inbound port on the target beyond the one the protocol already uses.
|
||||
|
||||
Protocol handling is Apache Guacamole's — the control plane proxies a WebSocket
|
||||
Protocol handling is Apache Guacamole's the control plane proxies a WebSocket
|
||||
to a **guacd** daemon and manages credentials around it.
|
||||
|
||||
## Requirements
|
||||
@@ -15,20 +15,23 @@ to a **guacd** daemon and manages credentials around it.
|
||||
- `guacd` running and reachable from the server. The bundled Compose stack
|
||||
includes it; `GUACD_ADDR` defaults to `guacd:4822`.
|
||||
- `KEY_ENCRYPTION_KEY` set, since every credential involved is stored encrypted.
|
||||
- Network reachability **from the control plane to the target** on the protocol
|
||||
port. This is the one part of Vantage that is not agent-mediated: guacd
|
||||
connects directly, so a machine reachable only by its agent cannot be
|
||||
consoled.
|
||||
- The target's **agent must be online**. Console traffic is relayed over the
|
||||
agent's existing outbound connection, so the control plane never needs a route
|
||||
to the server's address — but it does need the agent.
|
||||
- No inbound port on the target, beyond what the protocol already listens on
|
||||
locally. A service bound only to `127.0.0.1` works, because the agent dials
|
||||
loopback on the target itself.
|
||||
|
||||
## Opening a session
|
||||
|
||||
From a server's page, choose **Console**. Then:
|
||||
|
||||
1. The UI calls `POST /api/console/connect`, which mints a **one-time** session
|
||||
token.
|
||||
token. If the target's agent is not connected, this fails immediately with
|
||||
`409 agent_offline` rather than hanging.
|
||||
2. The browser opens a WebSocket to `GET /api/console/tunnel` with that token.
|
||||
3. The server marks the token consumed — atomically, so a second use cannot
|
||||
race — and proxies the connection to guacd.
|
||||
3. The server marks the token consumed atomically, so a second use cannot
|
||||
race and proxies the connection to guacd.
|
||||
|
||||
## Credentials
|
||||
|
||||
@@ -52,19 +55,19 @@ is worth one connection at most, and only until it is used.
|
||||
## Session behaviour
|
||||
|
||||
Closing the tab ends the session. There is no reconnect and no session
|
||||
persistence — reopening mints a new token and a new connection.
|
||||
persistence reopening mints a new token and a new connection.
|
||||
|
||||
## Auditing
|
||||
|
||||
Opening a console is an audited action, with actor, server and time. What
|
||||
happens *inside* the session is not recorded: there is no session capture or
|
||||
happens _inside_ the session is not recorded: there is no session capture or
|
||||
keystroke log. If you need that, it has to come from the target machine.
|
||||
|
||||
## When it does not work
|
||||
|
||||
| Symptom | Cause |
|
||||
| --- | --- |
|
||||
| Connects then closes immediately | guacd unreachable — check `GUACD_ADDR` and that the container is up |
|
||||
| SSH refuses the key | The stored key has no private half, or is not in the target's `authorized_keys` |
|
||||
| RDP fails on a fresh credential | Credentials are consumed on open; a retry needs them entered again |
|
||||
| Hangs at connecting | The control plane cannot reach the target on the protocol port |
|
||||
| Symptom | Cause |
|
||||
| -------------------------------- | ------------------------------------------------------------------------------- |
|
||||
| Connects then closes immediately | guacd unreachable check `GUACD_ADDR` and that the container is up |
|
||||
| SSH refuses the key | The stored key has no private half, or is not in the target's `authorized_keys` |
|
||||
| RDP fails on a fresh credential | Credentials are consumed on open; a retry needs them entered again |
|
||||
| Hangs, then disconnects | The agent never claimed the relay, nothing is listening on the protocol port on the target's own loopback address, or guacd never dialled in time. Check the audit log for `console.proxy_failed` — its reason (`agent_timeout`, `dial_refused`, `guacd_timeout`, `rejected`) names which |
|
||||
|
||||
@@ -9,12 +9,12 @@ from, and a notification path when they stop being satisfied.
|
||||
|
||||
## Types
|
||||
|
||||
| Type | Checks | Options |
|
||||
| --- | --- | --- |
|
||||
| `http` | An HTTP(S) URL | method, expected status, keyword that must appear in the body, allow insecure TLS |
|
||||
| `tcp` | A host and port accept a connection | — |
|
||||
| `icmp` | A host answers ping | — |
|
||||
| `tls` | A certificate is valid and not expiring | warn N days before expiry |
|
||||
| Type | Checks | Options |
|
||||
| ------ | --------------------------------------- | --------------------------------------------------------------------------------- |
|
||||
| `http` | An HTTP(S) URL | method, expected status, keyword that must appear in the body, allow insecure TLS |
|
||||
| `tcp` | A host and port accept a connection | |
|
||||
| `icmp` | A host answers ping | |
|
||||
| `tls` | A certificate is valid and not expiring | warn N days before expiry |
|
||||
|
||||
An `http` monitor with a keyword is usually the one you want for an application:
|
||||
a 200 that returns an error page still fails the keyword.
|
||||
@@ -23,12 +23,12 @@ a 200 that returns an error page still fails the keyword.
|
||||
|
||||
Every monitor has a **runner**:
|
||||
|
||||
| Runner | Meaning |
|
||||
| --- | --- |
|
||||
| `server` | The control plane's scheduler performs the check |
|
||||
| Runner | Meaning |
|
||||
| ----------- | -------------------------------------------------------------- |
|
||||
| `server` | The control plane's scheduler performs the check |
|
||||
| a server ID | That server's agent performs it locally and reports the result |
|
||||
|
||||
Use `server` for anything reachable from the control plane — public endpoints,
|
||||
Use `server` for anything reachable from the control plane public endpoints,
|
||||
your own front door. Use an agent for anything only reachable from inside the
|
||||
target network: a database on a private subnet, a service bound to localhost, a
|
||||
device on a management VLAN.
|
||||
@@ -41,8 +41,8 @@ different questions, and outages usually live in the gap.
|
||||
|
||||
## Interval, retries and state
|
||||
|
||||
- **Interval** — how often to check.
|
||||
- **Retries** — how many consecutive failures are tolerated before the state
|
||||
- **Interval** how often to check.
|
||||
- **Retries** how many consecutive failures are tolerated before the state
|
||||
flips.
|
||||
|
||||
A monitor sits in `pending` until its first result. Failures accumulate; once
|
||||
@@ -65,7 +65,7 @@ hours does not send a message per interval.
|
||||
|
||||
The monitor detail page shows:
|
||||
|
||||
- **Uptime**, from hourly rollup records — checks performed, how many were up,
|
||||
- **Uptime**, from hourly rollup records checks performed, how many were up,
|
||||
and mean latency per hour. Rollups are what make the graph cheap to draw over
|
||||
long windows.
|
||||
- **Incidents**, each with a start, a resolution and the cause recorded at the
|
||||
@@ -74,5 +74,5 @@ The monitor detail page shows:
|
||||
## Disabling versus deleting
|
||||
|
||||
Disabling stops the checks and keeps the history. Deleting removes the monitor.
|
||||
Prefer disabling for anything seasonal — the uptime record is usually the part
|
||||
Prefer disabling for anything seasonal the uptime record is usually the part
|
||||
you wanted.
|
||||
|
||||
@@ -15,9 +15,9 @@ Manage them at **Settings → Notifications**.
|
||||
|
||||
Posts JSON to a URL you choose.
|
||||
|
||||
| Setting | |
|
||||
| --- | --- |
|
||||
| `url` | Required |
|
||||
| Setting | |
|
||||
| ------- | -------- |
|
||||
| `url` | Required |
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -35,31 +35,31 @@ after 10 seconds.
|
||||
|
||||
### Discord
|
||||
|
||||
| Setting | |
|
||||
| --- | --- |
|
||||
| `url` | Discord webhook URL |
|
||||
| Setting | |
|
||||
| ------- | ------------------- |
|
||||
| `url` | Discord webhook URL |
|
||||
|
||||
Posts the alert as message content.
|
||||
|
||||
### Slack
|
||||
|
||||
| Setting | |
|
||||
| --- | --- |
|
||||
| `url` | Slack incoming webhook URL |
|
||||
| Setting | |
|
||||
| ------- | -------------------------- |
|
||||
| `url` | Slack incoming webhook URL |
|
||||
|
||||
### Telegram
|
||||
|
||||
| Setting | |
|
||||
| --- | --- |
|
||||
| `token` | Bot token |
|
||||
| Setting | |
|
||||
| --------- | ----------- |
|
||||
| `token` | Bot token |
|
||||
| `chat_id` | Target chat |
|
||||
|
||||
### SMTP
|
||||
|
||||
| Setting | |
|
||||
| --- | --- |
|
||||
| `host`, `port` | Required |
|
||||
| `from`, `to` | Required |
|
||||
| Setting | |
|
||||
| ---------------------- | ---------------------------------------------------- |
|
||||
| `host`, `port` | Required |
|
||||
| `from`, `to` | Required |
|
||||
| `username`, `password` | Optional; auth is skipped when the username is empty |
|
||||
|
||||
Port `465` uses implicit TLS; anything else uses STARTTLS.
|
||||
@@ -83,7 +83,7 @@ something that needs to branch on status.
|
||||
## Testing
|
||||
|
||||
Every channel has a **Test** button. It dispatches a fabricated down event for a
|
||||
monitor called "Test monitor", through the real delivery path — so a test that
|
||||
monitor called "Test monitor", through the real delivery path so a test that
|
||||
arrives proves the credentials, the network path and the destination, not just
|
||||
the configuration form.
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ External Secrets Operator.
|
||||
|
||||
## Groups and values
|
||||
|
||||
A **group** is a named bundle — `prod-db`, `registry`, `acme-api`. Inside it are
|
||||
A **group** is a named bundle `prod-db`, `registry`, `acme-api`. Inside it are
|
||||
key/value pairs.
|
||||
|
||||
Group by consumer, not by type. A group is the unit a workflow step references
|
||||
@@ -22,7 +22,7 @@ a group holding everything is over-sharing to every step that needs any of it.
|
||||
**Secrets → New group**, then add keys.
|
||||
|
||||
Values are write-then-hidden. The list shows keys, never values. **Reveal** is a
|
||||
separate action on a separate endpoint, and it writes an audit event — so
|
||||
separate action on a separate endpoint, and it writes an audit event so
|
||||
looking at a secret is a recorded act.
|
||||
|
||||
Deleting a single key and deleting the whole group are separate operations.
|
||||
@@ -42,7 +42,7 @@ library entry.
|
||||
|
||||
:::warning A step can print its own secrets
|
||||
Injection puts values in the environment. If your script echoes them, or runs
|
||||
with `set -x`, they land in the run log — which is stored on disk and readable
|
||||
with `set -x`, they land in the run log which is stored on disk and readable
|
||||
in the UI. Vantage does not scrub step output.
|
||||
:::
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@ sidebar_label: Servers
|
||||
---
|
||||
|
||||
The fleet. Every managed machine runs an agent that connects outbound to the
|
||||
control plane, and everything else in Vantage — keys, workflows, monitors,
|
||||
consoles — targets these records.
|
||||
control plane, and everything else in Vantage keys, workflows, monitors,
|
||||
consoles targets these records.
|
||||
|
||||
## Enrolling a server
|
||||
|
||||
@@ -16,14 +16,14 @@ a one-liner to run as root on the target machine.
|
||||
|
||||
## Lifecycle
|
||||
|
||||
| Status | Meaning |
|
||||
| --- | --- |
|
||||
| Status | Meaning |
|
||||
| --------- | --------------------------------------------------- |
|
||||
| `pending` | Enrolment created; the agent has not registered yet |
|
||||
| `active` | The agent registered and is syncing |
|
||||
| `offline` | Last-seen passed the threshold |
|
||||
| `active` | The agent registered and is syncing |
|
||||
| `offline` | Last-seen passed the threshold |
|
||||
|
||||
The offline sweep runs every two minutes, so a machine that has just gone away
|
||||
takes a little while to be marked as such. That delay is intentional — a single
|
||||
takes a little while to be marked as such. That delay is intentional a single
|
||||
missed poll is not an outage.
|
||||
|
||||
## The server detail page
|
||||
@@ -37,9 +37,9 @@ Which SSH keys are assigned to this machine, and their state. See
|
||||
|
||||
Agents report:
|
||||
|
||||
| Data | Refreshed |
|
||||
| --- | --- |
|
||||
| CPU, memory, swap, load | every 30 seconds |
|
||||
| Data | Refreshed |
|
||||
| ---------------------------------------- | ---------------- |
|
||||
| CPU, memory, swap, load | every 30 seconds |
|
||||
| Partitions, kernel, full static snapshot | every 15 minutes |
|
||||
|
||||
The two carry separate timestamps, so a stale static snapshot beside fresh
|
||||
@@ -50,15 +50,15 @@ metrics is normal rather than a fault.
|
||||
Agents check for pending package updates hourly and report the count. From the
|
||||
server page you can:
|
||||
|
||||
- **Apply updates** — pushes `ApplyUpdatesCmd` down the command stream. The
|
||||
- **Apply updates** pushes `ApplyUpdatesCmd` down the command stream. The
|
||||
agent runs the platform's package manager and reports back.
|
||||
- **Update agent** — pushes `UpdateAgentCmd` with a target version; the agent
|
||||
- **Update agent** pushes `UpdateAgentCmd` with a target version; the agent
|
||||
downloads the release, verifies it and replaces itself. See
|
||||
[Agent updates](../operations/agent-updates.md).
|
||||
|
||||
:::warning Applying updates is not scheduled or staged
|
||||
It runs now, on that machine. If you need ordering, health gates or a canary,
|
||||
build it as a [workflow](./workflows.md) instead — that is what workflows exist
|
||||
build it as a [workflow](./workflows.md) instead that is what workflows exist
|
||||
for.
|
||||
:::
|
||||
|
||||
@@ -69,7 +69,7 @@ Opens a browser SSH, RDP or VNC session. See [Browser console](./browser-console
|
||||
## Windows servers
|
||||
|
||||
Windows agents register, heartbeat, run workflow steps and report inventory.
|
||||
They do not manage `authorized_keys` — the poll loop stops after the heartbeat
|
||||
They do not manage `authorized_keys` the poll loop stops after the heartbeat
|
||||
on any non-Linux host. This is a deliberate scope decision, not a gap being
|
||||
worked on.
|
||||
|
||||
@@ -94,5 +94,5 @@ no longer running to remove them. Revoke and let the agent apply the change
|
||||
|
||||
Each server has its own token. The control plane stores only its SHA-256; the
|
||||
plaintext exists in the agent's `0600` config and nowhere else. There is no way
|
||||
to read a token back out of the control plane — if one is lost, re-enrol the
|
||||
to read a token back out of the control plane if one is lost, re-enrol the
|
||||
machine.
|
||||
|
||||
@@ -11,7 +11,7 @@ Settings require the `owner` or `admin` role.
|
||||
|
||||
:::info Where instance settings went
|
||||
Members and single sign-on used to live at `/settings/instance`. They are now
|
||||
the Access group at the top of this page — splitting "who can sign in" from "how
|
||||
the Access group at the top of this page splitting "who can sign in" from "how
|
||||
this instance behaves" produced two half-pages and a nav entry nobody could
|
||||
distinguish from Settings. The old path still redirects.
|
||||
:::
|
||||
@@ -22,10 +22,10 @@ distinguish from Settings. The old path still redirects.
|
||||
|
||||
Add, remove and re-role the people who can sign in.
|
||||
|
||||
| Role | Can |
|
||||
| --- | --- |
|
||||
| `owner` | Everything |
|
||||
| `admin` | Everything except owner-only settings |
|
||||
| Role | Can |
|
||||
| -------- | ---------------------------------------------------- |
|
||||
| `owner` | Everything |
|
||||
| `admin` | Everything except owner-only settings |
|
||||
| `member` | Servers, keys, workflows, monitors, secrets, console |
|
||||
|
||||
Local members authenticate with email and a bcrypt-hashed password.
|
||||
@@ -37,7 +37,7 @@ read-only rows with a link to the portal.
|
||||
|
||||
:::warning HQ-managed users cannot be edited locally
|
||||
Changing the role of, or deleting, an `hq`-sourced user is refused with `409`.
|
||||
HQ owns their role, their password and whether they exist at all — a local
|
||||
HQ owns their role, their password and whether they exist at all a local
|
||||
change would be overwritten by the next sync and would leave two writers for one
|
||||
password hash. Manage them from [People and roles](../hq/people-and-roles.md).
|
||||
:::
|
||||
@@ -46,10 +46,10 @@ password hash. Manage them from [People and roles](../hq/people-and-roles.md).
|
||||
|
||||
Configured per organisation:
|
||||
|
||||
| Field | |
|
||||
| --- | --- |
|
||||
| Issuer | Your provider's issuer URL |
|
||||
| Client ID | |
|
||||
| Field | |
|
||||
| ------------- | ---------------------------- |
|
||||
| Issuer | Your provider's issuer URL |
|
||||
| Client ID | |
|
||||
| Client secret | Stored AES-256-GCM encrypted |
|
||||
|
||||
Sign-in then goes `/auth/oidc/start` → your provider → `/auth/oidc/callback`.
|
||||
@@ -60,7 +60,7 @@ misconfigured or unreachable, a local account is the way back in.
|
||||
## Monitoring
|
||||
|
||||
- **Alert defaults** for monitors.
|
||||
- **Notification channels** — their own page. See
|
||||
- **Notification channels** their own page. See
|
||||
[Notification channels](./notification-channels.md).
|
||||
|
||||
## Integrations
|
||||
@@ -69,11 +69,11 @@ misconfigured or unreachable, a local account is the way back in.
|
||||
|
||||
How long run logs are kept.
|
||||
|
||||
| Value | Meaning |
|
||||
| --- | --- |
|
||||
| unset | 30 days |
|
||||
| Value | Meaning |
|
||||
| -------- | -------------- |
|
||||
| unset | 30 days |
|
||||
| a number | that many days |
|
||||
| `0` | forever |
|
||||
| `0` | forever |
|
||||
|
||||
### ESO read token
|
||||
|
||||
@@ -87,7 +87,7 @@ once, stored as a SHA-256 hash, rotatable. See
|
||||
features and expiry.
|
||||
|
||||
On **self-hosted**, paste a licence here. This works even while the current
|
||||
licence is expired — that is the way out of degraded mode.
|
||||
licence is expired that is the way out of degraded mode.
|
||||
|
||||
On **cloud**, there is no paste form. The endpoint answers `409 cloud_managed`,
|
||||
because a cloud licence is written by HQ directly. The page links to the portal
|
||||
@@ -108,6 +108,6 @@ organisation from the slug and rejects a session belonging to a different one.
|
||||
The label it looks for comes from `APP_ROOT_LABEL`.
|
||||
|
||||
:::warning A wrong `APP_ROOT_LABEL` disables the guard
|
||||
It does not fail loudly — it simply stops matching, and the host check stops
|
||||
It does not fail loudly it simply stops matching, and the host check stops
|
||||
protecting anything. If you serve the UI on a custom domain, set it to match.
|
||||
:::
|
||||
|
||||
@@ -28,7 +28,7 @@ library. You may optionally upload the private half too, in which case it is
|
||||
stored **AES-256-GCM encrypted** under `KEY_ENCRYPTION_KEY`.
|
||||
|
||||
The JSON representation of a key exposes only `has_private_key` and
|
||||
`has_passphrase` — never the material. Retrieving a stored private key is its
|
||||
`has_passphrase` never the material. Retrieving a stored private key is its
|
||||
own endpoint and its own audit event.
|
||||
|
||||
:::tip Why store a private key at all
|
||||
@@ -38,7 +38,7 @@ you are not using the console, do not upload private halves.
|
||||
|
||||
## Assigning
|
||||
|
||||
Assign a key to one or more servers. Within one poll interval — 30 seconds — the
|
||||
Assign a key to one or more servers. Within one poll interval 30 seconds the
|
||||
agent picks up the change.
|
||||
|
||||
## Revoking
|
||||
@@ -58,7 +58,7 @@ until it ends. Kill sessions on the machine if that matters.
|
||||
|
||||
Each poll:
|
||||
|
||||
1. `SyncKeys` returns the desired set of public keys for that server.
|
||||
1. The control plane returns the desired set of public keys for that server.
|
||||
2. The agent reads `/root/.ssh/authorized_keys` and computes fingerprints.
|
||||
3. **If the sets match, it writes nothing.** No disk churn on unchanged state,
|
||||
which is most polls.
|
||||
@@ -77,5 +77,5 @@ it in Vantage.
|
||||
## Recovering from a lockout
|
||||
|
||||
If you have removed every key from a machine and cannot get in, you still have
|
||||
the console — provided a private key is stored — or out-of-band access from your
|
||||
the console provided a private key is stored or out-of-band access from your
|
||||
hosting provider. Vantage has no backdoor and does not keep a break-glass key.
|
||||
|
||||
@@ -13,14 +13,14 @@ back live.
|
||||
|
||||
A step has:
|
||||
|
||||
| Field | Meaning |
|
||||
| --- | --- |
|
||||
| `name`, `description` | Library identity |
|
||||
| `interpreter` | `bash` or `powershell` |
|
||||
| `script` | The body |
|
||||
| `declared_inputs` | Named parameters with defaults and descriptions |
|
||||
| `declared_outputs` | Names this step promises to export |
|
||||
| `secret_refs` | Vault entries injected as environment variables |
|
||||
| Field | Meaning |
|
||||
| --------------------- | ----------------------------------------------- |
|
||||
| `name`, `description` | Library identity |
|
||||
| `interpreter` | `bash` or `powershell` |
|
||||
| `script` | The body |
|
||||
| `declared_inputs` | Named parameters with defaults and descriptions |
|
||||
| `declared_outputs` | Names this step promises to export |
|
||||
| `secret_refs` | Vault entries injected as environment variables |
|
||||
|
||||
### Passing values between steps
|
||||
|
||||
@@ -59,7 +59,7 @@ new install is not staring at an empty page.
|
||||
:::warning Default steps are read-only
|
||||
Editing or deleting one is refused with `409`. Seeding rewrites them on every
|
||||
boot, so an edit would silently revert and a delete would come back at the next
|
||||
restart — refusing is the honest answer.
|
||||
restart refusing is the honest answer.
|
||||
|
||||
To customise one, use the per-step **script override** in the workflow designer,
|
||||
which belongs to that workflow and is not touched by seeding. To add to the
|
||||
@@ -67,7 +67,7 @@ shared library permanently, a file has to be committed to the repository and the
|
||||
server image rebuilt.
|
||||
:::
|
||||
|
||||
The UI mirrors this — the step modal opens read-only and Delete is hidden — but
|
||||
The UI mirrors this the step modal opens read-only and Delete is hidden but
|
||||
the API is the boundary; the UI is the courtesy.
|
||||
|
||||
## Building a workflow
|
||||
@@ -80,11 +80,11 @@ the API is the boundary; the UI is the courtesy.
|
||||
|
||||
### Failure behaviour
|
||||
|
||||
| `on_failure` | Effect |
|
||||
| --- | --- |
|
||||
| `stop` | Abort this server's run. Other servers continue |
|
||||
| `continue` | Record the failure, run the next step anyway |
|
||||
| `retry` | Re-run the step up to `max_retries`, then treat it as a failure |
|
||||
| `on_failure` | Effect |
|
||||
| ------------ | --------------------------------------------------------------- |
|
||||
| `stop` | Abort this server's run. Other servers continue |
|
||||
| `continue` | Record the failure, run the next step anyway |
|
||||
| `retry` | Re-run the step up to `max_retries`, then treat it as a failure |
|
||||
|
||||
### Per-step overrides
|
||||
|
||||
@@ -94,9 +94,8 @@ scoped to that workflow.
|
||||
|
||||
## Running
|
||||
|
||||
**Run** snapshots the resolved steps into the run record and dispatches
|
||||
`RunStepCmd` to each target's agent over the command stream — no waiting for the
|
||||
next poll.
|
||||
**Run** snapshots the resolved steps into the run record and dispatches each step
|
||||
to the target's agent over the command stream no waiting for the next poll.
|
||||
|
||||
:::info Runs freeze their steps
|
||||
The snapshot is why editing a step tomorrow never rewrites what happened today.
|
||||
@@ -119,11 +118,11 @@ nothing further is dispatched.
|
||||
Run logs are swept on a schedule set by `workflow_log_retention_days` in
|
||||
Settings:
|
||||
|
||||
| Value | Meaning |
|
||||
| --- | --- |
|
||||
| unset | 30 days |
|
||||
| Value | Meaning |
|
||||
| -------- | -------------- |
|
||||
| unset | 30 days |
|
||||
| a number | that many days |
|
||||
| `0` | keep forever |
|
||||
| `0` | keep forever |
|
||||
|
||||
## Import and export
|
||||
|
||||
|
||||
+3
-23
@@ -36,37 +36,17 @@ const sidebars: SidebarsConfig = {
|
||||
{
|
||||
type: "category",
|
||||
label: "Vantage HQ",
|
||||
items: [
|
||||
"hq/accounts-and-signup",
|
||||
"hq/people-and-roles",
|
||||
"hq/cloud-instances",
|
||||
"hq/self-hosted-instances",
|
||||
"hq/licensing-and-entitlements",
|
||||
"hq/billing",
|
||||
"hq/free-tier",
|
||||
],
|
||||
items: ["hq/accounts-and-signup", "hq/people-and-roles", "hq/cloud-instances", "hq/self-hosted-instances", "hq/licensing-and-entitlements", "hq/billing", "hq/free-tier"],
|
||||
},
|
||||
{
|
||||
type: "category",
|
||||
label: "Reference",
|
||||
items: [
|
||||
"reference/environment-variables",
|
||||
"reference/rest-api",
|
||||
"reference/grpc-api",
|
||||
"reference/agent-config",
|
||||
"reference/ports-and-networking",
|
||||
"reference/troubleshooting",
|
||||
],
|
||||
items: ["reference/environment-variables", "reference/rest-api", "reference/agent-config", "reference/ports-and-networking", "reference/troubleshooting"],
|
||||
},
|
||||
{
|
||||
type: "category",
|
||||
label: "Operations",
|
||||
items: [
|
||||
"operations/upgrading",
|
||||
"operations/backups",
|
||||
"operations/agent-updates",
|
||||
"operations/ci-cd",
|
||||
],
|
||||
items: ["operations/upgrading", "operations/backups", "operations/agent-updates"],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -14,6 +14,7 @@ service Vantage {
|
||||
rpc ReportChecks(ReportChecksRequest) returns (ReportChecksResponse);
|
||||
// Bidirectional stream: agent sends auth once, server pushes commands.
|
||||
rpc CommandStream(stream AgentMessage) returns (stream ServerCommand);
|
||||
rpc ProxyStream(stream ProxyClientMsg) returns (stream ProxyServerMsg);
|
||||
}
|
||||
|
||||
message RegisterRequest {
|
||||
@@ -180,6 +181,7 @@ message ServerCommand {
|
||||
ApplyUpdatesCmd apply_updates = 5;
|
||||
RunStepCmd run_step = 6;
|
||||
CleanupWorkspaceCmd cleanup_workspace = 7;
|
||||
OpenProxyCmd open_proxy = 8;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,3 +230,33 @@ message StepOutputChunk {
|
||||
bytes data = 3;
|
||||
bool eof = 4;
|
||||
}
|
||||
|
||||
// OpenProxyCmd tells the agent to dial 127.0.0.1:port locally and relay that
|
||||
// connection back over a fresh ProxyStream identified by proxy_id.
|
||||
message OpenProxyCmd {
|
||||
string proxy_id = 1;
|
||||
uint32 port = 2;
|
||||
}
|
||||
|
||||
message ProxyOpen {
|
||||
string server_id = 1;
|
||||
string agent_token = 2;
|
||||
string proxy_id = 3;
|
||||
}
|
||||
|
||||
message ProxyClose { string reason = 1; }
|
||||
|
||||
message ProxyClientMsg {
|
||||
oneof payload {
|
||||
ProxyOpen open = 1; // first message only
|
||||
bytes data = 2;
|
||||
ProxyClose close = 3;
|
||||
}
|
||||
}
|
||||
|
||||
message ProxyServerMsg {
|
||||
oneof payload {
|
||||
bytes data = 1;
|
||||
ProxyClose close = 2;
|
||||
}
|
||||
}
|
||||
|
||||
+87
-15
@@ -4,10 +4,12 @@ import (
|
||||
"context"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/api"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/auth"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/bus"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"
|
||||
grpcserver "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/grpc"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/monitorsched"
|
||||
@@ -18,7 +20,21 @@ import (
|
||||
func main() {
|
||||
mongoURI := getEnv("MONGO_URI", "mongodb://localhost:27017")
|
||||
|
||||
if os.Getenv("GRPC_HOST") == "" {
|
||||
// Two flags, so the schema work can be lifted out of the serving pods.
|
||||
//
|
||||
// Under Docker Compose neither is set and nothing changes: one process
|
||||
// migrates and then serves. Under Kubernetes with more than one replica
|
||||
// that is unsafe — every pod would run MigrateOrgToInstance at once, and
|
||||
// renaming collections while a sibling reads them is not a race anyone
|
||||
// wins. The chart therefore runs a pre-upgrade Job with MIGRATE_ONLY and
|
||||
// starts the Deployment with SKIP_MIGRATIONS.
|
||||
migrateOnly := boolEnv("VANTAGE_MIGRATE_ONLY")
|
||||
skipMigrations := boolEnv("VANTAGE_SKIP_MIGRATIONS")
|
||||
|
||||
// Not required in migrate-only mode: that process never serves gRPC, and
|
||||
// demanding it would put an agent-facing address in a Job that has no
|
||||
// business knowing one.
|
||||
if !migrateOnly && os.Getenv("GRPC_HOST") == "" {
|
||||
log.Fatal("GRPC_HOST is required (host:port agents dial for gRPC)")
|
||||
}
|
||||
|
||||
@@ -28,6 +44,24 @@ func main() {
|
||||
}
|
||||
log.Println("connected to MongoDB")
|
||||
|
||||
if migrateOnly || !skipMigrations {
|
||||
runSchemaSetup()
|
||||
} else {
|
||||
log.Println("VANTAGE_SKIP_MIGRATIONS set: assuming migrations ran elsewhere")
|
||||
}
|
||||
|
||||
if migrateOnly {
|
||||
log.Println("VANTAGE_MIGRATE_ONLY set: schema setup complete, exiting")
|
||||
return
|
||||
}
|
||||
|
||||
serve()
|
||||
}
|
||||
|
||||
// runSchemaSetup performs every write that must happen exactly once before the
|
||||
// application serves: migrations, index builders and default step seeding. It
|
||||
// is fatal on anything that would leave the schema half-moved.
|
||||
func runSchemaSetup() {
|
||||
// Migrations 0001 to 0003 still speak the pre-rename shape (orgs, org_id),
|
||||
// so they must run before 0004 renames everything underneath them.
|
||||
if err := services.RunMigrations(); err != nil {
|
||||
@@ -87,24 +121,27 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
services.StartLogSweeper()
|
||||
services.StartAuditSweeper()
|
||||
}
|
||||
|
||||
func serve() {
|
||||
redisAddr := getEnv("REDIS_ADDR", "localhost:6379")
|
||||
if err := auth.InitRedis(redisAddr); err != nil {
|
||||
redisUser := os.Getenv("REDIS_USERNAME")
|
||||
redisPass := os.Getenv("REDIS_PASSWORD")
|
||||
if err := auth.InitRedis(redisAddr, redisUser, redisPass); err != nil {
|
||||
log.Fatalf("failed to connect to Redis: %v", err)
|
||||
}
|
||||
log.Println("connected to Redis")
|
||||
|
||||
go func() {
|
||||
ticker := time.NewTicker(2 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
if err := services.MarkOfflineServers(); err != nil {
|
||||
log.Printf("mark offline error: %v", err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
// The bus carries agent commands and step results between replicas. It is
|
||||
// not optional even on a single-replica deployment: dispatch takes the same
|
||||
// path either way, so the code exercised in production is the code
|
||||
// exercised everywhere.
|
||||
if err := bus.Init(redisAddr, redisUser, redisPass); err != nil {
|
||||
log.Fatalf("failed to connect the message bus: %v", err)
|
||||
}
|
||||
log.Printf("message bus ready as node %s", bus.NodeID())
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
go func() {
|
||||
if err := grpcserver.StartGRPC(9090); err != nil {
|
||||
@@ -112,9 +149,33 @@ func main() {
|
||||
}
|
||||
}()
|
||||
|
||||
monitorsched.Start(context.Background())
|
||||
// Everything below runs on exactly one replica at a time.
|
||||
//
|
||||
// These are cluster-singleton jobs, not per-pod work: N replicas would mean
|
||||
// every monitor check firing N times, every incident notification delivered
|
||||
// to the customer N times, every retention sweep deleting concurrently, and
|
||||
// N reapers racing to purge the same instance. They share one lock rather
|
||||
// than holding four, because they are one role — housekeeping — and
|
||||
// splitting them would only spread that role across pods for no benefit.
|
||||
bus.RunAsLeader(ctx, "housekeeping", func(jobCtx context.Context) {
|
||||
services.StartLogSweeper(jobCtx)
|
||||
services.StartAuditSweeper(jobCtx)
|
||||
services.StartReaper(jobCtx)
|
||||
monitorsched.Start(jobCtx)
|
||||
|
||||
services.StartReaper(context.Background())
|
||||
ticker := time.NewTicker(2 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-jobCtx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
if err := services.MarkOfflineServers(); err != nil {
|
||||
log.Printf("mark offline error: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
r := gin.New()
|
||||
r.Use(gin.Recovery())
|
||||
@@ -147,3 +208,14 @@ func getEnv(key, fallback string) string {
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// boolEnv reads a flag env var. Anything other than a recognised truthy value
|
||||
// is false, so a typo leaves the safe default (migrate here, serve here) rather
|
||||
// than silently skipping schema setup.
|
||||
func boolEnv(key string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(os.Getenv(key))) {
|
||||
case "1", "true", "yes", "on":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -33,6 +35,15 @@ func consoleConnect(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if srv.Status != "active" {
|
||||
c.JSON(http.StatusConflict, gin.H{
|
||||
"error": "agent_offline",
|
||||
"message": "The agent on this server is not connected. " +
|
||||
"Console sessions are relayed by the agent, so it must be online.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
sess, err := services.CreateConsoleSession(auth.InstanceID(c), body.ServerID, body.Protocol, body.KeyID, actorFromCtx(c), c.ClientIP())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
@@ -59,7 +70,7 @@ func consoleConnect(c *gin.Context) {
|
||||
}
|
||||
|
||||
services.LogEvent(auth.InstanceID(c), "console.opened", actorFromCtx(c), srv.ServerID, "",
|
||||
"console session opened ("+body.Protocol+")")
|
||||
"console session opened ("+body.Protocol+", agent-relayed)")
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"session_id": sess.SessionID,
|
||||
@@ -124,7 +135,43 @@ func consoleTunnel(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
}
|
||||
gp, err := services.BuildGuacParams(srv, sess.Protocol, sess.SSHUsername, privKey, passphrase, rdpUser, rdpPass)
|
||||
targetPort, err := services.TargetPort(srv, sess.Protocol)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
relay, err := services.OpenConsoleProxy(instanceID, srv.ServerID, targetPort)
|
||||
if err != nil {
|
||||
if errors.Is(err, services.ErrAgentOffline) {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "agent_offline"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not open relay"})
|
||||
return
|
||||
}
|
||||
// guac.WebsocketServer.ServeHTTP returns before installing its
|
||||
// OnDisconnect handler when the connect callback errors, which is exactly
|
||||
// the path every relay failure this proxy introduces takes (the agent
|
||||
// never claims it, dial_refused, guacd never dials, rejected). Emitting
|
||||
// console.proxy_failed and ending the session here, unconditionally on
|
||||
// teardown, is what makes those failures reach the audit log at all;
|
||||
// OnDisconnect below only sees the rarer case of a session that was fully
|
||||
// established and then failed.
|
||||
defer func() {
|
||||
relay.Close()
|
||||
if reason := relay.Reason(); reason != "" {
|
||||
services.LogEvent(instanceID, "console.proxy_failed", actorFromCtx(c), srv.ServerID, "",
|
||||
fmt.Sprintf("console relay failed: %s (proxy_id=%s, port=%d)", reason, relay.ProxyID, relay.Port))
|
||||
}
|
||||
_ = services.EndConsoleSession(instanceID, sessionID)
|
||||
}()
|
||||
|
||||
services.LogEvent(instanceID, "console.proxy_opened", actorFromCtx(c), srv.ServerID, "",
|
||||
fmt.Sprintf("console relay opened (proxy_id=%s, port=%d)", relay.ProxyID, relay.Port))
|
||||
|
||||
gp, err := services.BuildGuacParams(sess.Protocol, sess.SSHUsername, privKey, passphrase,
|
||||
rdpUser, rdpPass, relay.Host, relay.Port)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -160,9 +207,9 @@ func consoleTunnel(c *gin.Context) {
|
||||
return guac.NewSimpleTunnel(stream), nil
|
||||
}
|
||||
|
||||
// Teardown (proxy_failed audit + EndConsoleSession) lives in the deferred
|
||||
// func above, not here: this only fires once a tunnel was actually
|
||||
// established, and letting both paths log would double the audit event.
|
||||
wsServer := guac.NewWebsocketServer(connect)
|
||||
wsServer.OnDisconnect = func(id string, r *http.Request, t guac.Tunnel) {
|
||||
_ = services.EndConsoleSession(instanceID, sessionID)
|
||||
}
|
||||
wsServer.ServeHTTP(c.Writer, c.Request)
|
||||
}
|
||||
|
||||
@@ -20,6 +20,9 @@ func actorFromCtx(c *gin.Context) string {
|
||||
}
|
||||
|
||||
func RegisterRoutes(r *gin.Engine) {
|
||||
r.GET("/healthz", handleHealthz)
|
||||
r.GET("/readyz", handleReadyz)
|
||||
|
||||
r.GET("/install", handleInstallScript)
|
||||
r.GET("/install.ps1", handleInstallScriptWindows)
|
||||
r.GET("/update", handleUpdateScript)
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/auth"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// The two probes answer different questions on purpose.
|
||||
//
|
||||
// /healthz is liveness: the process is up and serving. It touches nothing
|
||||
// external, because a Mongo outage must not make Kubernetes restart every
|
||||
// server pod — a restart loop cannot fix someone else's database, and it
|
||||
// destroys every open command stream and console session on the way.
|
||||
//
|
||||
// /readyz is readiness: this pod can serve a request end to end, which needs
|
||||
// both Mongo and Redis. A failing readiness probe pulls the pod out of the
|
||||
// Service and leaves it running, which is the behaviour that matters during a
|
||||
// dependency blip.
|
||||
//
|
||||
// Both sit outside /api, so neither the session middleware nor the licence
|
||||
// gate applies. Neither reveals anything beyond up or down.
|
||||
|
||||
const probeTimeout = 2 * time.Second
|
||||
|
||||
func handleHealthz(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
}
|
||||
|
||||
func handleReadyz(c *gin.Context) {
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), probeTimeout)
|
||||
defer cancel()
|
||||
|
||||
checks := gin.H{"mongo": "ok", "redis": "ok"}
|
||||
ready := true
|
||||
|
||||
if db.Client == nil {
|
||||
checks["mongo"] = "not initialised"
|
||||
ready = false
|
||||
} else if err := db.Client.Ping(ctx, nil); err != nil {
|
||||
checks["mongo"] = "unreachable"
|
||||
ready = false
|
||||
}
|
||||
|
||||
if err := auth.PingRedis(ctx); err != nil {
|
||||
checks["redis"] = "unreachable"
|
||||
ready = false
|
||||
}
|
||||
|
||||
status := http.StatusOK
|
||||
state := "ok"
|
||||
if !ready {
|
||||
status = http.StatusServiceUnavailable
|
||||
state = "unready"
|
||||
}
|
||||
c.JSON(status, gin.H{"status": state, "checks": checks})
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -50,23 +49,42 @@ func getServerRunLog(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
path := services.ServerRunLogPath(runID, serverID)
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if !services.HasServerRunLog(runID, serverID) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "no logs"})
|
||||
return
|
||||
}
|
||||
c.Data(http.StatusOK, "text/plain; charset=utf-8", b)
|
||||
|
||||
c.Header("Content-Type", "text/plain; charset=utf-8")
|
||||
c.Status(http.StatusOK)
|
||||
|
||||
// Streamed in pages rather than read whole. A log capped at 200k lines is
|
||||
// tens of megabytes, and holding that in memory per concurrent download is
|
||||
// how one curious user takes a pod down.
|
||||
var after int64
|
||||
for {
|
||||
lines, last, err := services.ReadServerRunLog(runID, serverID, after, logPageSize)
|
||||
if err != nil || len(lines) == 0 {
|
||||
return
|
||||
}
|
||||
for _, l := range lines {
|
||||
_, _ = c.Writer.WriteString(l)
|
||||
_, _ = c.Writer.WriteString("\n")
|
||||
}
|
||||
c.Writer.Flush()
|
||||
after = last
|
||||
}
|
||||
}
|
||||
|
||||
// logPageSize bounds one read of the log store. Large enough that a normal log
|
||||
// is one or two queries, small enough that no single response buffers much.
|
||||
const logPageSize = 2000
|
||||
|
||||
func streamServerRunLog(c *gin.Context) {
|
||||
runID, serverID := c.Param("runId"), c.Param("serverId")
|
||||
if !uuidLike.MatchString(runID) || !uuidLike.MatchString(serverID) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
path := services.ServerRunLogPath(runID, serverID)
|
||||
|
||||
c.Writer.Header().Set("Content-Type", "text/event-stream")
|
||||
c.Writer.Header().Set("Cache-Control", "no-cache")
|
||||
c.Writer.Header().Set("Connection", "keep-alive")
|
||||
@@ -78,29 +96,27 @@ func streamServerRunLog(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
var offset int64
|
||||
// The cursor is a sequence number now, not a byte offset: lines come from
|
||||
// the log store, which any pod can read, rather than from a file only this
|
||||
// one has.
|
||||
var after int64
|
||||
sendNew := func() {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
if _, err := f.Seek(offset, 0); err != nil {
|
||||
return
|
||||
}
|
||||
buf := make([]byte, 32*1024)
|
||||
for {
|
||||
n, _ := f.Read(buf)
|
||||
if n <= 0 {
|
||||
break
|
||||
lines, last, err := services.ReadServerRunLog(runID, serverID, after, logPageSize)
|
||||
if err != nil || len(lines) == 0 {
|
||||
return
|
||||
}
|
||||
offset += int64(n)
|
||||
|
||||
for _, line := range splitSSE(buf[:n]) {
|
||||
_, _ = c.Writer.WriteString("data: " + line + "\n")
|
||||
after = last
|
||||
for _, line := range lines {
|
||||
for _, part := range splitSSE([]byte(line)) {
|
||||
_, _ = c.Writer.WriteString("data: " + part + "\n")
|
||||
}
|
||||
_, _ = c.Writer.WriteString("\n")
|
||||
}
|
||||
_, _ = c.Writer.WriteString("\n")
|
||||
flusher.Flush()
|
||||
if len(lines) < logPageSize {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
@@ -25,13 +26,31 @@ type Session struct {
|
||||
|
||||
var rdb *redis.Client
|
||||
|
||||
func InitRedis(addr string) error {
|
||||
rdb = redis.NewClient(&redis.Options{Addr: addr})
|
||||
// InitRedis connects the session store.
|
||||
//
|
||||
// Username and password may both be empty for an unauthenticated instance. For
|
||||
// a legacy `requirepass` Redis, pass the password with an empty username —
|
||||
// go-redis then sends AUTH with one argument instead of two.
|
||||
func InitRedis(addr, username, password string) error {
|
||||
rdb = redis.NewClient(&redis.Options{
|
||||
Addr: addr,
|
||||
Username: username,
|
||||
Password: password,
|
||||
})
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
return rdb.Ping(ctx).Err()
|
||||
}
|
||||
|
||||
// PingRedis reports whether the session store is reachable. Readiness depends
|
||||
// on it: a pod that cannot reach Redis can authenticate nobody.
|
||||
func PingRedis(ctx context.Context) error {
|
||||
if rdb == nil {
|
||||
return errors.New("redis not initialised")
|
||||
}
|
||||
return rdb.Ping(ctx).Err()
|
||||
}
|
||||
|
||||
func randomHex(n int) (string, error) {
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
// Package bus is the control plane's inter-process message bus.
|
||||
//
|
||||
// It exists because an agent's CommandStream lands on exactly one server
|
||||
// process. With a single replica that process is also the one handling every
|
||||
// REST request, so an in-memory map was enough. With several replicas it is
|
||||
// not: the pod asked to run a workflow step is almost never the pod holding
|
||||
// that agent's stream, and a map cannot reach across the gap.
|
||||
//
|
||||
// Redis is already a hard dependency (sessions), so the bus adds no new
|
||||
// infrastructure. It carries three things:
|
||||
//
|
||||
// presence which pod, if any, currently holds an agent's stream
|
||||
// commands a request/ack exchange delivering a ServerCommand to that pod
|
||||
// results step results travelling back to the pod driving the run
|
||||
//
|
||||
// Everything here is deliberately best-effort delivery with an explicit ack
|
||||
// rather than a queue. A command whose owner pod died between the presence
|
||||
// check and the publish must fail loudly and immediately — the caller answers
|
||||
// 503 and the operator retries — not sit in a queue waiting for a stream that
|
||||
// no longer exists.
|
||||
package bus
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// ErrNoResponder means nothing was subscribed to the channel, or the subscriber
|
||||
// did not ack within the timeout. Either way the command did not reach an agent.
|
||||
var ErrNoResponder = errors.New("no responder on channel")
|
||||
|
||||
var (
|
||||
rdb *redis.Client
|
||||
nodeID string
|
||||
)
|
||||
|
||||
// Init connects the bus. It takes its own client rather than sharing the
|
||||
// session store's: a subscription occupies its connection for as long as it
|
||||
// lives, and every agent stream on this pod holds one, so they must not come
|
||||
// out of the pool that ordinary session reads depend on.
|
||||
func Init(addr, username, password string) error {
|
||||
rdb = redis.NewClient(&redis.Options{
|
||||
Addr: addr,
|
||||
Username: username,
|
||||
Password: password,
|
||||
})
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := rdb.Ping(ctx).Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Hostname is the pod name under Kubernetes, which makes a log line naming
|
||||
// a node directly actionable. The random suffix keeps two processes on one
|
||||
// host (or a reused pod name) from claiming each other's locks.
|
||||
host, _ := os.Hostname()
|
||||
if host == "" {
|
||||
host = "server"
|
||||
}
|
||||
b := make([]byte, 4)
|
||||
_, _ = rand.Read(b)
|
||||
nodeID = host + "-" + hex.EncodeToString(b)
|
||||
return nil
|
||||
}
|
||||
|
||||
// NodeID identifies this process on the bus. Stable for the process lifetime.
|
||||
func NodeID() string { return nodeID }
|
||||
|
||||
// Client exposes the underlying Redis client for the leader election and
|
||||
// presence helpers in this package. It is nil before Init.
|
||||
func Client() *redis.Client { return rdb }
|
||||
|
||||
// Channel names. Every key and channel is prefixed so a Redis shared with the
|
||||
// session store (which uses km:) stays legible.
|
||||
const (
|
||||
prefix = "vantage:"
|
||||
|
||||
// CommandChannel carries envelopes to whichever pod holds serverID's stream.
|
||||
CommandChannel = prefix + "cmd:"
|
||||
// ackChannel carries the owner pod's answer back to the requesting pod.
|
||||
ackChannel = prefix + "ack:"
|
||||
// ResultChannel carries a StepResult back to the pod driving the run.
|
||||
ResultChannel = prefix + "res:"
|
||||
// ProxyEndChannel carries a console relay's terminal reason back to the pod
|
||||
// serving the WebSocket, which is the pod that has to write the audit event.
|
||||
ProxyEndChannel = prefix + "proxyend:"
|
||||
|
||||
// PresenceKey records which node holds an agent's command stream.
|
||||
PresenceKey = prefix + "agent:"
|
||||
// leaderKey records the holder of a named singleton job.
|
||||
leaderKey = prefix + "leader:"
|
||||
)
|
||||
|
||||
// Publish sends v, JSON-encoded, to channel. It reports how many subscribers
|
||||
// received it, which is the only signal Redis pub/sub gives that anyone was
|
||||
// listening.
|
||||
func Publish(ctx context.Context, channel string, v any) (int64, error) {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return rdb.Publish(ctx, channel, b).Result()
|
||||
}
|
||||
|
||||
// Subscribe returns a channel of raw payloads and a function that unsubscribes.
|
||||
// It blocks until Redis confirms the subscription, so a caller may publish
|
||||
// immediately afterwards without racing its own subscriber.
|
||||
func Subscribe(ctx context.Context, channel string) (<-chan []byte, func(), error) {
|
||||
ps := rdb.Subscribe(ctx, channel)
|
||||
if _, err := ps.Receive(ctx); err != nil {
|
||||
_ = ps.Close()
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
out := make(chan []byte, 64)
|
||||
go func() {
|
||||
defer close(out)
|
||||
for msg := range ps.Channel() {
|
||||
select {
|
||||
case out <- []byte(msg.Payload):
|
||||
default:
|
||||
// A subscriber too slow to keep up would otherwise stall every
|
||||
// other subscriber sharing this connection. Dropping is correct
|
||||
// here: commands are acked, and a dropped ack fails the caller
|
||||
// loudly rather than hanging it.
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return out, func() { _ = ps.Close() }, nil
|
||||
}
|
||||
|
||||
// Request publishes req to channel and waits for a single reply on replyTo.
|
||||
//
|
||||
// The reply subscription is established before the publish, so a responder that
|
||||
// answers instantly cannot beat the subscriber into place. A publish that
|
||||
// reaches no subscriber fails immediately with ErrNoResponder rather than
|
||||
// burning the whole timeout: nobody is going to answer.
|
||||
func Request(ctx context.Context, channel, replyTo string, req any, timeout time.Duration) ([]byte, error) {
|
||||
waitCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
replies, unsub, err := Subscribe(waitCtx, replyTo)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("subscribe reply channel: %w", err)
|
||||
}
|
||||
defer unsub()
|
||||
|
||||
n, err := Publish(waitCtx, channel, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if n == 0 {
|
||||
return nil, ErrNoResponder
|
||||
}
|
||||
|
||||
select {
|
||||
case b, ok := <-replies:
|
||||
if !ok {
|
||||
return nil, ErrNoResponder
|
||||
}
|
||||
return b, nil
|
||||
case <-waitCtx.Done():
|
||||
return nil, ErrNoResponder
|
||||
}
|
||||
}
|
||||
|
||||
// Reply answers a Request on its reply channel.
|
||||
func Reply(ctx context.Context, replyTo string, v any) error {
|
||||
_, err := Publish(ctx, replyTo, v)
|
||||
return err
|
||||
}
|
||||
|
||||
// AckChannelFor names the reply channel for a command. The command ID is
|
||||
// already unique per dispatch, so it needs no further qualification.
|
||||
func AckChannelFor(commandID string) string { return ackChannel + commandID }
|
||||
|
||||
// SetPresence claims serverID for this node for ttl. Called repeatedly by the
|
||||
// pod holding the stream; the TTL is what bounds how long a crashed pod keeps
|
||||
// claiming an agent it can no longer reach.
|
||||
func SetPresence(ctx context.Context, serverID string, ttl time.Duration) error {
|
||||
return rdb.Set(ctx, PresenceKey+serverID, nodeID, ttl).Err()
|
||||
}
|
||||
|
||||
// ClearPresence releases serverID, but only if this node still holds it. A
|
||||
// blind DEL would let a pod whose stream had already been re-established
|
||||
// elsewhere delete the new owner's claim on its way out.
|
||||
func ClearPresence(ctx context.Context, serverID string) error {
|
||||
return releaseIfOwner.Run(ctx, rdb, []string{PresenceKey + serverID}, nodeID).Err()
|
||||
}
|
||||
|
||||
// PresenceHolder returns the node holding serverID's stream, or "" if none does.
|
||||
func PresenceHolder(ctx context.Context, serverID string) string {
|
||||
v, err := rdb.Get(ctx, PresenceKey+serverID).Result()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// IsConnected reports whether any pod currently holds serverID's stream.
|
||||
func IsConnected(ctx context.Context, serverID string) bool {
|
||||
n, err := rdb.Exists(ctx, PresenceKey+serverID).Result()
|
||||
return err == nil && n > 0
|
||||
}
|
||||
|
||||
var releaseIfOwner = redis.NewScript(`
|
||||
if redis.call("GET", KEYS[1]) == ARGV[1] then
|
||||
return redis.call("DEL", KEYS[1])
|
||||
end
|
||||
return 0
|
||||
`)
|
||||
@@ -0,0 +1,100 @@
|
||||
package bus
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
const (
|
||||
leaderTTL = 30 * time.Second
|
||||
leaderRenew = 10 * time.Second
|
||||
)
|
||||
|
||||
// RunAsLeader runs job on exactly one process at a time, cluster-wide.
|
||||
//
|
||||
// The background work this guards is not merely wasteful when duplicated. Every
|
||||
// replica running the monitor scheduler means every check fires N times, every
|
||||
// incident notification is delivered N times to the customer, and every hourly
|
||||
// rollup is written N times. The Free-instance reaper is worse: it deletes
|
||||
// whole instances, and two processes deleting the same one concurrently is not
|
||||
// a race anyone wins.
|
||||
//
|
||||
// Redis rather than a Kubernetes Lease so that Docker Compose, which has no
|
||||
// API server, takes the identical code path — one implementation to reason
|
||||
// about, not two.
|
||||
//
|
||||
// job is given a context cancelled the moment leadership is lost, and must
|
||||
// return when it is cancelled. Losing the lock (a paused process, a Redis
|
||||
// blip) is treated as fatal to that run of the job: the successor may already
|
||||
// have started, and two schedulers overlapping is the exact thing being
|
||||
// prevented.
|
||||
func RunAsLeader(ctx context.Context, name string, job func(context.Context)) {
|
||||
go func() {
|
||||
key := leaderKey + name
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
ok, err := rdb.SetNX(ctx, key, nodeID, leaderTTL).Result()
|
||||
if err != nil {
|
||||
log.Printf("leader %s: acquire failed: %v", name, err)
|
||||
}
|
||||
if !ok {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(leaderRenew):
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
log.Printf("leader %s: acquired by %s", name, nodeID)
|
||||
jobCtx, cancel := context.WithCancel(ctx)
|
||||
go job(jobCtx)
|
||||
holdLeadership(ctx, key, name)
|
||||
cancel()
|
||||
log.Printf("leader %s: released by %s", name, nodeID)
|
||||
|
||||
// Give up the key on a clean shutdown so a successor takes over in
|
||||
// milliseconds rather than waiting out the TTL.
|
||||
if ctx.Err() != nil {
|
||||
relCtx, relCancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
_ = releaseIfOwner.Run(relCtx, rdb, []string{key}, nodeID).Err()
|
||||
relCancel()
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// holdLeadership renews the lock until it is lost or ctx ends.
|
||||
func holdLeadership(ctx context.Context, key, name string) {
|
||||
t := time.NewTicker(leaderRenew)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
n, err := renewIfOwner.Run(ctx, rdb, []string{key}, nodeID, int(leaderTTL/time.Millisecond)).Int()
|
||||
if err != nil && err != redis.Nil {
|
||||
log.Printf("leader %s: renew failed, standing down: %v", name, err)
|
||||
return
|
||||
}
|
||||
if n == 0 {
|
||||
log.Printf("leader %s: lock lost, standing down", name)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var renewIfOwner = redis.NewScript(`
|
||||
if redis.call("GET", KEYS[1]) == ARGV[1] then
|
||||
return redis.call("PEXPIRE", KEYS[1], ARGV[2])
|
||||
end
|
||||
return 0
|
||||
`)
|
||||
@@ -123,6 +123,32 @@ 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"`
|
||||
@@ -131,6 +157,7 @@ type ServerCommand struct {
|
||||
ApplyUpdates *ApplyUpdatesCmd `json:"apply_updates,omitempty"`
|
||||
RunStep *RunStepCmd `json:"run_step,omitempty"`
|
||||
CleanupWorkspace *CleanupWorkspaceCmd `json:"cleanup_workspace,omitempty"`
|
||||
OpenProxy *OpenProxyCmd `json:"open_proxy,omitempty"`
|
||||
}
|
||||
|
||||
type CleanupWorkspaceCmd struct {
|
||||
@@ -239,6 +266,55 @@ func (c *vantageCommandStreamClient) Recv() (*ServerCommand, error) {
|
||||
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)
|
||||
@@ -248,6 +324,7 @@ type VantageServer interface {
|
||||
SyncMonitors(context.Context, *SyncMonitorsRequest) (*SyncMonitorsResponse, error)
|
||||
ReportChecks(context.Context, *ReportChecksRequest) (*ReportChecksResponse, error)
|
||||
CommandStream(Vantage_CommandStreamServer) error
|
||||
ProxyStream(Vantage_ProxyStreamServer) error
|
||||
}
|
||||
|
||||
type UnimplementedVantageServer struct{}
|
||||
@@ -284,6 +361,10 @@ func (UnimplementedVantageServer) CommandStream(Vantage_CommandStreamServer) err
|
||||
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)
|
||||
@@ -293,6 +374,7 @@ type VantageClient interface {
|
||||
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 {
|
||||
@@ -367,6 +449,14 @@ func (c *keyManagerClient) CommandStream(ctx context.Context, opts ...grpc.CallO
|
||||
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)
|
||||
}
|
||||
@@ -390,6 +480,12 @@ var Vantage_ServiceDesc = grpc.ServiceDesc{
|
||||
ServerStreams: true,
|
||||
ClientStreams: true,
|
||||
},
|
||||
{
|
||||
StreamName: "ProxyStream",
|
||||
Handler: _Vantage_ProxyStream_Handler,
|
||||
ServerStreams: true,
|
||||
ClientStreams: true,
|
||||
},
|
||||
},
|
||||
Metadata: "vantage/v1/vantage.proto",
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package pb
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestProxyClientMsgRoundTrip(t *testing.T) {
|
||||
in := &ProxyClientMsg{Data: []byte{0x00, 0xff, 0x10}}
|
||||
raw, err := json.Marshal(in)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
var out ProxyClientMsg
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if string(out.Data) != string(in.Data) {
|
||||
t.Fatalf("data mismatch: got %v want %v", out.Data, in.Data)
|
||||
}
|
||||
if out.Open != nil || out.Close != nil {
|
||||
t.Fatalf("empty oneof fields should stay nil, got open=%v close=%v", out.Open, out.Close)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenProxyCmdOnServerCommand(t *testing.T) {
|
||||
cmd := &ServerCommand{CommandId: "c1", OpenProxy: &OpenProxyCmd{ProxyId: "p1", Port: 22}}
|
||||
raw, err := json.Marshal(cmd)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
var out ServerCommand
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if out.OpenProxy == nil || out.OpenProxy.ProxyId != "p1" || out.OpenProxy.Port != 22 {
|
||||
t.Fatalf("open_proxy did not round-trip: %+v", out.OpenProxy)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package grpcserver
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/grpc/pb"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/proxy"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
// ProxyStream carries one console TCP connection. The agent opens it after
|
||||
// dialling its own loopback address, and authenticates with the same agent
|
||||
// token as the command stream plus the single-use proxy_id it was handed.
|
||||
func (s *vantageServer) ProxyStream(stream pb.Vantage_ProxyStreamServer) error {
|
||||
msg, err := stream.Recv()
|
||||
if err != nil {
|
||||
return status.Errorf(codes.InvalidArgument, "expected initial open message: %v", err)
|
||||
}
|
||||
if msg.Open == nil {
|
||||
return status.Error(codes.InvalidArgument, "first message must be open")
|
||||
}
|
||||
|
||||
srv, err := services.ValidateAgentToken(msg.Open.ServerId, msg.Open.AgentToken)
|
||||
if err != nil {
|
||||
// Deliberately identical to the claim-failure response below: a bad
|
||||
// token, an unknown proxy_id, and a proxy_id belonging to another
|
||||
// instance/server must be indistinguishable to the caller.
|
||||
log.Printf("proxy %s (server %s): invalid agent token", msg.Open.ProxyId, msg.Open.ServerId)
|
||||
return status.Error(codes.PermissionDenied, "proxy session unavailable")
|
||||
}
|
||||
|
||||
if err := serveProxy(proxy.Default, msg.Open, srv.InstanceID, stream); err != nil {
|
||||
// The reason is deliberately not returned to the agent: an unknown and a
|
||||
// foreign proxy_id must be indistinguishable.
|
||||
log.Printf("proxy %s (server %s): %v", msg.Open.ProxyId, msg.Open.ServerId, err)
|
||||
return status.Error(codes.PermissionDenied, "proxy session unavailable")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// serveProxy claims the pending session and relays it. Split out from the gRPC
|
||||
// method so the authorisation matrix is testable without a real stream.
|
||||
func serveProxy(reg *proxy.Registry, open *pb.ProxyOpen, instanceID string, stream proxy.AgentStream) error {
|
||||
entry, err := reg.Claim(instanceID, open.ServerId, open.ProxyId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return entry.Session.Serve(stream)
|
||||
}
|
||||
@@ -178,8 +178,13 @@ func (s *vantageServer) CommandStream(stream pb.Vantage_CommandStreamServer) err
|
||||
log.Printf("update last seen %s: %v", srv.ServerID, err)
|
||||
}
|
||||
|
||||
ch := services.Dispatcher.Connect(srv.ServerID)
|
||||
defer services.Dispatcher.Disconnect(srv.ServerID)
|
||||
// Serve claims this agent's presence on the bus and subscribes this pod to
|
||||
// its command channel, so a dispatch issued by any other replica arrives
|
||||
// here. The teardown releases both — an agent that reconnects to a
|
||||
// different pod must not leave this one advertising a stream it no longer
|
||||
// has.
|
||||
ch, release := services.Dispatcher.Serve(stream.Context(), srv.ServerID)
|
||||
defer release()
|
||||
|
||||
log.Printf("agent %s connected command stream", srv.ServerID)
|
||||
defer log.Printf("agent %s disconnected command stream", srv.ServerID)
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
// Package proxy relays console TCP traffic between guacd and a managed server's
|
||||
// agent. The agent dials only its own loopback address; the port is the single
|
||||
// value it takes from the control plane.
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"sync"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNotFound = errors.New("proxy session not found")
|
||||
ErrForbidden = errors.New("proxy session belongs to another server")
|
||||
)
|
||||
|
||||
// NewID returns a 32-byte random identifier as hex. It is the only credential
|
||||
// tying an incoming ProxyStream to a pending console session.
|
||||
func NewID() (string, error) {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
type Entry struct {
|
||||
ProxyID string
|
||||
InstanceID string
|
||||
ServerID string
|
||||
Session *Session
|
||||
}
|
||||
|
||||
type Registry struct {
|
||||
mu sync.Mutex
|
||||
entries map[string]*Entry
|
||||
}
|
||||
|
||||
func NewRegistry() *Registry {
|
||||
return &Registry{entries: make(map[string]*Entry)}
|
||||
}
|
||||
|
||||
var Default = NewRegistry()
|
||||
|
||||
func (r *Registry) Add(e *Entry) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.entries[e.ProxyID] = e
|
||||
}
|
||||
|
||||
// Claim removes and returns the entry. It is single-use: a second claim on the
|
||||
// same proxy_id gets ErrNotFound. A claim whose instance or server does not
|
||||
// match leaves the entry in place and gets ErrForbidden.
|
||||
func (r *Registry) Claim(instanceID, serverID, proxyID string) (*Entry, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
e, ok := r.entries[proxyID]
|
||||
if !ok {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if e.InstanceID != instanceID || e.ServerID != serverID {
|
||||
return nil, ErrForbidden
|
||||
}
|
||||
delete(r.entries, proxyID)
|
||||
return e, nil
|
||||
}
|
||||
|
||||
func (r *Registry) Remove(proxyID string) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
delete(r.entries, proxyID)
|
||||
}
|
||||
|
||||
func (r *Registry) Len() int {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return len(r.entries)
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/grpc/pb"
|
||||
)
|
||||
|
||||
const (
|
||||
chunkSize = 32 * 1024
|
||||
rendezvousTimeout = 10 * time.Second
|
||||
)
|
||||
|
||||
// AgentStream is the server's half of a ProxyStream. It is an interface so the
|
||||
// relay can be tested without gRPC.
|
||||
type AgentStream interface {
|
||||
Send(*pb.ProxyServerMsg) error
|
||||
Recv() (*pb.ProxyClientMsg, error)
|
||||
}
|
||||
|
||||
// Session owns one ephemeral listener and relays the single connection that
|
||||
// arrives on it to an agent stream.
|
||||
type Session struct {
|
||||
listener net.Listener
|
||||
allowed []string
|
||||
timeout time.Duration
|
||||
|
||||
once sync.Once
|
||||
mu sync.Mutex
|
||||
reason string
|
||||
conn net.Conn
|
||||
closing bool
|
||||
onEnd func(string)
|
||||
|
||||
rendezvous *time.Timer
|
||||
}
|
||||
|
||||
// OnEnd registers a callback invoked exactly once, when the session finishes
|
||||
// tearing down, with the recorded reason (empty if it ended cleanly).
|
||||
//
|
||||
// It exists because the process that observes a relay failing is not
|
||||
// necessarily the process that has to record it: with several replicas the
|
||||
// listener lives on the pod holding the agent's stream, while the audit event
|
||||
// belongs to the pod serving the browser's WebSocket.
|
||||
func (s *Session) OnEnd(fn func(string)) {
|
||||
s.mu.Lock()
|
||||
s.onEnd = fn
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// NewSession binds an ephemeral port on listenHost. allowed is the set of IPs
|
||||
// permitted to connect; an empty set allows any, which is the degraded case
|
||||
// when guacd's host could not be resolved.
|
||||
//
|
||||
// A watchdog is armed immediately: if the agent never opens its ProxyStream
|
||||
// and calls Serve, nothing else would ever bound how long the listener (and
|
||||
// the registry entry that references it) stays open. Serve cancels it once
|
||||
// entered and re-arms the same deadline for the accept wait.
|
||||
func NewSession(listenHost string, allowed []string) (*Session, error) {
|
||||
ln, err := net.Listen("tcp", net.JoinHostPort(listenHost, "0"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("bind relay listener: %w", err)
|
||||
}
|
||||
s := &Session{listener: ln, allowed: allowed, timeout: rendezvousTimeout}
|
||||
s.rendezvous = time.AfterFunc(rendezvousTimeout, func() {
|
||||
s.Close("agent_timeout")
|
||||
})
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Session) Port() int {
|
||||
return s.listener.Addr().(*net.TCPAddr).Port
|
||||
}
|
||||
|
||||
// Reason reports why the session ended, empty if it ended cleanly.
|
||||
func (s *Session) Reason() string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.reason
|
||||
}
|
||||
|
||||
// setReason records r as the session's failure reason, first write wins. It is
|
||||
// a no-op once a deliberate teardown (Close) has begun: a local Close closing
|
||||
// the conn out from under the relay goroutines produces exactly the kind of
|
||||
// error (net.ErrClosed, a broken pipe on write, ...) that looks like a remote
|
||||
// failure but is not one, and must not overwrite — or race to set — the real
|
||||
// reason, or invent one where a clean local close has none.
|
||||
func (s *Session) setReason(r string) {
|
||||
s.mu.Lock()
|
||||
if s.reason == "" && !s.closing {
|
||||
s.reason = r
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// Close tears the session down once. A non-empty reason is recorded only if no
|
||||
// reason has been recorded already, and only before teardown begins. It closes
|
||||
// both the listener and, if a connection has already been accepted, that
|
||||
// connection too — an unconditional kill for the whole relay chain regardless
|
||||
// of which stage it is in.
|
||||
func (s *Session) Close(reason string) {
|
||||
if reason != "" {
|
||||
s.setReason(reason)
|
||||
}
|
||||
s.once.Do(func() {
|
||||
// Recorded before anything is actually closed: the reader/writer
|
||||
// goroutines in relay() call setReason from the errors this Close
|
||||
// itself is about to cause (a closed conn, a closed stream), and
|
||||
// those must be recognised as teardown noise, not a genuine failure.
|
||||
s.mu.Lock()
|
||||
s.closing = true
|
||||
conn := s.conn
|
||||
s.mu.Unlock()
|
||||
|
||||
if s.rendezvous != nil {
|
||||
s.rendezvous.Stop()
|
||||
}
|
||||
_ = s.listener.Close()
|
||||
if conn != nil {
|
||||
_ = conn.Close()
|
||||
}
|
||||
|
||||
// Read after teardown, not before: relay()'s goroutines set the reason
|
||||
// as they unwind, and reporting one recorded earlier than that would
|
||||
// name the symptom rather than the cause.
|
||||
s.mu.Lock()
|
||||
fn, final := s.onEnd, s.reason
|
||||
s.mu.Unlock()
|
||||
if fn != nil {
|
||||
fn(final)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Session) setConn(conn net.Conn) {
|
||||
s.mu.Lock()
|
||||
s.conn = conn
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// Serve accepts exactly one connection, verifies its source, and relays until
|
||||
// either side ends. It always closes the listener before returning.
|
||||
func (s *Session) Serve(stream AgentStream) error {
|
||||
defer s.Close("")
|
||||
|
||||
// The agent has claimed the session and opened its stream, so the
|
||||
// unclaimed-rendezvous watchdog no longer applies; the accept deadline set
|
||||
// just below takes over for the claimed case.
|
||||
if s.rendezvous != nil {
|
||||
s.rendezvous.Stop()
|
||||
}
|
||||
|
||||
if l, ok := s.listener.(*net.TCPListener); ok {
|
||||
_ = l.SetDeadline(time.Now().Add(s.timeout))
|
||||
}
|
||||
|
||||
// Accept until a connection from an allowed source arrives or the
|
||||
// rendezvous deadline (set once, above) expires. A rejected connection is
|
||||
// closed and the loop retries within the same deadline; the listener is
|
||||
// only closed once we have a valid connection, the deadline expires, or
|
||||
// Accept fails for a genuine (non-timeout) reason.
|
||||
for {
|
||||
conn, err := s.listener.Accept()
|
||||
if err != nil {
|
||||
var netErr net.Error
|
||||
if errors.Is(err, os.ErrDeadlineExceeded) || (errors.As(err, &netErr) && netErr.Timeout()) {
|
||||
s.setReason("guacd_timeout")
|
||||
} else {
|
||||
s.setReason("accept_failed")
|
||||
}
|
||||
return fmt.Errorf("waiting for guacd: %w", err)
|
||||
}
|
||||
|
||||
if !allowedRemote(conn.RemoteAddr().String(), s.allowed) {
|
||||
_ = conn.Close()
|
||||
continue
|
||||
}
|
||||
|
||||
// One connection only: nothing else may claim this port. Store the
|
||||
// conn first so a concurrent external Close (e.g. the browser tab
|
||||
// closing) can reach it via the sync.Once teardown; then close just
|
||||
// the listener directly (not through Close, which would also close
|
||||
// the conn we are about to relay). The deferred s.Close("") above
|
||||
// performs the real one-shot teardown, including this conn, once
|
||||
// relay returns.
|
||||
s.setConn(conn)
|
||||
_ = s.listener.Close()
|
||||
|
||||
return s.relay(conn, stream)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Session) relay(conn net.Conn, stream AgentStream) error {
|
||||
errCh := make(chan error, 2)
|
||||
|
||||
// guacd -> agent
|
||||
go func() {
|
||||
buf := make([]byte, chunkSize)
|
||||
for {
|
||||
n, err := conn.Read(buf)
|
||||
if n > 0 {
|
||||
chunk := make([]byte, n)
|
||||
copy(chunk, buf[:n])
|
||||
if sendErr := stream.Send(&pb.ProxyServerMsg{Data: chunk}); sendErr != nil {
|
||||
errCh <- sendErr
|
||||
return
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
if !errors.Is(err, io.EOF) {
|
||||
s.setReason("guacd_read_error")
|
||||
}
|
||||
errCh <- err
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// agent -> guacd
|
||||
go func() {
|
||||
for {
|
||||
msg, err := stream.Recv()
|
||||
if err != nil {
|
||||
errCh <- err
|
||||
return
|
||||
}
|
||||
if msg.Close != nil {
|
||||
s.setReason(msg.Close.Reason)
|
||||
errCh <- fmt.Errorf("agent closed relay: %s", msg.Close.Reason)
|
||||
return
|
||||
}
|
||||
if len(msg.Data) > 0 {
|
||||
if _, err := conn.Write(msg.Data); err != nil {
|
||||
errCh <- err
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
err := <-errCh
|
||||
_ = conn.Close()
|
||||
if errors.Is(err, io.EOF) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// allowedRemote reports whether remote (a host:port string) is in allowed. An
|
||||
// empty allowed list permits anything.
|
||||
func allowedRemote(remote string, allowed []string) bool {
|
||||
host, _, err := net.SplitHostPort(remote)
|
||||
if err != nil {
|
||||
log.Printf("proxy: unparseable remote address %q", remote)
|
||||
return false
|
||||
}
|
||||
if len(allowed) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, a := range allowed {
|
||||
if a == host {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -15,13 +15,21 @@ import (
|
||||
// An immediate pass then daily, following StartLogSweeper's shape. Daily rather
|
||||
// than hourly because the unit of retention is a day: sweeping twenty-four times
|
||||
// to delete the same nothing is load without a purpose.
|
||||
func StartAuditSweeper() {
|
||||
// It takes a context because it runs under leader election: several replicas
|
||||
// all trimming the same audit logs is duplicated deletion of customer data, and
|
||||
// the loop must stop the moment this process stops being the leader.
|
||||
func StartAuditSweeper(ctx context.Context) {
|
||||
go func() {
|
||||
sweepAuditLogs()
|
||||
t := time.NewTicker(24 * time.Hour)
|
||||
defer t.Stop()
|
||||
for range t.C {
|
||||
sweepAuditLogs()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
sweepAuditLogs()
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -77,20 +77,37 @@ type GuacParams struct {
|
||||
Params map[string]string
|
||||
}
|
||||
|
||||
func portOr(v, def int) string {
|
||||
func portOr(v, def int) int {
|
||||
if v == 0 {
|
||||
v = def
|
||||
return def
|
||||
}
|
||||
return strconv.Itoa(v)
|
||||
return v
|
||||
}
|
||||
|
||||
func BuildGuacParams(srv *models.Server, protocol, sshUser, privateKey, passphrase, rdpUser, rdpPass string) (*GuacParams, error) {
|
||||
host := srv.IPAddress
|
||||
// TargetPort is the port on the managed server that the agent will dial on its
|
||||
// own loopback address.
|
||||
func TargetPort(srv *models.Server, protocol string) (int, error) {
|
||||
switch protocol {
|
||||
case "ssh":
|
||||
return portOr(srv.SSHPort, 22), nil
|
||||
case "rdp":
|
||||
return portOr(srv.RDPPort, 3389), nil
|
||||
case "vnc":
|
||||
return 5900, nil
|
||||
default:
|
||||
return 0, fmt.Errorf("unsupported protocol %q", protocol)
|
||||
}
|
||||
}
|
||||
|
||||
// BuildGuacParams points guacd at the relay listener, never at the managed
|
||||
// server: on a cloud deployment the server's address is not routable from here.
|
||||
func BuildGuacParams(protocol, sshUser, privateKey, passphrase, rdpUser, rdpPass, relayHost string, relayPort int) (*GuacParams, error) {
|
||||
port := strconv.Itoa(relayPort)
|
||||
switch protocol {
|
||||
case "ssh":
|
||||
p := map[string]string{
|
||||
"hostname": host,
|
||||
"port": portOr(srv.SSHPort, 22),
|
||||
"hostname": relayHost,
|
||||
"port": port,
|
||||
}
|
||||
if sshUser == "" {
|
||||
sshUser = "root"
|
||||
@@ -105,8 +122,8 @@ func BuildGuacParams(srv *models.Server, protocol, sshUser, privateKey, passphra
|
||||
return &GuacParams{Protocol: "ssh", Params: p}, nil
|
||||
case "rdp":
|
||||
return &GuacParams{Protocol: "rdp", Params: map[string]string{
|
||||
"hostname": host,
|
||||
"port": portOr(srv.RDPPort, 3389),
|
||||
"hostname": relayHost,
|
||||
"port": port,
|
||||
"username": rdpUser,
|
||||
"password": rdpPass,
|
||||
"security": "any",
|
||||
@@ -114,8 +131,8 @@ func BuildGuacParams(srv *models.Server, protocol, sshUser, privateKey, passphra
|
||||
}}, nil
|
||||
case "vnc":
|
||||
return &GuacParams{Protocol: "vnc", Params: map[string]string{
|
||||
"hostname": host,
|
||||
"port": "5900",
|
||||
"hostname": relayHost,
|
||||
"port": port,
|
||||
"password": rdpPass,
|
||||
}}, nil
|
||||
default:
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/bus"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/grpc/pb"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/proxy"
|
||||
)
|
||||
|
||||
// ErrAgentOffline means the console cannot be opened because the target's agent
|
||||
// is not on the command stream. Every console session is relayed by the agent,
|
||||
// so this is fatal rather than a degraded mode.
|
||||
var ErrAgentOffline = errors.New("agent is not connected")
|
||||
|
||||
// A console session spans two processes once there is more than one replica.
|
||||
//
|
||||
// The browser's WebSocket lands on an arbitrary pod. The agent's ProxyStream
|
||||
// lands on the pod holding that agent's command stream. The relay listener has
|
||||
// to be on the latter — that is the only process that can match an incoming
|
||||
// ProxyStream to a waiting listener — while guacd is dialled from the former.
|
||||
//
|
||||
// So the WebSocket's pod asks, over the bus, for a relay to be bound on the
|
||||
// agent's pod, and gets back an address to hand to guacd. That address is the
|
||||
// owner pod's own, which is why it must resolve to a single pod (POD_IP under
|
||||
// Kubernetes) rather than to the Service, which would send guacd to a pod
|
||||
// holding no listener roughly (n-1)/n of the time.
|
||||
//
|
||||
// Teardown needs no message of its own. When the browser goes away guac closes
|
||||
// its connection to the relay, the relay sees the read end, and the session
|
||||
// closes itself — the same path a single-process deployment always took. Only
|
||||
// the *reason* has to cross back, because the pod that writes the audit event
|
||||
// is not the pod that observed the failure.
|
||||
|
||||
// How long Close waits for the relay's terminal reason to arrive before giving
|
||||
// up. The audit event is written immediately afterwards; a reason that has not
|
||||
// crossed the bus in this long is not going to improve the record by being
|
||||
// waited for longer.
|
||||
const proxyEndGrace = 2 * time.Second
|
||||
|
||||
// ConsoleProxy is a relay as seen by the pod serving the WebSocket.
|
||||
type ConsoleProxy struct {
|
||||
ProxyID string
|
||||
Host string
|
||||
Port int
|
||||
|
||||
serverID string
|
||||
|
||||
mu sync.Mutex
|
||||
reason string
|
||||
closed bool
|
||||
stop func()
|
||||
ended chan struct{}
|
||||
}
|
||||
|
||||
// proxyEnd is the terminal event a relay's owner pod publishes.
|
||||
type proxyEnd struct {
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
func proxyListenHost() string {
|
||||
if v := os.Getenv("PROXY_LISTEN_HOST"); v != "" {
|
||||
return v
|
||||
}
|
||||
return "0.0.0.0"
|
||||
}
|
||||
|
||||
// proxyAdvertiseHost is the address guacd will dial to reach a relay bound by
|
||||
// *this* process. POD_IP wins over the configured value because with several
|
||||
// replicas the configured value names the Service, and a Service cannot address
|
||||
// one pod. The chart sets POD_IP from the downward API.
|
||||
func proxyAdvertiseHost() string {
|
||||
if v := os.Getenv("POD_IP"); v != "" {
|
||||
return v
|
||||
}
|
||||
if v := os.Getenv("PROXY_ADVERTISE_HOST"); v != "" {
|
||||
return v
|
||||
}
|
||||
return "server"
|
||||
}
|
||||
|
||||
func guacdAddr() string {
|
||||
if v := os.Getenv("GUACD_ADDR"); v != "" {
|
||||
return v
|
||||
}
|
||||
return "guacd:4822"
|
||||
}
|
||||
|
||||
// guacdHosts resolves guacd's address to the IPs allowed to claim a relay
|
||||
// listener. An unresolvable host yields an empty set, which allows any source:
|
||||
// refusing everything would take the console down entirely, so the narrower
|
||||
// protections (ephemeral port, 10s window, single accept) carry it instead.
|
||||
func guacdHosts(addr string) []string {
|
||||
host, _, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
host = addr
|
||||
}
|
||||
if ip := net.ParseIP(host); ip != nil {
|
||||
return []string{ip.String()}
|
||||
}
|
||||
ips, err := net.LookupHost(host)
|
||||
if err != nil {
|
||||
log.Printf("proxy: cannot resolve guacd host %q, allowing any relay source: %v", host, err)
|
||||
return nil
|
||||
}
|
||||
return ips
|
||||
}
|
||||
|
||||
// localRelay is a listener bound by this process on behalf of a remote request.
|
||||
type localRelay struct {
|
||||
proxyID string
|
||||
host string
|
||||
port int
|
||||
session *proxy.Session
|
||||
}
|
||||
|
||||
// openLocalRelay binds a listener here and registers it, so the agent's
|
||||
// ProxyStream — which will arrive at this process — can be matched to it.
|
||||
// Called on the owner pod, from the dispatch handler.
|
||||
func openLocalRelay(instanceID, serverID, proxyID string) (*localRelay, error) {
|
||||
sess, err := proxy.NewSession(proxyListenHost(), guacdHosts(guacdAddr()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sess.OnEnd(func(reason string) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), dispatchAckTimeout)
|
||||
defer cancel()
|
||||
if _, err := bus.Publish(ctx, bus.ProxyEndChannel+proxyID, proxyEnd{Reason: reason}); err != nil {
|
||||
log.Printf("proxy: publish end for %s: %v", proxyID, err)
|
||||
}
|
||||
})
|
||||
|
||||
proxy.Default.Add(&proxy.Entry{
|
||||
ProxyID: proxyID,
|
||||
InstanceID: instanceID,
|
||||
ServerID: serverID,
|
||||
Session: sess,
|
||||
})
|
||||
|
||||
return &localRelay{
|
||||
proxyID: proxyID,
|
||||
host: proxyAdvertiseHost(),
|
||||
port: sess.Port(),
|
||||
session: sess,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// abandon tears down a relay that was bound but whose command never reached the
|
||||
// agent, so the listener does not sit out its rendezvous timeout for nothing.
|
||||
func (r *localRelay) abandon() {
|
||||
proxy.Default.Remove(r.proxyID)
|
||||
r.session.Close("dispatch_failed")
|
||||
}
|
||||
|
||||
// OpenConsoleProxy asks the pod holding serverID's stream to bind a relay and
|
||||
// tell the agent to meet it. The caller must Close the result.
|
||||
func OpenConsoleProxy(instanceID, serverID string, targetPort int) (*ConsoleProxy, error) {
|
||||
if !Dispatcher.IsConnected(serverID) {
|
||||
return nil, ErrAgentOffline
|
||||
}
|
||||
|
||||
proxyID, err := proxy.NewID()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generate proxy id: %w", err)
|
||||
}
|
||||
|
||||
// Subscribed before the relay is asked for: a relay that fails immediately
|
||||
// (the agent never claims it, the dial is refused) publishes its reason at
|
||||
// once, and that reason is the whole content of the audit event.
|
||||
cp := &ConsoleProxy{ProxyID: proxyID, serverID: serverID, ended: make(chan struct{})}
|
||||
endCtx, endCancel := context.WithCancel(context.Background())
|
||||
ends, unsub, err := bus.Subscribe(endCtx, bus.ProxyEndChannel+proxyID)
|
||||
if err != nil {
|
||||
endCancel()
|
||||
return nil, fmt.Errorf("subscribe relay end: %w", err)
|
||||
}
|
||||
cp.stop = func() {
|
||||
endCancel()
|
||||
unsub()
|
||||
}
|
||||
go cp.watchEnd(ends)
|
||||
|
||||
ack, err := Dispatcher.send(CommandEnvelope{
|
||||
ServerID: serverID,
|
||||
Command: &pb.ServerCommand{
|
||||
CommandId: proxyID,
|
||||
OpenProxy: &pb.OpenProxyCmd{ProxyId: proxyID, Port: uint32(targetPort)},
|
||||
},
|
||||
Proxy: &ProxyRelayRequest{InstanceID: instanceID, ProxyID: proxyID},
|
||||
})
|
||||
if err != nil {
|
||||
cp.stop()
|
||||
if errors.Is(err, ErrAgentNotConnected) {
|
||||
return nil, ErrAgentOffline
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if ack.ProxyHost == "" || ack.ProxyPort == 0 {
|
||||
cp.stop()
|
||||
return nil, fmt.Errorf("relay opened without an address")
|
||||
}
|
||||
|
||||
cp.Host = ack.ProxyHost
|
||||
cp.Port = ack.ProxyPort
|
||||
return cp, nil
|
||||
}
|
||||
|
||||
func (c *ConsoleProxy) watchEnd(ends <-chan []byte) {
|
||||
defer close(c.ended)
|
||||
b, ok := <-ends
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var e proxyEnd
|
||||
if err := json.Unmarshal(b, &e); err != nil {
|
||||
return
|
||||
}
|
||||
c.mu.Lock()
|
||||
if c.reason == "" {
|
||||
c.reason = e.Reason
|
||||
}
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// Close waits briefly for the relay's terminal reason, then releases the
|
||||
// subscription. It is safe to call more than once.
|
||||
func (c *ConsoleProxy) Close() {
|
||||
c.mu.Lock()
|
||||
if c.closed {
|
||||
c.mu.Unlock()
|
||||
return
|
||||
}
|
||||
c.closed = true
|
||||
c.mu.Unlock()
|
||||
|
||||
select {
|
||||
case <-c.ended:
|
||||
case <-time.After(proxyEndGrace):
|
||||
}
|
||||
c.stop()
|
||||
}
|
||||
|
||||
// Reason reports why the relay ended, empty if it ended cleanly.
|
||||
func (c *ConsoleProxy) Reason() string {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.reason
|
||||
}
|
||||
@@ -1,63 +1,253 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/bus"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/grpc/pb"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type commandDispatcher struct {
|
||||
mu sync.RWMutex
|
||||
channels map[string]chan *pb.ServerCommand
|
||||
// Commands travel over the bus even when the sender is also the owner.
|
||||
//
|
||||
// An agent's CommandStream terminates on exactly one process, and with several
|
||||
// replicas that is almost never the process handling the REST request that
|
||||
// wants to talk to it. Publishing unconditionally — rather than checking for a
|
||||
// local stream first and falling back — means one code path, exercised on every
|
||||
// deployment including the single-replica ones, instead of a rare cross-pod
|
||||
// path that only fails in production.
|
||||
const (
|
||||
// How long a caller waits for the owning pod to acknowledge. Generous
|
||||
// enough to cross a loaded cluster, short enough that a REST handler
|
||||
// answering 503 does not look hung.
|
||||
dispatchAckTimeout = 5 * time.Second
|
||||
|
||||
// Presence must outlive a renew or two, or a momentarily slow pod would
|
||||
// look offline and its agent would be declared unreachable.
|
||||
presenceTTL = 30 * time.Second
|
||||
presenceRenew = 10 * time.Second
|
||||
)
|
||||
|
||||
// CommandEnvelope is what actually crosses the bus. It is the command plus the
|
||||
// small amount of context the owning pod needs to act on it locally.
|
||||
type CommandEnvelope struct {
|
||||
ServerID string `json:"server_id"`
|
||||
Command *pb.ServerCommand `json:"command"`
|
||||
ReplyTo string `json:"reply_to"`
|
||||
Log *LogRequest `json:"log,omitempty"`
|
||||
Proxy *ProxyRelayRequest `json:"proxy,omitempty"`
|
||||
}
|
||||
|
||||
var Dispatcher = &commandDispatcher{
|
||||
channels: make(map[string]chan *pb.ServerCommand),
|
||||
// LogRequest asks the owner pod to open a step log before it dispatches.
|
||||
//
|
||||
// Step output arrives on the owner pod's gRPC stream, so that is where it is
|
||||
// masked and written. Shipping raw output back to the run's pod first would put
|
||||
// unmasked bytes on the bus for no gain.
|
||||
type LogRequest struct {
|
||||
RunID string `json:"run_id"`
|
||||
ServerID string `json:"server_id"`
|
||||
Mask []string `json:"mask,omitempty"`
|
||||
}
|
||||
|
||||
func (d *commandDispatcher) Connect(serverID string) chan *pb.ServerCommand {
|
||||
ch := make(chan *pb.ServerCommand, 16)
|
||||
d.mu.Lock()
|
||||
d.channels[serverID] = ch
|
||||
d.mu.Unlock()
|
||||
return ch
|
||||
// ProxyRelayRequest asks the owner pod to bind a console relay listener and
|
||||
// register it before dispatching OpenProxyCmd.
|
||||
//
|
||||
// The listener has to live on the owner pod: the agent's ProxyStream arrives
|
||||
// there, and only there can it be matched to a waiting listener. The pod
|
||||
// serving the browser's WebSocket learns the address from the ack and hands
|
||||
// that to guacd.
|
||||
type ProxyRelayRequest struct {
|
||||
InstanceID string `json:"instance_id"`
|
||||
ProxyID string `json:"proxy_id"`
|
||||
}
|
||||
|
||||
func (d *commandDispatcher) Disconnect(serverID string) {
|
||||
d.mu.Lock()
|
||||
delete(d.channels, serverID)
|
||||
d.mu.Unlock()
|
||||
// CommandAck is the owner pod's answer.
|
||||
type CommandAck struct {
|
||||
OK bool `json:"ok"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Node string `json:"node,omitempty"`
|
||||
ProxyHost string `json:"proxy_host,omitempty"`
|
||||
ProxyPort int `json:"proxy_port,omitempty"`
|
||||
}
|
||||
|
||||
type commandDispatcher struct{}
|
||||
|
||||
var Dispatcher = &commandDispatcher{}
|
||||
|
||||
// ErrAgentNotConnected is returned when no pod holds the agent's stream.
|
||||
var ErrAgentNotConnected = errors.New("agent is not connected")
|
||||
|
||||
// Serve subscribes this pod to serverID's command channel and claims presence
|
||||
// for it, returning the channel the gRPC handler should send from and a
|
||||
// teardown function.
|
||||
//
|
||||
// send is the local delivery: it is called on the bus goroutine and must not
|
||||
// block for long, which is why it pushes onto a buffered channel rather than
|
||||
// writing to the gRPC stream directly.
|
||||
func (d *commandDispatcher) Serve(ctx context.Context, serverID string) (<-chan *pb.ServerCommand, func()) {
|
||||
out := make(chan *pb.ServerCommand, 16)
|
||||
|
||||
envelopes, unsub, err := bus.Subscribe(ctx, bus.CommandChannel+serverID)
|
||||
if err != nil {
|
||||
// Without a subscription this pod cannot receive commands for the
|
||||
// agent. Claiming presence anyway would advertise a stream nobody can
|
||||
// reach, which is worse than the agent appearing offline.
|
||||
log.Printf("dispatch: subscribe for %s failed, commands will not reach it: %v", serverID, err)
|
||||
close(out)
|
||||
return out, func() {}
|
||||
}
|
||||
|
||||
runCtx, cancel := context.WithCancel(ctx)
|
||||
|
||||
if err := bus.SetPresence(runCtx, serverID, presenceTTL); err != nil {
|
||||
log.Printf("dispatch: claim presence for %s: %v", serverID, err)
|
||||
}
|
||||
go func() {
|
||||
t := time.NewTicker(presenceRenew)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-runCtx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
if err := bus.SetPresence(runCtx, serverID, presenceTTL); err != nil {
|
||||
log.Printf("dispatch: renew presence for %s: %v", serverID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-runCtx.Done():
|
||||
return
|
||||
case raw, ok := <-envelopes:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
d.handleEnvelope(runCtx, raw, out)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return out, func() {
|
||||
cancel()
|
||||
unsub()
|
||||
relCtx, relCancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
if err := bus.ClearPresence(relCtx, serverID); err != nil {
|
||||
log.Printf("dispatch: release presence for %s: %v", serverID, err)
|
||||
}
|
||||
relCancel()
|
||||
}
|
||||
}
|
||||
|
||||
// handleEnvelope performs the owner-pod side of a dispatch: any local setup the
|
||||
// command needs, then queueing it for the stream, then the ack.
|
||||
func (d *commandDispatcher) handleEnvelope(ctx context.Context, raw []byte, out chan *pb.ServerCommand) {
|
||||
var env CommandEnvelope
|
||||
if err := json.Unmarshal(raw, &env); err != nil {
|
||||
log.Printf("dispatch: undecodable envelope: %v", err)
|
||||
return
|
||||
}
|
||||
if env.Command == nil || env.ReplyTo == "" {
|
||||
return
|
||||
}
|
||||
|
||||
ack := CommandAck{OK: true, Node: bus.NodeID()}
|
||||
|
||||
if env.Log != nil {
|
||||
if err := StepLogs.Open(env.Command.CommandId, env.Log.RunID, env.Log.ServerID, env.Log.Mask); err != nil {
|
||||
log.Printf("dispatch: open step log for %s: %v", env.Command.CommandId, err)
|
||||
}
|
||||
}
|
||||
|
||||
var relay *localRelay
|
||||
if env.Proxy != nil {
|
||||
r, err := openLocalRelay(env.Proxy.InstanceID, env.ServerID, env.Proxy.ProxyID)
|
||||
if err != nil {
|
||||
ack = CommandAck{OK: false, Error: err.Error(), Node: bus.NodeID()}
|
||||
} else {
|
||||
relay = r
|
||||
ack.ProxyHost = r.host
|
||||
ack.ProxyPort = r.port
|
||||
}
|
||||
}
|
||||
|
||||
if ack.OK {
|
||||
select {
|
||||
case out <- env.Command:
|
||||
default:
|
||||
ack = CommandAck{OK: false, Error: "command queue full", Node: bus.NodeID()}
|
||||
if relay != nil {
|
||||
relay.abandon()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := bus.Reply(ctx, env.ReplyTo, ack); err != nil {
|
||||
log.Printf("dispatch: reply on %s: %v", env.ReplyTo, err)
|
||||
}
|
||||
}
|
||||
|
||||
// IsConnected reports whether any pod holds the agent's command stream.
|
||||
func (d *commandDispatcher) IsConnected(serverID string) bool {
|
||||
d.mu.RLock()
|
||||
_, ok := d.channels[serverID]
|
||||
d.mu.RUnlock()
|
||||
return ok
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
return bus.IsConnected(ctx, serverID)
|
||||
}
|
||||
|
||||
func (d *commandDispatcher) dispatch(serverID string, cmd *pb.ServerCommand) error {
|
||||
d.mu.RLock()
|
||||
ch, ok := d.channels[serverID]
|
||||
d.mu.RUnlock()
|
||||
if !ok {
|
||||
return fmt.Errorf("agent for server %s is not connected", serverID)
|
||||
}
|
||||
select {
|
||||
case ch <- cmd:
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("command queue full for server %s", serverID)
|
||||
}
|
||||
_, err := d.send(CommandEnvelope{ServerID: serverID, Command: cmd})
|
||||
return err
|
||||
}
|
||||
|
||||
func DispatchRunStep(serverID, commandID string, cmd *pb.RunStepCmd) error {
|
||||
return Dispatcher.dispatch(serverID, &pb.ServerCommand{CommandId: commandID, RunStep: cmd})
|
||||
// send publishes the envelope and waits for the owning pod's ack. A missing
|
||||
// responder and a refusing responder are both errors: in neither case did the
|
||||
// command reach the agent.
|
||||
func (d *commandDispatcher) send(env CommandEnvelope) (CommandAck, error) {
|
||||
if env.Command == nil || env.Command.CommandId == "" {
|
||||
return CommandAck{}, fmt.Errorf("command id is required")
|
||||
}
|
||||
env.ReplyTo = bus.AckChannelFor(env.Command.CommandId)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), dispatchAckTimeout)
|
||||
defer cancel()
|
||||
|
||||
raw, err := bus.Request(ctx, bus.CommandChannel+env.ServerID, env.ReplyTo, env, dispatchAckTimeout)
|
||||
if err != nil {
|
||||
if errors.Is(err, bus.ErrNoResponder) {
|
||||
return CommandAck{}, fmt.Errorf("%w: %s", ErrAgentNotConnected, env.ServerID)
|
||||
}
|
||||
return CommandAck{}, err
|
||||
}
|
||||
|
||||
var ack CommandAck
|
||||
if err := json.Unmarshal(raw, &ack); err != nil {
|
||||
return CommandAck{}, fmt.Errorf("undecodable ack: %w", err)
|
||||
}
|
||||
if !ack.OK {
|
||||
return ack, fmt.Errorf("agent for server %s: %s", env.ServerID, ack.Error)
|
||||
}
|
||||
return ack, nil
|
||||
}
|
||||
|
||||
// DispatchRunStep sends a step to the agent and asks the owning pod to capture
|
||||
// its output. mask holds the secret values that must not reach the log.
|
||||
func DispatchRunStep(serverID, commandID, runID string, mask []string, cmd *pb.RunStepCmd) error {
|
||||
_, err := Dispatcher.send(CommandEnvelope{
|
||||
ServerID: serverID,
|
||||
Command: &pb.ServerCommand{CommandId: commandID, RunStep: cmd},
|
||||
Log: &LogRequest{RunID: runID, ServerID: serverID, Mask: mask},
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func DispatchCleanupWorkspace(serverID, workspaceID string) {
|
||||
|
||||
@@ -2,9 +2,8 @@ package services
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -12,47 +11,130 @@ import (
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
)
|
||||
|
||||
func WorkflowLogDir() string {
|
||||
dir := os.Getenv("VANTAGE_WORKFLOW_LOG_DIR")
|
||||
if dir == "" {
|
||||
dir = filepath.Join("data", "workflow-logs")
|
||||
}
|
||||
_ = os.MkdirAll(dir, 0700)
|
||||
return dir
|
||||
}
|
||||
// Workflow run logs live in MongoDB, not on disk.
|
||||
//
|
||||
// They used to be a file per (run, server) under a volume. That is the cheapest
|
||||
// possible writer, and it is wrong the moment there is more than one server
|
||||
// process: step output arrives on whichever pod holds the agent's stream, the
|
||||
// run's markers are written by whichever pod started the run, and the browser
|
||||
// asks for the log through whichever pod the load balancer picked. Three pods,
|
||||
// one file, one local disk — two of them see an empty log.
|
||||
//
|
||||
// Mongo makes every pod an equal reader and writer, which is the property that
|
||||
// matters. It costs writes on the hot path, so the writer batches (see
|
||||
// stepLogWriter) and both caps below exist to keep a runaway step from turning
|
||||
// a workflow into a database incident.
|
||||
const (
|
||||
// A single line longer than this is truncated. Base64 blobs and minified
|
||||
// output are the usual cause; nobody reads column 9000 of a log line.
|
||||
maxLogLineBytes = 8 * 1024
|
||||
|
||||
func ServerRunLogPath(runID, serverID string) string {
|
||||
return filepath.Join(WorkflowLogDir(), runID, serverID+".log")
|
||||
// A single (run, server) log stops accepting lines here, with one final
|
||||
// marker saying so. 200k lines is far past what anyone reads and still
|
||||
// only a few tens of MB. Without a cap, `yes` in a step fills the database.
|
||||
maxLogLines = 200_000
|
||||
|
||||
// The writer flushes on whichever comes first. Batching is what keeps a
|
||||
// chatty step to a handful of writes a second instead of one per line.
|
||||
logFlushLines = 128
|
||||
logFlushInterval = 250 * time.Millisecond
|
||||
)
|
||||
|
||||
const logLinesCol = "workflow_log_lines"
|
||||
const logSeqCol = "workflow_log_seq"
|
||||
|
||||
func logCtx() (context.Context, context.CancelFunc) {
|
||||
return context.WithTimeout(context.Background(), 10*time.Second)
|
||||
}
|
||||
|
||||
func logTS() string {
|
||||
return time.Now().UTC().Format("2006-01-02T15:04:05.000") + "Z"
|
||||
}
|
||||
|
||||
func AppendMarker(runID, serverID, text string) (int64, error) {
|
||||
path := ServerRunLogPath(runID, serverID)
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
|
||||
return 0, err
|
||||
func seqID(runID, serverID string) string { return runID + "/" + serverID }
|
||||
|
||||
// reserveSeq atomically claims n consecutive sequence numbers for a
|
||||
// (run, server) log and returns the first.
|
||||
//
|
||||
// The counter is a document rather than a per-process integer because two pods
|
||||
// write the same log concurrently: the run's pod emits markers while the
|
||||
// agent's pod emits step output. Ordering between them is only meaningful if
|
||||
// they draw from the same counter.
|
||||
func reserveSeq(ctx context.Context, runID, serverID string, n int) (int64, error) {
|
||||
var doc struct {
|
||||
Seq int64 `bson:"seq"`
|
||||
}
|
||||
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
|
||||
err := db.Col(logSeqCol).FindOneAndUpdate(ctx,
|
||||
bson.M{"_id": seqID(runID, serverID)},
|
||||
bson.M{"$inc": bson.M{"seq": int64(n)}},
|
||||
options.FindOneAndUpdate().SetUpsert(true).SetReturnDocument(options.After),
|
||||
).Decode(&doc)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer f.Close()
|
||||
off, _ := f.Seek(0, 2)
|
||||
if _, err := f.WriteString("[" + logTS() + "] " + text + "\n"); err != nil {
|
||||
return off, err
|
||||
}
|
||||
return off, nil
|
||||
return doc.Seq - int64(n), nil
|
||||
}
|
||||
|
||||
type logLine struct {
|
||||
RunID string `bson:"run_id"`
|
||||
ServerID string `bson:"server_id"`
|
||||
Seq int64 `bson:"seq"`
|
||||
At time.Time `bson:"at"`
|
||||
Line string `bson:"line"`
|
||||
}
|
||||
|
||||
// writeLines reserves a block of sequence numbers and inserts the batch.
|
||||
func writeLines(ctx context.Context, runID, serverID string, lines []string) (int64, error) {
|
||||
if len(lines) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
start, err := reserveSeq(ctx, runID, serverID, len(lines))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
docs := make([]any, 0, len(lines))
|
||||
for i, l := range lines {
|
||||
docs = append(docs, logLine{
|
||||
RunID: runID,
|
||||
ServerID: serverID,
|
||||
Seq: start + int64(i),
|
||||
At: now,
|
||||
Line: l,
|
||||
})
|
||||
}
|
||||
// Unordered: one rejected document must not discard the rest of the batch.
|
||||
_, err = db.Col(logLinesCol).InsertMany(ctx, docs, options.InsertMany().SetOrdered(false))
|
||||
return start, err
|
||||
}
|
||||
|
||||
// AppendMarker writes one control line (step banners, retries, run outcome) and
|
||||
// returns its sequence number, which is what a StepRun's log_offset records so
|
||||
// the UI can scroll to where a step began.
|
||||
func AppendMarker(runID, serverID, text string) (int64, error) {
|
||||
ctx, cancel := logCtx()
|
||||
defer cancel()
|
||||
return writeLines(ctx, runID, serverID, []string{"[" + logTS() + "] " + text})
|
||||
}
|
||||
|
||||
// stepLogWriter accumulates one command's output, splits it into lines, masks
|
||||
// secrets and flushes batches to Mongo.
|
||||
type stepLogWriter struct {
|
||||
mu sync.Mutex
|
||||
f *os.File
|
||||
mu sync.Mutex
|
||||
runID string
|
||||
serverID string
|
||||
secrets []string
|
||||
|
||||
carry []byte
|
||||
secrets []string
|
||||
pending []string
|
||||
written int
|
||||
capped bool
|
||||
|
||||
stop chan struct{}
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
type stepLogRegistry struct {
|
||||
@@ -60,18 +142,30 @@ type stepLogRegistry struct {
|
||||
writers map[string]*stepLogWriter
|
||||
}
|
||||
|
||||
// The registry stays process-local, and correctly so: a step's output arrives
|
||||
// on the pod holding that agent's stream, and that is the same pod the
|
||||
// dispatch envelope asked to open the writer. Nothing here crosses pods —
|
||||
// only the lines it produces do, by virtue of landing in Mongo.
|
||||
var StepLogs = &stepLogRegistry{writers: make(map[string]*stepLogWriter)}
|
||||
|
||||
func (r *stepLogRegistry) Open(commandID, path string, secrets []string) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
|
||||
return err
|
||||
func (r *stepLogRegistry) Open(commandID, runID, serverID string, secrets []string) error {
|
||||
w := &stepLogWriter{
|
||||
runID: runID,
|
||||
serverID: serverID,
|
||||
secrets: secrets,
|
||||
stop: make(chan struct{}),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
w := &stepLogWriter{f: f, secrets: secrets}
|
||||
go w.flushLoop()
|
||||
|
||||
r.mu.Lock()
|
||||
if old := r.writers[commandID]; old != nil {
|
||||
// A retry reuses the command ID. Close the previous attempt's writer
|
||||
// rather than leaking its flush goroutine.
|
||||
r.mu.Unlock()
|
||||
old.close()
|
||||
r.mu.Lock()
|
||||
}
|
||||
r.writers[commandID] = w
|
||||
r.mu.Unlock()
|
||||
return nil
|
||||
@@ -89,24 +183,22 @@ func (r *stepLogRegistry) Append(commandID string, data []byte) {
|
||||
return
|
||||
}
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
buf := append(w.carry, data...)
|
||||
for {
|
||||
i := bytes.IndexByte(buf, '\n')
|
||||
if i < 0 {
|
||||
break
|
||||
}
|
||||
w.writeLine(buf[:i])
|
||||
w.queueLine(buf[:i])
|
||||
buf = buf[i+1:]
|
||||
}
|
||||
w.carry = append([]byte{}, buf...)
|
||||
}
|
||||
full := len(w.pending) >= logFlushLines
|
||||
w.mu.Unlock()
|
||||
|
||||
func (w *stepLogWriter) writeLine(line []byte) {
|
||||
masked := maskBytes(line, w.secrets)
|
||||
_, _ = w.f.WriteString("[" + logTS() + "] ")
|
||||
_, _ = w.f.Write(masked)
|
||||
_, _ = w.f.WriteString("\n")
|
||||
if full {
|
||||
w.flush()
|
||||
}
|
||||
}
|
||||
|
||||
func (r *stepLogRegistry) Close(commandID string) {
|
||||
@@ -117,13 +209,72 @@ func (r *stepLogRegistry) Close(commandID string) {
|
||||
if w == nil {
|
||||
return
|
||||
}
|
||||
w.close()
|
||||
}
|
||||
|
||||
// queueLine masks, truncates and enqueues a line. Caller holds w.mu.
|
||||
func (w *stepLogWriter) queueLine(line []byte) {
|
||||
if w.capped {
|
||||
return
|
||||
}
|
||||
if w.written+len(w.pending) >= maxLogLines {
|
||||
w.capped = true
|
||||
w.pending = append(w.pending, "["+logTS()+"] [vantage] log truncated: this step exceeded the per-server line limit")
|
||||
return
|
||||
}
|
||||
masked := maskBytes(line, w.secrets)
|
||||
if len(masked) > maxLogLineBytes {
|
||||
masked = append(masked[:maxLogLineBytes], []byte(" [truncated]")...)
|
||||
}
|
||||
w.pending = append(w.pending, "["+logTS()+"] "+string(masked))
|
||||
}
|
||||
|
||||
func (w *stepLogWriter) flush() {
|
||||
w.mu.Lock()
|
||||
if len(w.pending) == 0 {
|
||||
w.mu.Unlock()
|
||||
return
|
||||
}
|
||||
batch := w.pending
|
||||
w.pending = nil
|
||||
w.written += len(batch)
|
||||
runID, serverID := w.runID, w.serverID
|
||||
w.mu.Unlock()
|
||||
|
||||
ctx, cancel := logCtx()
|
||||
defer cancel()
|
||||
if _, err := writeLines(ctx, runID, serverID, batch); err != nil {
|
||||
// Dropped rather than retried. A log line is not worth stalling the
|
||||
// step it describes, and a Mongo that cannot take writes has larger
|
||||
// problems than a missing line.
|
||||
log.Printf("step log: write %d line(s) for run %s: %v", len(batch), runID, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (w *stepLogWriter) flushLoop() {
|
||||
defer close(w.done)
|
||||
t := time.NewTicker(logFlushInterval)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-w.stop:
|
||||
return
|
||||
case <-t.C:
|
||||
w.flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *stepLogWriter) close() {
|
||||
close(w.stop)
|
||||
<-w.done
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if len(w.carry) > 0 {
|
||||
w.writeLine(w.carry)
|
||||
w.queueLine(w.carry)
|
||||
w.carry = nil
|
||||
}
|
||||
_ = w.f.Close()
|
||||
w.mu.Unlock()
|
||||
w.flush()
|
||||
}
|
||||
|
||||
func maskBytes(b []byte, secrets []string) []byte {
|
||||
@@ -137,86 +288,132 @@ func maskBytes(b []byte, secrets []string) []byte {
|
||||
return []byte(s)
|
||||
}
|
||||
|
||||
func StartLogSweeper() {
|
||||
// ReadServerRunLog returns the log for one server in a run, from sequence
|
||||
// number after onwards, along with the highest sequence returned.
|
||||
//
|
||||
// Callers page with it rather than fetching everything: the live stream asks
|
||||
// repeatedly for what is new, and the plain-text endpoint walks the whole log
|
||||
// in chunks so a very large one is never held in memory whole.
|
||||
func ReadServerRunLog(runID, serverID string, after int64, limit int) ([]string, int64, error) {
|
||||
ctx, cancel := logCtx()
|
||||
defer cancel()
|
||||
|
||||
cur, err := db.Col(logLinesCol).Find(ctx,
|
||||
bson.M{"run_id": runID, "server_id": serverID, "seq": bson.M{"$gt": after}},
|
||||
options.Find().SetSort(bson.D{{Key: "seq", Value: 1}}).SetLimit(int64(limit)),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, after, err
|
||||
}
|
||||
defer cur.Close(ctx)
|
||||
|
||||
lines := make([]string, 0, 64)
|
||||
last := after
|
||||
for cur.Next(ctx) {
|
||||
var l logLine
|
||||
if err := cur.Decode(&l); err != nil {
|
||||
return lines, last, err
|
||||
}
|
||||
lines = append(lines, l.Line)
|
||||
last = l.Seq
|
||||
}
|
||||
return lines, last, cur.Err()
|
||||
}
|
||||
|
||||
// HasServerRunLog reports whether any line exists, so a log read can answer 404
|
||||
// rather than 200 with an empty body.
|
||||
func HasServerRunLog(runID, serverID string) bool {
|
||||
ctx, cancel := logCtx()
|
||||
defer cancel()
|
||||
err := db.Col(logLinesCol).FindOne(ctx, bson.M{"run_id": runID, "server_id": serverID}).Err()
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func StartLogSweeper(ctx context.Context) {
|
||||
go func() {
|
||||
sweepLogs()
|
||||
t := time.NewTicker(time.Hour)
|
||||
defer t.Stop()
|
||||
for range t.C {
|
||||
sweepLogs()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
sweepLogs()
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// sweepLogs deletes the lines of finished runs past their instance's retention.
|
||||
//
|
||||
// It walks workflow_runs rather than the log collection: retention is a
|
||||
// property of the run (its instance, its finish time), and a run row is the
|
||||
// only place both are recorded.
|
||||
func sweepLogs() {
|
||||
base := WorkflowLogDir()
|
||||
entries, err := os.ReadDir(base)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
cur, err := db.Col("workflow_runs").Find(ctx,
|
||||
bson.M{"finished_at": bson.M{"$ne": nil}},
|
||||
options.Find().SetProjection(bson.M{"run_id": 1, "instance_id": 1, "finished_at": 1}),
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf("log sweep: list runs: %v", err)
|
||||
return
|
||||
}
|
||||
defer cur.Close(ctx)
|
||||
|
||||
cache := map[string]int{}
|
||||
now := time.Now()
|
||||
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
for cur.Next(ctx) {
|
||||
var run struct {
|
||||
RunID string `bson:"run_id"`
|
||||
InstanceID string `bson:"instance_id"`
|
||||
FinishedAt *time.Time `bson:"finished_at"`
|
||||
}
|
||||
runID := e.Name()
|
||||
dir := filepath.Join(base, runID)
|
||||
|
||||
instanceID, finishedAt, found, err := runRetentionInfo(runID)
|
||||
if err != nil {
|
||||
|
||||
log.Printf("log sweep: retention lookup failed for run %s: %v", runID, err)
|
||||
continue
|
||||
}
|
||||
if found && finishedAt == nil {
|
||||
if err := cur.Decode(&run); err != nil || run.FinishedAt == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
days, ok := cache[instanceID]
|
||||
days, ok := cache[run.InstanceID]
|
||||
if !ok {
|
||||
days = defaultRetentionDays
|
||||
if instanceID != "" {
|
||||
if v, err := GetWorkflowLogRetentionDays(instanceID); err == nil {
|
||||
if run.InstanceID != "" {
|
||||
if v, err := GetWorkflowLogRetentionDays(run.InstanceID); err == nil {
|
||||
days = v
|
||||
}
|
||||
}
|
||||
cache[instanceID] = days
|
||||
cache[run.InstanceID] = days
|
||||
}
|
||||
if days <= 0 {
|
||||
continue
|
||||
}
|
||||
cutoff := now.AddDate(0, 0, -days)
|
||||
|
||||
if found {
|
||||
if finishedAt.Before(cutoff) {
|
||||
_ = os.RemoveAll(dir)
|
||||
}
|
||||
if !run.FinishedAt.Before(now.AddDate(0, 0, -days)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if fi, e := os.Stat(dir); e == nil && fi.ModTime().Before(cutoff) {
|
||||
_ = os.RemoveAll(dir)
|
||||
if _, err := db.Col(logLinesCol).DeleteMany(ctx, bson.M{"run_id": run.RunID}); err != nil {
|
||||
log.Printf("log sweep: delete lines for run %s: %v", run.RunID, err)
|
||||
continue
|
||||
}
|
||||
_, _ = db.Col(logSeqCol).DeleteMany(ctx, bson.M{"_id": bson.M{"$regex": "^" + run.RunID + "/"}})
|
||||
}
|
||||
}
|
||||
|
||||
const defaultRetentionDays = 30
|
||||
|
||||
func runRetentionInfo(runID string) (string, *time.Time, bool, error) {
|
||||
ctx, cancel := wfCtx()
|
||||
// EnsureLogIndexes builds the indexes the log store depends on. The compound
|
||||
// index is not an optimisation: every read is a range scan over it, and without
|
||||
// it a log read collection-scans every line in the database.
|
||||
func EnsureLogIndexes() error {
|
||||
ctx, cancel := logCtx()
|
||||
defer cancel()
|
||||
var run struct {
|
||||
InstanceID string `bson:"instance_id"`
|
||||
FinishedAt *time.Time `bson:"finished_at"`
|
||||
if _, err := db.Col(logLinesCol).Indexes().CreateOne(ctx, mongo.IndexModel{
|
||||
Keys: bson.D{{Key: "run_id", Value: 1}, {Key: "server_id", Value: 1}, {Key: "seq", Value: 1}},
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
err := db.Col("workflow_runs").FindOne(ctx, bson.M{"run_id": runID}).Decode(&run)
|
||||
if err == mongo.ErrNoDocuments {
|
||||
return "", nil, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", nil, false, err
|
||||
}
|
||||
return run.InstanceID, run.FinishedAt, true, nil
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,43 +1,75 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/bus"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/grpc/pb"
|
||||
)
|
||||
|
||||
type stepResultRegistry struct {
|
||||
mu sync.Mutex
|
||||
pending map[string]chan *pb.StepResult
|
||||
}
|
||||
|
||||
var StepResults = &stepResultRegistry{pending: make(map[string]chan *pb.StepResult)}
|
||||
|
||||
func (r *stepResultRegistry) Await(commandID string) <-chan *pb.StepResult {
|
||||
ch := make(chan *pb.StepResult, 1)
|
||||
r.mu.Lock()
|
||||
r.pending[commandID] = ch
|
||||
r.mu.Unlock()
|
||||
return ch
|
||||
}
|
||||
|
||||
func (r *stepResultRegistry) Cancel(commandID string) {
|
||||
r.mu.Lock()
|
||||
delete(r.pending, commandID)
|
||||
r.mu.Unlock()
|
||||
// Step results travel back over the bus for the same reason commands travel out
|
||||
// over it: the pod driving a workflow run and the pod holding the agent's
|
||||
// stream are two different processes, and a map in one of them cannot be read
|
||||
// by the other.
|
||||
//
|
||||
// Await subscribes before the command is dispatched (see dispatchAndWait),
|
||||
// which is what stops a fast agent from answering into a channel nobody is
|
||||
// listening on yet.
|
||||
|
||||
type stepResultRegistry struct{}
|
||||
|
||||
var StepResults = &stepResultRegistry{}
|
||||
|
||||
// Await subscribes to a command's result channel. The returned cancel function
|
||||
// must be called once the caller is done, whether a result arrived or not —
|
||||
// it is what releases the Redis subscription.
|
||||
func (r *stepResultRegistry) Await(commandID string) (<-chan *pb.StepResult, func()) {
|
||||
out := make(chan *pb.StepResult, 1)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
raw, unsub, err := bus.Subscribe(ctx, bus.ResultChannel+commandID)
|
||||
if err != nil {
|
||||
log.Printf("step results: subscribe for %s: %v", commandID, err)
|
||||
cancel()
|
||||
close(out)
|
||||
return out, func() {}
|
||||
}
|
||||
|
||||
go func() {
|
||||
defer close(out)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case b, ok := <-raw:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var res pb.StepResult
|
||||
if err := json.Unmarshal(b, &res); err != nil {
|
||||
log.Printf("step results: undecodable result for %s: %v", commandID, err)
|
||||
return
|
||||
}
|
||||
out <- &res
|
||||
}
|
||||
}()
|
||||
|
||||
return out, func() {
|
||||
cancel()
|
||||
unsub()
|
||||
}
|
||||
}
|
||||
|
||||
// Deliver publishes a result received from an agent. Called on the pod holding
|
||||
// that agent's stream, which is not usually the pod waiting for it.
|
||||
func (r *stepResultRegistry) Deliver(res *pb.StepResult) {
|
||||
if res == nil {
|
||||
if res == nil || res.CommandId == "" {
|
||||
return
|
||||
}
|
||||
r.mu.Lock()
|
||||
ch, ok := r.pending[res.CommandId]
|
||||
if ok {
|
||||
delete(r.pending, res.CommandId)
|
||||
}
|
||||
r.mu.Unlock()
|
||||
if ok {
|
||||
ch <- res
|
||||
ctx, cancel := context.WithTimeout(context.Background(), dispatchAckTimeout)
|
||||
defer cancel()
|
||||
if _, err := bus.Publish(ctx, bus.ResultChannel+res.CommandId, res); err != nil {
|
||||
log.Printf("step results: publish for %s: %v", res.CommandId, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -240,7 +240,6 @@ func runServer(instanceID, runID string, srvIdx int, steps []models.ResolvedStep
|
||||
|
||||
marker := fmt.Sprintf("===== step %d/%d: %s (%s) =====", step.Order+1, len(steps), step.Name, step.Interpreter)
|
||||
offset, _ := AppendMarker(runID, serverID, marker)
|
||||
logPath := ServerRunLogPath(runID, serverID)
|
||||
secretsSlice := secretValues(secretVals)
|
||||
|
||||
commandID := uuid.New().String()
|
||||
@@ -250,15 +249,17 @@ func runServer(instanceID, runID string, srvIdx int, steps []models.ResolvedStep
|
||||
_, _ = AppendMarker(runID, serverID, fmt.Sprintf("retry %d/%d after failure", attempts-1, maxAttempts-1))
|
||||
}
|
||||
|
||||
_ = StepLogs.Open(commandID, logPath, secretsSlice)
|
||||
res = dispatchAndWait(serverID, commandID, &pb.RunStepCmd{
|
||||
// The log writer is opened by the pod that owns this agent's
|
||||
// stream, not here: that is where the output arrives, and the mask
|
||||
// list travels with the dispatch so it is applied before anything
|
||||
// is stored.
|
||||
res = dispatchAndWait(serverID, commandID, runID, secretsSlice, &pb.RunStepCmd{
|
||||
Interpreter: step.Interpreter,
|
||||
Script: step.Script,
|
||||
Env: cmdEnv,
|
||||
TimeoutSeconds: 0,
|
||||
WorkspaceId: runID,
|
||||
})
|
||||
StepLogs.Close(commandID)
|
||||
if res != nil && res.ExitCode == 0 {
|
||||
break
|
||||
}
|
||||
@@ -322,10 +323,14 @@ func runServer(instanceID, runID string, srvIdx int, steps []models.ResolvedStep
|
||||
})
|
||||
}
|
||||
|
||||
func dispatchAndWait(serverID, commandID string, cmd *pb.RunStepCmd) *pb.StepResult {
|
||||
ch := StepResults.Await(commandID)
|
||||
if err := DispatchRunStep(serverID, commandID, cmd); err != nil {
|
||||
StepResults.Cancel(commandID)
|
||||
// dispatchAndWait subscribes to the result before dispatching, because the two
|
||||
// happen on different pods and an agent that answers quickly would otherwise
|
||||
// publish into a channel this process had not yet joined.
|
||||
func dispatchAndWait(serverID, commandID, runID string, mask []string, cmd *pb.RunStepCmd) *pb.StepResult {
|
||||
ch, done := StepResults.Await(commandID)
|
||||
defer done()
|
||||
|
||||
if err := DispatchRunStep(serverID, commandID, runID, mask, cmd); err != nil {
|
||||
return &pb.StepResult{ExitCode: 1, Stderr: "[vantage] dispatch failed: " + err.Error()}
|
||||
}
|
||||
wait := time.Duration(cmd.TimeoutSeconds)*time.Second + stepDispatchGrace
|
||||
@@ -333,10 +338,12 @@ func dispatchAndWait(serverID, commandID string, cmd *pb.RunStepCmd) *pb.StepRes
|
||||
wait = 30*time.Minute + stepDispatchGrace
|
||||
}
|
||||
select {
|
||||
case res := <-ch:
|
||||
case res, ok := <-ch:
|
||||
if !ok || res == nil {
|
||||
return &pb.StepResult{ExitCode: 1, Stderr: "[vantage] lost the result channel before the agent answered"}
|
||||
}
|
||||
return res
|
||||
case <-time.After(wait):
|
||||
StepResults.Cancel(commandID)
|
||||
return &pb.StepResult{ExitCode: 124, Stderr: "[vantage] timed out waiting for agent result"}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,6 +60,9 @@ func EnsureWorkflowIndexes() error {
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := EnsureLogIndexes(); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := db.Col("workflow_runs").Indexes().CreateOne(ctx, mongo.IndexModel{
|
||||
Keys: bson.D{{Key: "run_id", Value: 1}}, Options: options.Index().SetUnique(true),
|
||||
})
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
// Liveness and readiness for the Next server itself. Deliberately at /healthz
|
||||
// and not /api/healthz: next.config.ts rewrites the whole of /api to the Go
|
||||
// server, so a probe there would report the backend's health instead of this
|
||||
// process's — and would keep passing while this pod was wedged.
|
||||
//
|
||||
// It answers without touching the backend on purpose. web is stateless; a
|
||||
// backend outage must not take every web replica out of its Service as well,
|
||||
// which would turn one failure into two.
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export function GET() {
|
||||
return NextResponse.json({ status: "ok" });
|
||||
}
|
||||
+1
-10
@@ -4,19 +4,10 @@ import type { NextConfig } from "next";
|
||||
// process, never in the browser, so this never needed the NEXT_PUBLIC_ prefix
|
||||
// that pins a value into the image at build time. NEXT_PUBLIC_API_URL is still
|
||||
// honoured so an existing deployment passing it keeps working.
|
||||
const apiUrl =
|
||||
process.env.API_URL ?? process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8080";
|
||||
const apiUrl = process.env.API_URL ?? process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8080";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
async redirects() {
|
||||
return [
|
||||
// Members and SSO moved onto /settings. Permanent, because the old
|
||||
// page is gone rather than temporarily unavailable — but it costs
|
||||
// nothing to keep an old bookmark or a linked support reply working.
|
||||
{ source: "/settings/instance", destination: "/settings", permanent: true },
|
||||
];
|
||||
},
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user