Compare commits
38
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bbf9f72fd3 | ||
|
|
978b665aa6 | ||
|
|
1fe608f531 | ||
|
|
1e1546cb60 | ||
|
|
119d8694d1 | ||
|
|
8d43c689f5 | ||
|
|
05f10ed3c9 | ||
|
|
c0bec3737b | ||
|
|
59d147fe4d | ||
|
|
9e38a01e3d | ||
|
|
20a302f84a | ||
|
|
ba2e263d00 | ||
|
|
a000703199 | ||
|
|
8fcda63742 | ||
|
|
3363ac9dad | ||
|
|
a7e338b171 | ||
|
|
bc79daab48 | ||
|
|
d3d8dba3ff | ||
|
|
6d047e25ab | ||
|
|
ed4c39650c | ||
|
|
7b8fa4a8a0 | ||
|
|
8a02c35ec9 | ||
|
|
487de34a50 | ||
|
|
0424547dd4 | ||
|
|
5be9ddb2e5 | ||
|
|
bc6c7cdb5a | ||
|
|
f46fb7fc0e | ||
|
|
d9945882e5 | ||
|
|
9db16522e3 | ||
|
|
6070972f9a | ||
|
|
31e0000306 | ||
|
|
26567766a9 | ||
|
|
97dc6feaca | ||
|
|
f10e61cde4 | ||
|
|
febe48a974 | ||
|
|
766dcabfde | ||
|
|
c9dab99271 | ||
|
|
60a4ed9aab |
@@ -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
|
||||
@@ -77,15 +77,17 @@ jobs:
|
||||
# generated pb is committed under server/, but a proto change
|
||||
# that someone regenerates in the same push should not depend
|
||||
# on that ordering.
|
||||
flag server '^(server/|shared/|proto/|go\.work)'
|
||||
flag server '^(server/|shared/|proto/|default_steps/|go\.work)'
|
||||
flag sitesvc '^(sitesvc/|shared/|go\.work)'
|
||||
flag admin '^(admin/|shared/|go\.work)'
|
||||
|
||||
# The three Next images use their own directory as the build
|
||||
# context, so nothing outside it can affect them.
|
||||
# The three Next images and the docs site use their own
|
||||
# directory as the build context, so nothing outside it can
|
||||
# affect them.
|
||||
flag web '^web/'
|
||||
flag site '^site/'
|
||||
flag adminsite '^adminsite/'
|
||||
flag docsite '^docsite/'
|
||||
|
||||
- name: Log in to registry
|
||||
run: |
|
||||
@@ -106,7 +108,6 @@ jobs:
|
||||
run: |
|
||||
IMAGE="${{ vars.DOCKER_HOST }}/${{ github.repository_owner }}/vantage/web:latest"
|
||||
docker build \
|
||||
--build-arg NEXT_PUBLIC_API_URL="${{ vars.API_URL }}" \
|
||||
--build-arg NEXT_PUBLIC_HQ_URL="${{ vars.HQ_URL }}" \
|
||||
-t "$IMAGE" \
|
||||
-f web/Dockerfile web/
|
||||
@@ -149,6 +150,18 @@ 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"
|
||||
|
||||
- name: Build and push docsite image
|
||||
if: steps.changed.outputs.docsite == 'true'
|
||||
run: |
|
||||
IMAGE="${{ vars.DOCKER_HOST }}/${{ github.repository_owner }}/vantage/docsite:latest"
|
||||
# DOCS_BASE_URL must match the proxy location that routes to
|
||||
# this container and the directory the image serves from.
|
||||
docker build \
|
||||
-t "$IMAGE" \
|
||||
-f docsite/Dockerfile docsite/
|
||||
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
|
||||
|
||||
@@ -20,19 +20,14 @@ export default function BillingPage() {
|
||||
// Each subscription names the instance it pays for, because tier and term
|
||||
// are per-licence rather than per-account. Resolving the name here is the
|
||||
// difference between "professional · annual" and knowing which install that is.
|
||||
const nameFor = (instanceId?: string) =>
|
||||
account.data?.instances.find((i) => i.instance_id === instanceId)?.name;
|
||||
const nameFor = (instanceId?: string) => account.data?.instances.find((i) => i.instance_id === instanceId)?.name;
|
||||
|
||||
return (
|
||||
<div className="grid gap-6">
|
||||
<PageHeader
|
||||
title="Billing"
|
||||
subtitle="One subscription per instance — each carries its own tier and term."
|
||||
record={
|
||||
account.data
|
||||
? [{ key: "Billing", value: account.data.account.billing_email }]
|
||||
: undefined
|
||||
}
|
||||
subtitle="One subscription per instance each carries its own tier and term."
|
||||
record={account.data ? [{ key: "Billing", value: account.data.account.billing_email }] : undefined}
|
||||
/>
|
||||
|
||||
<PageFrame
|
||||
@@ -51,18 +46,10 @@ export default function BillingPage() {
|
||||
/>
|
||||
</RailCard>
|
||||
<RailCard title="Need a change?">
|
||||
<p className="text-[0.82rem] text-ink-2">
|
||||
Change a card, download an invoice or cancel from the billing
|
||||
portal. It covers every subscription on this account.
|
||||
</p>
|
||||
<p className="text-[0.82rem] text-ink-2">Change a card, download an invoice or cancel from the billing portal. It covers every subscription on this account.</p>
|
||||
<ManageBillingButton />
|
||||
<p className="text-[0.82rem] text-ink-2">
|
||||
Anything else, email support.
|
||||
</p>
|
||||
<a
|
||||
href="mailto:support@hostxtra.co.uk"
|
||||
className="text-[0.82rem] font-semibold text-accent underline"
|
||||
>
|
||||
<p className="text-[0.82rem] text-ink-2">Anything else, email support.</p>
|
||||
<a href="mailto:support@hostxtra.co.uk" className="text-[0.82rem] font-semibold text-accent underline">
|
||||
support@hostxtra.co.uk
|
||||
</a>
|
||||
</RailCard>
|
||||
@@ -70,10 +57,7 @@ export default function BillingPage() {
|
||||
}
|
||||
>
|
||||
{rows.length === 0 ? (
|
||||
<p className="rounded border border-rule bg-panel p-5 text-ink-2">
|
||||
You have no subscriptions. Cloud instances and self-hosted licences are
|
||||
both bought from the pricing page.
|
||||
</p>
|
||||
<p className="rounded border border-rule bg-panel p-5 text-ink-2">You have no subscriptions. Cloud instances and self-hosted licences are both bought from the pricing page.</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded border border-rule bg-panel">
|
||||
<table className="w-full border-collapse text-left">
|
||||
@@ -88,21 +72,12 @@ export default function BillingPage() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((s) => (
|
||||
<tr
|
||||
key={s.subscription_id}
|
||||
className="border-b border-rule-soft last:border-0"
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
{nameFor(s.instance_id) ?? (
|
||||
<span className="text-ink-3">Not linked yet</span>
|
||||
)}
|
||||
</td>
|
||||
<tr key={s.subscription_id} className="border-b border-rule-soft last:border-0">
|
||||
<td className="px-4 py-3">{nameFor(s.instance_id) ?? <span className="text-ink-3">Not linked yet</span>}</td>
|
||||
<td className="px-4 py-3">{s.tier.replace("_", " ")}</td>
|
||||
<td className="px-4 py-3">{s.term}</td>
|
||||
<td className="px-4 py-3">{s.status}</td>
|
||||
<td className="px-4 py-3 font-mono tabular-nums">
|
||||
{formatDate(s.current_period_end)}
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono tabular-nums">{formatDate(s.current_period_end)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
@@ -11,7 +11,7 @@ export default function LinkPage() {
|
||||
const qc = useQueryClient();
|
||||
// This page only claims a PAID placeholder's real install UUID. Self-hosted
|
||||
// Free is created on the purchase page, so with no placeholder to claim there
|
||||
// is nothing to do here — send them there.
|
||||
// is nothing to do here send them there.
|
||||
const claimId = useSearchParams().get("claim") ?? undefined;
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -6,8 +6,8 @@ import { RequireKind } from "@/lib/session";
|
||||
import { AppBar, type NavLink } from "@/components/AppBar";
|
||||
|
||||
/*
|
||||
* Three destinations, not five. Settings moved into the account menu — it is
|
||||
* your password, not a place — and Instances went with it, because Overview
|
||||
* Three destinations, not five. Settings moved into the account menu it is
|
||||
* your password, not a place and Instances went with it, because Overview
|
||||
* already lists them and a second door to the same room is just a second thing
|
||||
* to keep in sync. Linking an install is an action, so it is a button on
|
||||
* Overview rather than a permanent nav entry.
|
||||
@@ -22,11 +22,7 @@ function AccountName() {
|
||||
// Shares the ["account"] key with Overview, so this costs no extra request.
|
||||
const { data } = useQuery({ queryKey: ["account"], queryFn: api.account });
|
||||
if (!data) return null;
|
||||
return (
|
||||
<span className="block truncate text-[0.92rem] font-bold tracking-[-0.01em]">
|
||||
{data.account.name}
|
||||
</span>
|
||||
);
|
||||
return <span className="block truncate text-[0.92rem] font-bold tracking-[-0.01em]">{data.account.name}</span>;
|
||||
}
|
||||
|
||||
export default function CustomerLayout({ children }: { children: React.ReactNode }) {
|
||||
|
||||
@@ -35,14 +35,12 @@ export default function OverviewPage() {
|
||||
const live = data.instances.filter((i) => i.status !== "deleted");
|
||||
|
||||
// Work the customer has to do, gathered across every instance. This is the
|
||||
// only account-level view of it — each record only knows about itself.
|
||||
// only account-level view of it each record only knows about itself.
|
||||
const attention = live.flatMap((i) => {
|
||||
const lic = byInstance.get(i.instance_id);
|
||||
const state = licenceState(lic?.expires_at, Boolean(lic));
|
||||
if (state === "none")
|
||||
return [{ id: i.instance_id, text: `${i.name || "An instance"} is not linked`, note: "" }];
|
||||
if (state === "expired")
|
||||
return [{ id: i.instance_id, text: `${i.name} has expired`, note: "now" }];
|
||||
if (state === "none") return [{ id: i.instance_id, text: `${i.name || "An instance"} is not linked`, note: "" }];
|
||||
if (state === "expired") return [{ id: i.instance_id, text: `${i.name} has expired`, note: "now" }];
|
||||
if (state === "warn")
|
||||
return [
|
||||
{
|
||||
@@ -57,40 +55,26 @@ export default function OverviewPage() {
|
||||
const pending = (people.data ?? []).filter((p) => !p.verified_at).length;
|
||||
|
||||
const subtitle =
|
||||
live.length === 0
|
||||
? "Nothing here yet."
|
||||
: `${live.length} ${live.length === 1 ? "instance" : "instances"}${
|
||||
attention.length ? ` · ${attention.length} needing attention` : " · all licensed"
|
||||
}`;
|
||||
live.length === 0 ? "Nothing here yet." : `${live.length} ${live.length === 1 ? "instance" : "instances"}${attention.length ? ` · ${attention.length} needing attention` : " · all licensed"}`;
|
||||
|
||||
return (
|
||||
<div className="grid gap-6">
|
||||
<PageHeader
|
||||
title="Overview"
|
||||
subtitle={subtitle}
|
||||
actions={
|
||||
live.length > 0 ? (
|
||||
<LinkButton href="/purchase">Buy a plan</LinkButton>
|
||||
) : undefined
|
||||
}
|
||||
actions={live.length > 0 ? <LinkButton href="/purchase">Buy a plan</LinkButton> : undefined}
|
||||
record={[
|
||||
{ key: "Account", value: data.account.account_id, copy: true },
|
||||
{ key: "Billing", value: data.account.billing_email },
|
||||
]}
|
||||
status={
|
||||
attention.length === 0 && live.length > 0 ? (
|
||||
<StatePill state="valid" />
|
||||
) : undefined
|
||||
}
|
||||
status={attention.length === 0 && live.length > 0 ? <StatePill state="valid" /> : undefined}
|
||||
/>
|
||||
|
||||
{live.length === 0 ? (
|
||||
<div className="grid max-w-xl gap-3 rounded border border-rule bg-panel p-5">
|
||||
<h2 className="text-xl">No instances yet</h2>
|
||||
<p className="text-ink-2">
|
||||
Create a free cloud instance and we host it, with your licence applied
|
||||
automatically. Or run Vantage on your own server and get its licence — free
|
||||
or paid — from the purchase page.
|
||||
Create a free cloud instance and we host it, with your licence applied automatically. Or run Vantage on your own server and get its licence free or paid from the purchase page.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2.5">
|
||||
<LinkButton href="/purchase">Buy a plan</LinkButton>
|
||||
@@ -104,16 +88,9 @@ export default function OverviewPage() {
|
||||
<RailCard title="Needs you" count={attention.length}>
|
||||
<ul className="grid gap-2">
|
||||
{attention.map((a) => (
|
||||
<li
|
||||
key={a.id}
|
||||
className="flex items-center justify-between gap-2.5 text-[0.82rem] text-ink-2"
|
||||
>
|
||||
<li key={a.id} className="flex items-center justify-between gap-2.5 text-[0.82rem] text-ink-2">
|
||||
<span>{a.text}</span>
|
||||
{a.note && (
|
||||
<span className="font-mono text-[0.64rem] uppercase tracking-[0.08em] text-warn">
|
||||
{a.note}
|
||||
</span>
|
||||
)}
|
||||
{a.note && <span className="font-mono text-[0.64rem] uppercase tracking-[0.08em] text-warn">{a.note}</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -123,27 +100,18 @@ export default function OverviewPage() {
|
||||
<RailCard title="Your team" count={people.data?.length}>
|
||||
<ul className="grid gap-2">
|
||||
{(people.data ?? []).slice(0, 5).map((p) => (
|
||||
<li
|
||||
key={p.user_id}
|
||||
className="flex items-center justify-between gap-2.5 text-[0.82rem] text-ink-2"
|
||||
>
|
||||
<li key={p.user_id} className="flex items-center justify-between gap-2.5 text-[0.82rem] text-ink-2">
|
||||
<span className="truncate">{p.email}</span>
|
||||
<span className="shrink-0 font-mono text-[0.64rem] uppercase tracking-[0.08em] text-ink-3">
|
||||
{p.account_role}
|
||||
</span>
|
||||
<span className="shrink-0 font-mono text-[0.64rem] uppercase tracking-[0.08em] text-ink-3">{p.account_role}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{pending > 0 && (
|
||||
<p className="border-t border-rule-soft pt-2 text-[0.78rem] text-warn">
|
||||
{pending} {pending === 1 ? "invitation" : "invitations"} not
|
||||
accepted yet
|
||||
{pending} {pending === 1 ? "invitation" : "invitations"} not accepted yet
|
||||
</p>
|
||||
)}
|
||||
<Link
|
||||
href="/users"
|
||||
className="text-[0.82rem] font-semibold text-accent underline"
|
||||
>
|
||||
<Link href="/users" className="text-[0.82rem] font-semibold text-accent underline">
|
||||
Manage people
|
||||
</Link>
|
||||
</RailCard>
|
||||
@@ -151,7 +119,7 @@ export default function OverviewPage() {
|
||||
{/*
|
||||
* Account-level facts only. Tier, limits and renewal date
|
||||
* belong to a licence, and a licence belongs to one
|
||||
* instance — an account holding a Free cloud instance and
|
||||
* instance an account holding a Free cloud instance and
|
||||
* a Professional self-hosted one has no single plan.
|
||||
*/}
|
||||
<RailCard title="Account">
|
||||
@@ -165,23 +133,14 @@ export default function OverviewPage() {
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Link
|
||||
href="/billing"
|
||||
className="text-[0.82rem] font-semibold text-accent underline"
|
||||
>
|
||||
<Link href="/billing" className="text-[0.82rem] font-semibold text-accent underline">
|
||||
Billing history
|
||||
</Link>
|
||||
</RailCard>
|
||||
|
||||
<RailCard title="Running Vantage yourself?">
|
||||
<p className="text-[0.82rem] text-ink-2">
|
||||
Get a licence for your own install — free or paid — from the
|
||||
purchase page. It keeps its own users.
|
||||
</p>
|
||||
<Link
|
||||
href="/purchase"
|
||||
className="text-[0.82rem] font-semibold text-accent underline"
|
||||
>
|
||||
<p className="text-[0.82rem] text-ink-2">Get a licence for your own install free or paid from the purchase page. It keeps its own users.</p>
|
||||
<Link href="/purchase" className="text-[0.82rem] font-semibold text-accent underline">
|
||||
Get a licence
|
||||
</Link>
|
||||
</RailCard>
|
||||
@@ -199,12 +158,7 @@ export default function OverviewPage() {
|
||||
// Open when it is the only one, or when it is the
|
||||
// first thing that needs a decision. A saved toggle
|
||||
// beats this from then on.
|
||||
defaultOpen={
|
||||
live.length === 1 ||
|
||||
(state !== "valid" &&
|
||||
attention[0]?.id === i.instance_id) ||
|
||||
(attention.length === 0 && n === 0)
|
||||
}
|
||||
defaultOpen={live.length === 1 || (state !== "valid" && attention[0]?.id === i.instance_id) || (attention.length === 0 && n === 0)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -88,7 +88,7 @@ export function PurchaseForm() {
|
||||
|
||||
const rows = useMemo(() => (options?.catalogue ?? []).filter((r) => r.deployment === dep && r.tier === choice.tier), [options, dep, choice.tier]);
|
||||
|
||||
// Real line items for the current configuration — the same builder the
|
||||
// Real line items for the current configuration the same builder the
|
||||
// checkout uses, so the summary can never disagree with the overlay.
|
||||
const items = useMemo(() => (options ? lineItemsFor(options, choice, dep) : []), [options, choice, dep]);
|
||||
|
||||
@@ -321,8 +321,7 @@ export function PurchaseForm() {
|
||||
<Block n={3} label="Your install">
|
||||
<div className="grid gap-3 rounded border border-rule bg-panel p-4">
|
||||
<p className="text-[0.86rem] text-ink-2">
|
||||
Install Vantage on your own server first, then paste the instance ID it
|
||||
reports. We register it and issue your Free licence — nothing to pay.
|
||||
Install Vantage on your own server first, then paste the instance ID it reports. We register it and issue your Free licence nothing to pay.
|
||||
</p>
|
||||
<label className="grid gap-1">
|
||||
<span className="text-[0.72rem] font-semibold uppercase tracking-[0.08em] text-ink-3">Instance ID</span>
|
||||
@@ -332,10 +331,7 @@ export function PurchaseForm() {
|
||||
placeholder="00000000-0000-0000-0000-000000000000"
|
||||
className="rounded border border-rule bg-panel px-2.5 py-2 font-mono text-[0.82rem] text-ink placeholder:text-ink-3"
|
||||
/>
|
||||
<span className="text-[0.72rem] text-ink-3">
|
||||
Find this on your install’s Settings → Licence page, or the setup
|
||||
screen just after first sign-in.
|
||||
</span>
|
||||
<span className="text-[0.72rem] text-ink-3">Find this on your install’s Settings → Licence page, or the setup screen just after first sign-in.</span>
|
||||
</label>
|
||||
</div>
|
||||
</Block>
|
||||
@@ -423,7 +419,7 @@ export function PurchaseForm() {
|
||||
)}
|
||||
{pending?.deployment === "cloud" && (
|
||||
<div className="grid gap-2 border-t border-rule-soft pt-3">
|
||||
<p className="text-[0.8rem] text-ink-2">Your instance is being set up. Its licence appears the moment payment clears — no further steps.</p>
|
||||
<p className="text-[0.8rem] text-ink-2">Your instance is being set up. Its licence appears the moment payment clears no further steps.</p>
|
||||
<Link href={`/instances/${pending.instanceId}`} className="font-semibold text-accent underline">
|
||||
Go to your instance
|
||||
</Link>
|
||||
@@ -554,7 +550,7 @@ function TierCard({
|
||||
return (
|
||||
<FeatureLine key={key} on={st !== "absent"}>
|
||||
{featureLabel(key)}
|
||||
{st === "included" ? " — included" : st === "addon" ? " — add-on" : " — not available"}
|
||||
{st === "included" ? " included" : st === "addon" ? " add-on" : " not available"}
|
||||
</FeatureLine>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -25,9 +25,7 @@ export default function AccountDetailPage() {
|
||||
record={[
|
||||
{ key: "Account", value: data.account.account_id, copy: true },
|
||||
{ key: "Status", value: data.account.status },
|
||||
...(data.account.paddle_customer_id
|
||||
? [{ key: "Paddle", value: data.account.paddle_customer_id, copy: true }]
|
||||
: []),
|
||||
...(data.account.paddle_customer_id ? [{ key: "Paddle", value: data.account.paddle_customer_id, copy: true }] : []),
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -35,10 +33,7 @@ export default function AccountDetailPage() {
|
||||
<ul className="grid gap-2">
|
||||
{data.instances.map((i) => (
|
||||
<li key={i.instance_id} className="flex flex-wrap justify-between gap-2">
|
||||
<Link
|
||||
href={`/staff/instances/${i.instance_id}`}
|
||||
className="text-accent underline"
|
||||
>
|
||||
<Link href={`/staff/instances/${i.instance_id}`} className="text-accent underline">
|
||||
{i.name || i.instance_id}
|
||||
</Link>
|
||||
<span className="font-mono text-[0.82rem] text-ink-3">
|
||||
@@ -71,19 +66,10 @@ export default function AccountDetailPage() {
|
||||
{data.users.map((u) => (
|
||||
<li key={u.user_id} className="flex flex-wrap justify-between gap-2">
|
||||
<span className="font-mono text-[0.82rem]">{u.email}</span>
|
||||
<span className="font-mono text-[0.82rem] text-ink-3">
|
||||
{u.verified_at
|
||||
? `verified ${formatDate(u.verified_at)}`
|
||||
: "not verified"}
|
||||
</span>
|
||||
<span className="font-mono text-[0.82rem] text-ink-3">{u.verified_at ? `verified ${formatDate(u.verified_at)}` : "not verified"}</span>
|
||||
</li>
|
||||
))}
|
||||
{data.users.length === 0 && (
|
||||
<li className="text-ink-3">
|
||||
None — this is a cloud account, so its people sign in with their
|
||||
control-plane details.
|
||||
</li>
|
||||
)}
|
||||
{data.users.length === 0 && <li className="text-ink-3">None this is a cloud account, so its people sign in with their control-plane details.</li>}
|
||||
</ul>
|
||||
</Panel>
|
||||
|
||||
@@ -94,9 +80,7 @@ export default function AccountDetailPage() {
|
||||
<span>
|
||||
{e.action} · {e.actor}
|
||||
</span>
|
||||
<span className="tabular-nums text-ink-3">
|
||||
{formatDate(e.created_at)}
|
||||
</span>
|
||||
<span className="tabular-nums text-ink-3">{formatDate(e.created_at)}</span>
|
||||
</li>
|
||||
))}
|
||||
{data.audit.length === 0 && <li className="text-ink-3">Nothing yet.</li>}
|
||||
|
||||
@@ -6,11 +6,7 @@ import { ApiError, api, type Tier } from "@/lib/api";
|
||||
import { Button } from "@/components/Button";
|
||||
import { Field } from "@/components/Field";
|
||||
|
||||
export function IssuePanel({
|
||||
instanceId,
|
||||
}: {
|
||||
instanceId: string;
|
||||
}) {
|
||||
export function IssuePanel({ instanceId }: { instanceId: string }) {
|
||||
const qc = useQueryClient();
|
||||
const [tier, setTier] = useState<Tier>("professional");
|
||||
const [term, setTerm] = useState("annual");
|
||||
@@ -35,28 +31,16 @@ export function IssuePanel({
|
||||
<section className="grid gap-4 border-t border-rule-soft pt-5">
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<label className="grid gap-1.5">
|
||||
<span className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">
|
||||
Tier
|
||||
</span>
|
||||
<select
|
||||
value={tier}
|
||||
onChange={(e) => setTier(e.target.value as Tier)}
|
||||
className="rounded border border-rule bg-panel-2 px-2.5 py-2"
|
||||
>
|
||||
<span className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">Tier</span>
|
||||
<select value={tier} onChange={(e) => setTier(e.target.value as Tier)} className="rounded border border-rule bg-panel-2 px-2.5 py-2">
|
||||
<option value="free">Free</option>
|
||||
<option value="professional">Professional</option>
|
||||
<option value="enterprise">Enterprise</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="grid gap-1.5">
|
||||
<span className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">
|
||||
Term
|
||||
</span>
|
||||
<select
|
||||
value={term}
|
||||
onChange={(e) => setTerm(e.target.value)}
|
||||
className="rounded border border-rule bg-panel-2 px-2.5 py-2"
|
||||
>
|
||||
<span className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">Term</span>
|
||||
<select value={term} onChange={(e) => setTerm(e.target.value)} className="rounded border border-rule bg-panel-2 px-2.5 py-2">
|
||||
<option value="annual">Annual</option>
|
||||
<option value="monthly">Monthly</option>
|
||||
</select>
|
||||
@@ -67,18 +51,8 @@ export function IssuePanel({
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<Field
|
||||
label="Relink to instance ID"
|
||||
value={newId}
|
||||
onChange={(e) => setNewId(e.target.value)}
|
||||
hint="Staff relinks are not capped — the customer cap exists to put you in the loop."
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="line"
|
||||
onClick={() => relink.mutate()}
|
||||
disabled={!newId.trim()}
|
||||
>
|
||||
<Field label="Relink to instance ID" value={newId} onChange={(e) => setNewId(e.target.value)} hint="Staff relinks are not capped the customer cap exists to put you in the loop." />
|
||||
<Button type="button" variant="line" onClick={() => relink.mutate()} disabled={!newId.trim()}>
|
||||
Relink
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -14,7 +14,7 @@ import { IssuePanel } from "./IssuePanel";
|
||||
const INJECTION: Record<InjectionState, { label: string; tone: string }> = {
|
||||
current: { label: "Control plane holds the current licence", tone: "text-valid" },
|
||||
stale: {
|
||||
label: "Control plane holds an older blob — the reconciler will repair it",
|
||||
label: "Control plane holds an older blob the reconciler will repair it",
|
||||
tone: "text-warn",
|
||||
},
|
||||
missing: { label: "No matching instance in the control plane", tone: "text-expired" },
|
||||
@@ -44,30 +44,19 @@ export default function StaffInstancePage() {
|
||||
title={data.instance.name || data.instance.instance_id}
|
||||
subtitle={
|
||||
<>
|
||||
<Link
|
||||
href={`/staff/accounts/${data.account.account_id}`}
|
||||
className="text-accent underline"
|
||||
>
|
||||
<Link href={`/staff/accounts/${data.account.account_id}`} className="text-accent underline">
|
||||
{data.account.name || data.account.account_id}
|
||||
</Link>
|
||||
<span className="text-ink-3">
|
||||
{" "}
|
||||
· {data.instance.deployment} · {data.instance.status}
|
||||
{data.instance.relink_count > 0 &&
|
||||
` · ${data.instance.relink_count} relinks this term`}
|
||||
{data.instance.relink_count > 0 && ` · ${data.instance.relink_count} relinks this term`}
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
record={[
|
||||
{ key: "Instance", value: data.instance.instance_id, copy: true },
|
||||
...(data.instance.slug
|
||||
? [{ key: "Slug", value: data.instance.slug }]
|
||||
: []),
|
||||
]}
|
||||
record={[{ key: "Instance", value: data.instance.instance_id, copy: true }, ...(data.instance.slug ? [{ key: "Slug", value: data.instance.slug }] : [])]}
|
||||
/>
|
||||
{data.injection.applicable && inj && (
|
||||
<p className={clsx("font-mono text-[0.72rem]", inj.tone)}>{inj.label}</p>
|
||||
)}
|
||||
{data.injection.applicable && inj && <p className={clsx("font-mono text-[0.72rem]", inj.tone)}>{inj.label}</p>}
|
||||
</div>
|
||||
|
||||
<section className="grid gap-3 rounded border border-rule bg-panel p-5">
|
||||
@@ -76,10 +65,7 @@ export default function StaffInstancePage() {
|
||||
<IssuePanel instanceId={data.instance.instance_id} />
|
||||
</section>
|
||||
|
||||
<EntitlementSection
|
||||
instanceId={data.instance.instance_id}
|
||||
deployment={data.instance.deployment}
|
||||
/>
|
||||
<EntitlementSection instanceId={data.instance.instance_id} deployment={data.instance.deployment} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -114,8 +100,7 @@ function EntitlementSection({ instanceId, deployment }: { instanceId: string; de
|
||||
: { tier: "professional", term: deployment === "self_hosted" ? "annual" : "monthly", servers: 3, features: [] });
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: (grant: boolean) =>
|
||||
api.staff.setEntitlement(instanceId, { ...choice, grant }),
|
||||
mutationFn: (grant: boolean) => api.staff.setEntitlement(instanceId, { ...choice, grant }),
|
||||
onSuccess: () => {
|
||||
setDraft(null);
|
||||
qc.invalidateQueries({ queryKey: ["staff", "entitlement", instanceId] });
|
||||
@@ -127,38 +112,21 @@ function EntitlementSection({ instanceId, deployment }: { instanceId: string; de
|
||||
<header className="mb-3">
|
||||
<h2 className="text-[0.95rem] font-medium text-ink">Entitlement</h2>
|
||||
<p className="text-[0.78rem] text-ink-3">
|
||||
What this instance is allowed. A licence is signed from{" "}
|
||||
<em>granted</em>, never from <em>desired</em>.
|
||||
What this instance is allowed. A licence is signed from <em>granted</em>, never from <em>desired</em>.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{ent && data?.pending && (
|
||||
<p className="mb-3 rounded border border-warn/50 bg-panel-2 px-2.5 py-2 text-[0.82rem] text-ink-2">
|
||||
Pending change — currently granted {ent.granted.servers} servers,
|
||||
configured for {ent.desired.servers}
|
||||
{ent.scheduled_change_at
|
||||
? `, effective ${new Date(ent.scheduled_change_at).toLocaleDateString("en-GB", { day: "numeric", month: "long", year: "numeric" })}`
|
||||
: ""}
|
||||
.
|
||||
Pending change currently granted {ent.granted.servers} servers, configured for {ent.desired.servers}
|
||||
{ent.scheduled_change_at ? `, effective ${new Date(ent.scheduled_change_at).toLocaleDateString("en-GB", { day: "numeric", month: "long", year: "numeric" })}` : ""}.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<PlanConfigurator
|
||||
deployment={deployment}
|
||||
value={choice}
|
||||
plans={plans}
|
||||
catalogue={catalogue}
|
||||
onChange={setDraft}
|
||||
disabled={save.isPending}
|
||||
/>
|
||||
<PlanConfigurator deployment={deployment} value={choice} plans={plans} catalogue={catalogue} onChange={setDraft} disabled={save.isPending} />
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={save.isPending}
|
||||
onClick={() => save.mutate(false)}
|
||||
className="rounded border border-rule px-3 py-1.5 text-[0.85rem] text-ink-2 disabled:opacity-40"
|
||||
>
|
||||
<button type="button" disabled={save.isPending} onClick={() => save.mutate(false)} className="rounded border border-rule px-3 py-1.5 text-[0.85rem] text-ink-2 disabled:opacity-40">
|
||||
Save as configured
|
||||
</button>
|
||||
<button
|
||||
@@ -170,15 +138,8 @@ function EntitlementSection({ instanceId, deployment }: { instanceId: string; de
|
||||
Save and grant
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-2 text-[0.72rem] text-ink-3">
|
||||
Granting takes effect on the next licence issued. It does not issue
|
||||
one.
|
||||
</p>
|
||||
{save.error && (
|
||||
<p className="mt-2 text-[0.82rem] text-expired">
|
||||
{String((save.error as Error).message)}
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-2 text-[0.72rem] text-ink-3">Granting takes effect on the next licence issued. It does not issue one.</p>
|
||||
{save.error && <p className="mt-2 text-[0.82rem] text-expired">{String((save.error as Error).message)}</p>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -35,9 +35,7 @@ export default function StaffDashboard() {
|
||||
|
||||
if (injection.error instanceof NotConnected) return <NotConnectedPanel url={API_BASE} />;
|
||||
|
||||
const stale = (unlinked.data ?? []).filter(
|
||||
(i) => Date.now() - new Date(i.created_at).getTime() > HOURS_48,
|
||||
);
|
||||
const stale = (unlinked.data ?? []).filter((i) => Date.now() - new Date(i.created_at).getTime() > HOURS_48);
|
||||
|
||||
const failed = injection.data?.count ?? 0;
|
||||
const instances = allInstances.data ?? [];
|
||||
@@ -46,11 +44,7 @@ export default function StaffDashboard() {
|
||||
<div className="grid gap-6">
|
||||
<PageHeader
|
||||
title="Operations"
|
||||
subtitle={
|
||||
failed > 0
|
||||
? "Injection failures come first — those instances are paying for a licence they have not received."
|
||||
: "Nothing failing. Queues below are routine chasing."
|
||||
}
|
||||
subtitle={failed > 0 ? "Injection failures come first those instances are paying for a licence they have not received." : "Nothing failing. Queues below are routine chasing."}
|
||||
actions={
|
||||
<LinkButton variant="line" href="/staff/accounts">
|
||||
Find an account
|
||||
@@ -68,9 +62,7 @@ export default function StaffDashboard() {
|
||||
items={(injection.data?.failed ?? []).slice(0, 4).map((i) => ({
|
||||
label: i.name || i.instance_id,
|
||||
href: `/staff/instances/${i.instance_id}`,
|
||||
meta: i.inject_failed_at
|
||||
? new Date(i.inject_failed_at).toISOString().slice(11, 16)
|
||||
: "",
|
||||
meta: i.inject_failed_at ? new Date(i.inject_failed_at).toISOString().slice(11, 16) : "",
|
||||
}))}
|
||||
/>
|
||||
<Queue
|
||||
@@ -117,8 +109,7 @@ export default function StaffDashboard() {
|
||||
},
|
||||
{
|
||||
label: "Self-hosted",
|
||||
value: instances.filter((i) => i.deployment === "self_hosted")
|
||||
.length,
|
||||
value: instances.filter((i) => i.deployment === "self_hosted").length,
|
||||
},
|
||||
{
|
||||
label: "Awaiting link",
|
||||
@@ -126,10 +117,7 @@ export default function StaffDashboard() {
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Link
|
||||
href="/staff/licenses"
|
||||
className="text-[0.82rem] font-semibold text-accent underline"
|
||||
>
|
||||
<Link href="/staff/licenses" className="text-[0.82rem] font-semibold text-accent underline">
|
||||
All licences
|
||||
</Link>
|
||||
</RailCard>
|
||||
@@ -139,14 +127,9 @@ export default function StaffDashboard() {
|
||||
<header className="flex flex-wrap items-center justify-between gap-3 border-b border-rule-soft bg-panel-2 px-4 py-3">
|
||||
<div>
|
||||
<h2 className="text-[0.95rem]">Recent activity</h2>
|
||||
<p className="text-[0.8rem] text-ink-2">
|
||||
Every licence issued, relinked or reaped, newest first.
|
||||
</p>
|
||||
<p className="text-[0.8rem] text-ink-2">Every licence issued, relinked or reaped, newest first.</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/staff/audit"
|
||||
className="text-[0.82rem] font-semibold text-accent underline"
|
||||
>
|
||||
<Link href="/staff/audit" className="text-[0.82rem] font-semibold text-accent underline">
|
||||
Full audit
|
||||
</Link>
|
||||
</header>
|
||||
@@ -163,9 +146,7 @@ export default function StaffDashboard() {
|
||||
<tbody>
|
||||
{(audit.data ?? []).slice(0, 12).map((e, n) => (
|
||||
<tr key={n} className="border-b border-rule-soft last:border-0">
|
||||
<td className="px-4 py-2.5 font-mono tabular-nums text-ink-2">
|
||||
{new Date(e.created_at).toISOString().slice(11, 16)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 font-mono tabular-nums text-ink-2">{new Date(e.created_at).toISOString().slice(11, 16)}</td>
|
||||
<td className="px-4 py-2.5">{e.action}</td>
|
||||
<td className="px-4 py-2.5 text-ink-2">{e.target ?? "—"}</td>
|
||||
<td className="px-4 py-2.5 text-ink-3">{e.actor}</td>
|
||||
|
||||
@@ -26,15 +26,7 @@ const LIMIT_FIELDS = [
|
||||
* sentinel is a staff screen where nobody can tell whether a plan says
|
||||
* unlimited or nothing at all.
|
||||
*/
|
||||
function AllowanceForm({
|
||||
plan,
|
||||
onSave,
|
||||
saving,
|
||||
}: {
|
||||
plan: Plan;
|
||||
onSave: (next: Plan) => void;
|
||||
saving: boolean;
|
||||
}) {
|
||||
function AllowanceForm({ plan, onSave, saving }: { plan: Plan; onSave: (next: Plan) => void; saving: boolean }) {
|
||||
const [draft, setDraft] = useState<Plan>(plan);
|
||||
const dirty = JSON.stringify(draft) !== JSON.stringify(plan);
|
||||
|
||||
@@ -43,9 +35,7 @@ function AllowanceForm({
|
||||
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{LIMIT_FIELDS.map((f) => (
|
||||
<label key={f.key} className="block">
|
||||
<span className="mb-1 block text-[0.78rem] text-ink-3">
|
||||
{f.label}
|
||||
</span>
|
||||
<span className="mb-1 block text-[0.78rem] text-ink-3">{f.label}</span>
|
||||
<input
|
||||
type="number"
|
||||
value={draft.base_limits[f.key]}
|
||||
@@ -60,20 +50,14 @@ function AllowanceForm({
|
||||
}
|
||||
className="w-full rounded border border-rule bg-panel px-2 py-1.5 text-[0.85rem] text-ink"
|
||||
/>
|
||||
<span className="mt-0.5 block text-[0.72rem] text-ink-3">
|
||||
−1 is unlimited
|
||||
</span>
|
||||
<span className="mt-0.5 block text-[0.72rem] text-ink-3">−1 is unlimited</span>
|
||||
</label>
|
||||
))}
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-[0.78rem] text-ink-3">
|
||||
Support level
|
||||
</span>
|
||||
<span className="mb-1 block text-[0.78rem] text-ink-3">Support level</span>
|
||||
<select
|
||||
value={draft.support_level}
|
||||
onChange={(e) =>
|
||||
setDraft({ ...draft, support_level: e.target.value })
|
||||
}
|
||||
onChange={(e) => setDraft({ ...draft, support_level: e.target.value })}
|
||||
className="w-full rounded border border-rule bg-panel px-2 py-1.5 text-[0.85rem] text-ink"
|
||||
>
|
||||
{SUPPORT_LEVELS.map((s) => (
|
||||
@@ -86,25 +70,13 @@ function AllowanceForm({
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-[0.85rem] text-ink-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={draft.active}
|
||||
onChange={(e) => setDraft({ ...draft, active: e.target.checked })}
|
||||
/>
|
||||
<input type="checkbox" checked={draft.active} onChange={(e) => setDraft({ ...draft, active: e.target.checked })} />
|
||||
Offered to customers
|
||||
</label>
|
||||
|
||||
<p className="text-[0.78rem] text-ink-3">
|
||||
Changes apply to licences issued from now on. Existing licences
|
||||
snapshotted their plan and are unaffected.
|
||||
</p>
|
||||
<p className="text-[0.78rem] text-ink-3">Changes apply to licences issued from now on. Existing licences snapshotted their plan and are unaffected.</p>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={!dirty || saving}
|
||||
onClick={() => onSave(draft)}
|
||||
className="rounded border border-accent/50 px-3 py-1.5 text-[0.85rem] text-accent disabled:opacity-40"
|
||||
>
|
||||
<button type="button" disabled={!dirty || saving} onClick={() => onSave(draft)} className="rounded border border-accent/50 px-3 py-1.5 text-[0.85rem] text-accent disabled:opacity-40">
|
||||
{saving ? "Saving…" : "Save allowances"}
|
||||
</button>
|
||||
</div>
|
||||
@@ -131,26 +103,20 @@ export default function PlansPage() {
|
||||
onError: () => setSaving(null),
|
||||
});
|
||||
|
||||
const original = plans.data?.find(
|
||||
(p) => p.deployment === draft?.deployment && p.tier === draft?.tier,
|
||||
);
|
||||
const original = plans.data?.find((p) => p.deployment === draft?.deployment && p.tier === draft?.tier);
|
||||
|
||||
return (
|
||||
<div className="grid gap-6">
|
||||
<PageHeader
|
||||
title="Plans"
|
||||
subtitle="The authoritative tier table — six plans, two deployments by three tiers, base allowances only. Every issued licence snapshots the plan it was cut from, so editing one never rewrites an existing licence."
|
||||
subtitle="The authoritative tier table six plans, two deployments by three tiers, base allowances only. Every issued licence snapshots the plan it was cut from, so editing one never rewrites an existing licence."
|
||||
/>
|
||||
|
||||
{draft && original && (
|
||||
<ConfirmPlanChange
|
||||
plan={original}
|
||||
next={draft}
|
||||
issuedCount={
|
||||
(licenses.data ?? []).filter(
|
||||
(l) => l.tier === draft.tier && l.deployment === draft.deployment,
|
||||
).length
|
||||
}
|
||||
issuedCount={(licenses.data ?? []).filter((l) => l.tier === draft.tier && l.deployment === draft.deployment).length}
|
||||
onConfirm={() => {
|
||||
setSaving(`${draft.deployment}/${draft.tier}`);
|
||||
save.mutate(draft);
|
||||
@@ -161,29 +127,18 @@ export default function PlansPage() {
|
||||
|
||||
{(["cloud", "self_hosted"] as const).map((deployment: Deployment) => (
|
||||
<section key={deployment} className="space-y-3">
|
||||
<h2 className="text-[0.95rem] font-medium text-ink">
|
||||
{deployment === "cloud" ? "Cloud" : "Self-Hosted"}
|
||||
</h2>
|
||||
<h2 className="text-[0.95rem] font-medium text-ink">{deployment === "cloud" ? "Cloud" : "Self-Hosted"}</h2>
|
||||
{(plans.data ?? [])
|
||||
.filter((p) => p.deployment === deployment)
|
||||
.map((p) => (
|
||||
<article
|
||||
key={`${p.deployment}/${p.tier}`}
|
||||
className="rounded-lg border border-rule bg-panel p-4"
|
||||
>
|
||||
<article key={`${p.deployment}/${p.tier}`} className="rounded-lg border border-rule bg-panel p-4">
|
||||
<header className="mb-3 flex items-baseline justify-between gap-3">
|
||||
<h3 className="text-[0.9rem] font-medium text-ink">
|
||||
{p.name}
|
||||
</h3>
|
||||
<h3 className="text-[0.9rem] font-medium text-ink">{p.name}</h3>
|
||||
<span className="font-mono text-[0.75rem] text-ink-3">
|
||||
{p.deployment}/{p.tier}
|
||||
</span>
|
||||
</header>
|
||||
<AllowanceForm
|
||||
plan={p}
|
||||
saving={saving === `${p.deployment}/${p.tier}`}
|
||||
onSave={(next: Plan) => setDraft(next)}
|
||||
/>
|
||||
<AllowanceForm plan={p} saving={saving === `${p.deployment}/${p.tier}`} onSave={(next: Plan) => setDraft(next)} />
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -12,7 +12,7 @@ export type NavLink = { href: string; label: string };
|
||||
* which environment you are in.
|
||||
*
|
||||
* It replaces a brand bar and a separate nav strip. The nav's active state is
|
||||
* derived from the pathname rather than hardcoded — the previous customer nav
|
||||
* derived from the pathname rather than hardcoded the previous customer nav
|
||||
* marked Overview as current on every page, including the ones that weren't it.
|
||||
*
|
||||
* Staff sit on --panel-2 with a chip where the account name goes. web/ is locked
|
||||
@@ -21,24 +21,14 @@ export type NavLink = { href: string; label: string };
|
||||
* that distinction from each other too, and one shade plus one chip buys it
|
||||
* without a second palette.
|
||||
*/
|
||||
export function AppBar({
|
||||
links,
|
||||
context,
|
||||
staff = false,
|
||||
}: {
|
||||
links: NavLink[];
|
||||
context?: React.ReactNode;
|
||||
staff?: boolean;
|
||||
}) {
|
||||
export function AppBar({ links, context, staff = false }: { links: NavLink[]; context?: React.ReactNode; staff?: boolean }) {
|
||||
const pathname = usePathname();
|
||||
|
||||
const isCurrent = (href: string) =>
|
||||
// The section root matches only exactly; deeper routes match by prefix,
|
||||
// so /staff/accounts/:id still lights Accounts while /staff/accounts
|
||||
// does not light Operations.
|
||||
href === "/" || href === "/staff"
|
||||
? pathname === href
|
||||
: pathname === href || pathname.startsWith(`${href}/`);
|
||||
href === "/" || href === "/staff" ? pathname === href : pathname === href || pathname.startsWith(`${href}/`);
|
||||
|
||||
return (
|
||||
<header className={`border-b border-rule ${staff ? "bg-panel-2" : "bg-panel"}`}>
|
||||
@@ -46,9 +36,7 @@ export function AppBar({
|
||||
<div className="col-start-1 row-start-1 flex min-w-0 items-center gap-3 py-2.5">
|
||||
<span className="flex items-baseline gap-2 text-[1.16rem] font-extrabold tracking-[-0.02em]">
|
||||
Vantage
|
||||
<span className="font-mono text-[0.72rem] font-normal uppercase tracking-[0.14em] text-ink-3">
|
||||
HQ
|
||||
</span>
|
||||
<span className="font-mono text-[0.72rem] font-normal uppercase tracking-[0.14em] text-ink-3">HQ</span>
|
||||
</span>
|
||||
{context && (
|
||||
<>
|
||||
@@ -70,9 +58,7 @@ export function AppBar({
|
||||
href={l.href}
|
||||
aria-current={on ? "page" : undefined}
|
||||
className={`relative inline-flex shrink-0 items-center px-3 py-2.5 font-mono text-[0.72rem] uppercase tracking-[0.08em] md:py-0 ${
|
||||
on
|
||||
? "font-bold text-accent after:absolute after:inset-x-3 after:bottom-0 after:h-0.5 after:bg-accent after:content-['']"
|
||||
: "text-ink-3 hover:text-ink-2"
|
||||
on ? "font-bold text-accent after:absolute after:inset-x-3 after:bottom-0 after:h-0.5 after:bg-accent after:content-['']" : "text-ink-3 hover:text-ink-2"
|
||||
}`}
|
||||
>
|
||||
{l.label}
|
||||
|
||||
@@ -12,9 +12,7 @@ export function buttonClass(variant: Variant = "solid", disabled = false, classN
|
||||
return clsx(
|
||||
"inline-flex items-center gap-2 rounded border px-4 py-2.5 text-[0.94rem] font-semibold",
|
||||
"transition-[filter,border-color] duration-150 hover:brightness-110",
|
||||
variant === "solid"
|
||||
? "border-accent bg-accent text-accent-ink"
|
||||
: "border-rule bg-panel text-ink hover:border-ink-3",
|
||||
variant === "solid" ? "border-accent bg-accent text-accent-ink" : "border-rule bg-panel text-ink hover:border-ink-3",
|
||||
disabled && "cursor-not-allowed border-rule bg-panel text-ink-3 hover:brightness-100",
|
||||
className,
|
||||
);
|
||||
@@ -28,22 +26,10 @@ export function Button({ variant = "solid", className, ...rest }: Props) {
|
||||
|
||||
/*
|
||||
* A link that looks like a button. It exists so a navigation action never has to
|
||||
* be an <a> wrapped around a <button> — invalid markup, and it gives screen
|
||||
* be an <a> wrapped around a <button> invalid markup, and it gives screen
|
||||
* readers two nested controls where the page means one.
|
||||
*/
|
||||
export function LinkButton({
|
||||
href,
|
||||
variant = "solid",
|
||||
external,
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
href: string;
|
||||
variant?: Variant;
|
||||
external?: boolean;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
export function LinkButton({ href, variant = "solid", external, className, children }: { href: string; variant?: Variant; external?: boolean; className?: string; children: React.ReactNode }) {
|
||||
const cls = buttonClass(variant, false, className);
|
||||
return external ? (
|
||||
<a href={href} className={cls}>
|
||||
|
||||
@@ -5,30 +5,12 @@ import { Button } from "./Button";
|
||||
/*
|
||||
* Editing a plan changes what every future customer gets, so the confirmation
|
||||
* names each field rather than asking "are you sure". Existing licences
|
||||
* snapshotted their plan at issue time and are genuinely unaffected — saying so
|
||||
* snapshotted their plan at issue time and are genuinely unaffected saying so
|
||||
* is what stops a well-meaning edit being followed by a panicked reissue.
|
||||
*/
|
||||
export function ConfirmPlanChange({
|
||||
plan,
|
||||
next,
|
||||
issuedCount,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: {
|
||||
plan: Plan;
|
||||
next: Plan;
|
||||
issuedCount: number;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
export function ConfirmPlanChange({ plan, next, issuedCount, onConfirm, onCancel }: { plan: Plan; next: Plan; issuedCount: number; onConfirm: () => void; onCancel: () => void }) {
|
||||
const rows: { field: string; was: string; now: string }[] = [];
|
||||
const fields = [
|
||||
"max_servers",
|
||||
"max_monitors",
|
||||
"max_secret_groups",
|
||||
"max_channels",
|
||||
"audit_retention_days",
|
||||
] as const;
|
||||
const fields = ["max_servers", "max_monitors", "max_secret_groups", "max_channels", "audit_retention_days"] as const;
|
||||
for (const f of fields) {
|
||||
if (plan.base_limits[f] !== next.base_limits[f])
|
||||
rows.push({
|
||||
@@ -63,10 +45,7 @@ export function ConfirmPlanChange({
|
||||
))}
|
||||
{rows.length === 0 && <li className="text-ink-3">Nothing would change.</li>}
|
||||
</ul>
|
||||
<p className="text-[0.82rem] text-ink-3">
|
||||
This applies to licences issued from now on. The {issuedCount} licences already
|
||||
issued keep what they were signed with until each is reissued.
|
||||
</p>
|
||||
<p className="text-[0.82rem] text-ink-3">This applies to licences issued from now on. The {issuedCount} licences already issued keep what they were signed with until each is reissued.</p>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button type="button" onClick={onConfirm}>
|
||||
Change plan
|
||||
|
||||
@@ -21,7 +21,7 @@ const KEY = (id: string) => `vantage-hq-record-open:${id}`;
|
||||
/*
|
||||
* One instance, open or closed.
|
||||
*
|
||||
* Closed it is a row — name, tier, host, term bar, state. Open it adds what the
|
||||
* Closed it is a row name, tier, host, term bar, state. Open it adds what the
|
||||
* licence includes, who can sign in, and the actions. Deliberately ONE component
|
||||
* rather than a card and a detail panel: two components meant a single-instance
|
||||
* account got a third of a row of summary with its substance a click away, and
|
||||
|
||||
@@ -13,16 +13,12 @@ const REASON: Record<License["reason"], string> = {
|
||||
/*
|
||||
* Licences are append-only: a renewal supersedes its predecessor rather than
|
||||
* replacing it. So this is a ledger, not a table. Superseded rows stay visible
|
||||
* and are overprinted the way a cancelled instrument is — hiding them would
|
||||
* and are overprinted the way a cancelled instrument is hiding them would
|
||||
* destroy the only record of why an instance stopped working on a given date.
|
||||
*/
|
||||
export function Ledger({ licenses }: { licenses: License[] }) {
|
||||
if (licenses.length === 0) {
|
||||
return (
|
||||
<p className="text-ink-2">
|
||||
No licence has ever been issued for this instance, so it is read-only.
|
||||
</p>
|
||||
);
|
||||
return <p className="text-ink-2">No licence has ever been issued for this instance, so it is read-only.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -30,22 +26,9 @@ export function Ledger({ licenses }: { licenses: License[] }) {
|
||||
{licenses.map((l) => {
|
||||
const dead = Boolean(l.superseded_by);
|
||||
return (
|
||||
<li
|
||||
key={l.license_id}
|
||||
className={clsx(
|
||||
"grid gap-4 border-b border-rule-soft py-4 last:border-0 sm:grid-cols-[9.5rem_1fr]",
|
||||
dead && "text-ink-3",
|
||||
)}
|
||||
>
|
||||
<li key={l.license_id} className={clsx("grid gap-4 border-b border-rule-soft py-4 last:border-0 sm:grid-cols-[9.5rem_1fr]", dead && "text-ink-3")}>
|
||||
<div className="font-mono text-[0.72rem] tabular-nums text-ink-3">
|
||||
<b
|
||||
className={clsx(
|
||||
"block text-[0.82rem] font-semibold",
|
||||
dead ? "text-ink-3" : "text-ink",
|
||||
)}
|
||||
>
|
||||
{formatDate(l.issued_at)}
|
||||
</b>
|
||||
<b className={clsx("block text-[0.82rem] font-semibold", dead ? "text-ink-3" : "text-ink")}>{formatDate(l.issued_at)}</b>
|
||||
{formatStamp(l.issued_at)}
|
||||
</div>
|
||||
<div className="grid justify-items-start gap-1.5">
|
||||
@@ -56,20 +39,14 @@ export function Ledger({ licenses }: { licenses: License[] }) {
|
||||
)}
|
||||
<p className="flex flex-wrap items-center gap-2 font-semibold">
|
||||
{l.tier.replace("_", " ")}
|
||||
<span className="rounded-sm border border-rule px-1.5 py-0.5 font-mono text-[0.72rem] font-normal uppercase tracking-[0.09em] text-accent">
|
||||
{REASON[l.reason]}
|
||||
</span>
|
||||
<span className="rounded-sm border border-rule px-1.5 py-0.5 font-mono text-[0.72rem] font-normal uppercase tracking-[0.09em] text-accent">{REASON[l.reason]}</span>
|
||||
</p>
|
||||
<p className="font-mono text-[0.72rem] tabular-nums text-ink-3">
|
||||
{l.license_id.slice(0, 8)} · expires {formatDate(l.expires_at)} ·{" "}
|
||||
{limitLabel(l.limits.max_servers)} servers · issued by {l.issued_by}
|
||||
{l.license_id.slice(0, 8)} · expires {formatDate(l.expires_at)} · {limitLabel(l.limits.max_servers)} servers · issued by {l.issued_by}
|
||||
{l.superseded_by && (
|
||||
<>
|
||||
{" "}
|
||||
· replaced by{" "}
|
||||
<span className="text-accent underline">
|
||||
{l.superseded_by.slice(0, 8)}
|
||||
</span>
|
||||
· replaced by <span className="text-accent underline">{l.superseded_by.slice(0, 8)}</span>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
|
||||
@@ -4,19 +4,11 @@ import { useState } from "react";
|
||||
import { Button } from "./Button";
|
||||
|
||||
/*
|
||||
* A licence blob is signed public data, not a secret — it is useless on any
|
||||
* A licence blob is signed public data, not a secret it is useless on any
|
||||
* instance other than the one it names. So it is safe to show inline, and
|
||||
* showing it is what stops a blocked download from blocking a paying customer.
|
||||
*/
|
||||
export function LicenceDelivery({
|
||||
instanceId,
|
||||
blob,
|
||||
downloadUrl,
|
||||
}: {
|
||||
instanceId: string;
|
||||
blob: string;
|
||||
downloadUrl: string;
|
||||
}) {
|
||||
export function LicenceDelivery({ instanceId, blob, downloadUrl }: { instanceId: string; blob: string; downloadUrl: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
async function copy() {
|
||||
@@ -27,13 +19,11 @@ export function LicenceDelivery({
|
||||
|
||||
const steps = [
|
||||
<>
|
||||
Open <code className="rounded-sm bg-accent-wash px-1">Settings → Licence</code> on your
|
||||
install.
|
||||
Open <code className="rounded-sm bg-accent-wash px-1">Settings → Licence</code> on your install.
|
||||
</>,
|
||||
<>Paste the licence into the box and save.</>,
|
||||
<>
|
||||
The page reports <code className="rounded-sm bg-accent-wash px-1">Valid</code> straight
|
||||
away — no restart.
|
||||
The page reports <code className="rounded-sm bg-accent-wash px-1">Valid</code> straight away no restart.
|
||||
</>,
|
||||
];
|
||||
|
||||
@@ -52,18 +42,11 @@ export function LicenceDelivery({
|
||||
{copied ? "Copied" : "Copy to clipboard"}
|
||||
</Button>
|
||||
</div>
|
||||
<pre className="max-h-48 overflow-y-auto whitespace-pre-wrap break-all rounded border border-dashed border-rule bg-panel-2 p-3 font-mono text-[0.72rem] text-ink-2">
|
||||
{blob}
|
||||
</pre>
|
||||
<pre className="max-h-48 overflow-y-auto whitespace-pre-wrap break-all rounded border border-dashed border-rule bg-panel-2 p-3 font-mono text-[0.72rem] text-ink-2">{blob}</pre>
|
||||
<ol className="grid gap-2">
|
||||
{steps.map((body, i) => (
|
||||
<li
|
||||
key={i}
|
||||
className="grid grid-cols-[1.6rem_1fr] gap-3 text-[0.82rem] text-ink-2"
|
||||
>
|
||||
<span className="h-6 rounded-sm border border-rule text-center font-mono text-[0.72rem] leading-6 text-accent">
|
||||
{i + 1}
|
||||
</span>
|
||||
<li key={i} className="grid grid-cols-[1.6rem_1fr] gap-3 text-[0.82rem] text-ink-2">
|
||||
<span className="h-6 rounded-sm border border-rule text-center font-mono text-[0.72rem] leading-6 text-accent">{i + 1}</span>
|
||||
<span>{body}</span>
|
||||
</li>
|
||||
))}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Button } from "@/components/Button";
|
||||
const ROLES: InstanceRole[] = ["owner", "admin", "member"];
|
||||
|
||||
/*
|
||||
* Absent entirely for self-hosted instances — the backend refuses those, and a
|
||||
* Absent entirely for self-hosted instances the backend refuses those, and a
|
||||
* panel that renders controls the server will reject is a panel that lies.
|
||||
*/
|
||||
export function MembersPanel({ instanceId }: { instanceId: string }) {
|
||||
@@ -26,8 +26,7 @@ export function MembersPanel({ instanceId }: { instanceId: string }) {
|
||||
const people = useQuery({ queryKey: ["account-users"], queryFn: api.accountUsers });
|
||||
|
||||
const refresh = () => qc.invalidateQueries({ queryKey: ["members", instanceId] });
|
||||
const fail = (e: unknown) =>
|
||||
setError(e instanceof ApiError ? e.message : "Something went wrong. Try again.");
|
||||
const fail = (e: unknown) => setError(e instanceof ApiError ? e.message : "Something went wrong. Try again.");
|
||||
|
||||
const grant = useMutation({
|
||||
mutationFn: () => api.grantMember(instanceId, selected, role),
|
||||
@@ -39,8 +38,7 @@ export function MembersPanel({ instanceId }: { instanceId: string }) {
|
||||
onError: fail,
|
||||
});
|
||||
const changeRole = useMutation({
|
||||
mutationFn: (v: { uid: string; role: InstanceRole }) =>
|
||||
api.setMemberRole(instanceId, v.uid, v.role),
|
||||
mutationFn: (v: { uid: string; role: InstanceRole }) => api.setMemberRole(instanceId, v.uid, v.role),
|
||||
onSuccess: refresh,
|
||||
onError: fail,
|
||||
});
|
||||
@@ -54,29 +52,21 @@ export function MembersPanel({ instanceId }: { instanceId: string }) {
|
||||
const canManage = myRole === "owner" || myRole === "admin";
|
||||
|
||||
const granted = new Set((members.data ?? []).map((m) => m.customer_user_id));
|
||||
const candidates = (people.data ?? []).filter(
|
||||
(p) => !granted.has(p.user_id) && p.verified_at,
|
||||
);
|
||||
const candidates = (people.data ?? []).filter((p) => !granted.has(p.user_id) && p.verified_at);
|
||||
const pending = (people.data ?? []).filter((p) => !p.verified_at).length;
|
||||
|
||||
return (
|
||||
<section className="grid gap-4 rounded border border-rule bg-panel p-5">
|
||||
<div className="grid gap-1">
|
||||
<h2 className="text-xl">Who can sign in</h2>
|
||||
<p className="text-[0.82rem] text-ink-2">
|
||||
Each person here has a real user inside this instance and signs in with their
|
||||
Vantage HQ password.
|
||||
</p>
|
||||
<p className="text-[0.82rem] text-ink-2">Each person here has a real user inside this instance and signs in with their Vantage HQ password.</p>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-[0.9rem] text-expired">{error}</p>}
|
||||
|
||||
<ul className="grid gap-2">
|
||||
{(members.data ?? []).map((m) => (
|
||||
<li
|
||||
key={m.member_id}
|
||||
className="flex flex-wrap items-center justify-between gap-3 border-b border-rule-soft pb-2"
|
||||
>
|
||||
<li key={m.member_id} className="flex flex-wrap items-center justify-between gap-3 border-b border-rule-soft pb-2">
|
||||
<span>{m.email}</span>
|
||||
<span className="flex items-center gap-3">
|
||||
{canManage ? (
|
||||
@@ -104,8 +94,7 @@ export function MembersPanel({ instanceId }: { instanceId: string }) {
|
||||
type="button"
|
||||
className="text-[0.82rem] font-semibold text-expired underline"
|
||||
onClick={() => {
|
||||
if (confirm(`Remove ${m.email} from this instance?`))
|
||||
revoke.mutate(m.customer_user_id);
|
||||
if (confirm(`Remove ${m.email} from this instance?`)) revoke.mutate(m.customer_user_id);
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
@@ -114,9 +103,7 @@ export function MembersPanel({ instanceId }: { instanceId: string }) {
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
{members.data?.length === 0 && (
|
||||
<li className="text-ink-2">Nobody has been added yet.</li>
|
||||
)}
|
||||
{members.data?.length === 0 && <li className="text-ink-2">Nobody has been added yet.</li>}
|
||||
</ul>
|
||||
|
||||
{canManage && (
|
||||
@@ -129,14 +116,8 @@ export function MembersPanel({ instanceId }: { instanceId: string }) {
|
||||
}}
|
||||
>
|
||||
<label className="grid gap-1.5">
|
||||
<span className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">
|
||||
Add someone
|
||||
</span>
|
||||
<select
|
||||
value={selected}
|
||||
onChange={(e) => setSelected(e.target.value)}
|
||||
className="rounded border border-rule bg-panel-2 px-2.5 py-2 font-mono text-ink"
|
||||
>
|
||||
<span className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">Add someone</span>
|
||||
<select value={selected} onChange={(e) => setSelected(e.target.value)} className="rounded border border-rule bg-panel-2 px-2.5 py-2 font-mono text-ink">
|
||||
<option value="">Choose a person…</option>
|
||||
{candidates.map((p) => (
|
||||
<option key={p.user_id} value={p.user_id}>
|
||||
@@ -146,14 +127,8 @@ export function MembersPanel({ instanceId }: { instanceId: string }) {
|
||||
</select>
|
||||
</label>
|
||||
<label className="grid gap-1.5">
|
||||
<span className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">
|
||||
Role here
|
||||
</span>
|
||||
<select
|
||||
value={role}
|
||||
onChange={(e) => setRole(e.target.value as InstanceRole)}
|
||||
className="rounded border border-rule bg-panel-2 px-2.5 py-2 font-mono text-ink"
|
||||
>
|
||||
<span className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">Role here</span>
|
||||
<select value={role} onChange={(e) => setRole(e.target.value as InstanceRole)} className="rounded border border-rule bg-panel-2 px-2.5 py-2 font-mono text-ink">
|
||||
{ROLES.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{r}
|
||||
@@ -169,8 +144,7 @@ export function MembersPanel({ instanceId }: { instanceId: string }) {
|
||||
|
||||
{canManage && pending > 0 && (
|
||||
<p className="text-[0.82rem] text-ink-3">
|
||||
{pending} invited {pending === 1 ? "person has" : "people have"} not accepted
|
||||
yet and cannot be added until they do.
|
||||
{pending} invited {pending === 1 ? "person has" : "people have"} not accepted yet and cannot be added until they do.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/*
|
||||
* The deployment failure this repo makes most often, made legible. It names the
|
||||
* variable, the value baked in, and both reasons it fails — unreachable from
|
||||
* variable, the value baked in, and both reasons it fails unreachable from
|
||||
* the browser, or missing from admin's ADMIN_ORIGIN.
|
||||
*/
|
||||
export function NotConnectedPanel({ url }: { url: string }) {
|
||||
@@ -9,19 +9,16 @@ export function NotConnectedPanel({ url }: { url: string }) {
|
||||
<h2 className="text-xl text-expired">Not connected to the licensing service</h2>
|
||||
{url ? (
|
||||
<p className="text-ink-2">
|
||||
This build points at <code className="text-ink">ADMIN_API_URL</code> ={" "}
|
||||
<code className="text-ink">{url}</code>, which did not respond.
|
||||
This build points at <code className="text-ink">ADMIN_API_URL</code> = <code className="text-ink">{url}</code>, which did not respond.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-ink-2">
|
||||
<code className="text-ink">ADMIN_API_URL</code> was not set when this app was
|
||||
built, so there is nowhere to send requests.
|
||||
<code className="text-ink">ADMIN_API_URL</code> was not set when this app was built, so there is nowhere to send requests.
|
||||
</p>
|
||||
)}
|
||||
<p className="text-[0.82rem] text-ink-3">
|
||||
The value is baked in when the image is built and has to be reachable from your
|
||||
browser, not just from the server. It also has to appear in the licensing
|
||||
service’s <code>ADMIN_ORIGIN</code>, or the browser blocks every request.
|
||||
The value is baked in when the image is built and has to be reachable from your browser, not just from the server. It also has to appear in the licensing service’s{" "}
|
||||
<code>ADMIN_ORIGIN</code>, or the browser blocks every request.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -7,16 +7,10 @@
|
||||
* many instances exist, so the page has a floor.
|
||||
*
|
||||
* It collapses below lg in source order, which puts the main column first on a
|
||||
* phone. Nothing is hidden at any width — if content only fits on a desktop it
|
||||
* phone. Nothing is hidden at any width if content only fits on a desktop it
|
||||
* does not belong in the rail.
|
||||
*/
|
||||
export function PageFrame({
|
||||
children,
|
||||
aside,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
aside?: React.ReactNode;
|
||||
}) {
|
||||
export function PageFrame({ children, aside }: { children: React.ReactNode; aside?: React.ReactNode }) {
|
||||
if (!aside) return <div className="grid gap-5">{children}</div>;
|
||||
|
||||
return (
|
||||
@@ -28,24 +22,12 @@ export function PageFrame({
|
||||
}
|
||||
|
||||
/** One card in the rail. Title is a label, not a heading you read for pleasure. */
|
||||
export function RailCard({
|
||||
title,
|
||||
count,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
count?: number | string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
export function RailCard({ title, count, children }: { title: string; count?: number | string; children: React.ReactNode }) {
|
||||
return (
|
||||
<section className="grid gap-2.5 rounded border border-rule bg-panel p-3.5">
|
||||
<header className="flex items-baseline justify-between gap-2.5">
|
||||
<h2 className="font-mono text-[0.66rem] font-normal uppercase tracking-[0.12em] text-ink-3">
|
||||
{title}
|
||||
</h2>
|
||||
{count !== undefined && (
|
||||
<b className="text-[0.95rem] font-extrabold tabular-nums">{count}</b>
|
||||
)}
|
||||
<h2 className="font-mono text-[0.66rem] font-normal uppercase tracking-[0.12em] text-ink-3">{title}</h2>
|
||||
{count !== undefined && <b className="text-[0.95rem] font-extrabold tabular-nums">{count}</b>}
|
||||
</header>
|
||||
{children}
|
||||
</section>
|
||||
@@ -59,9 +41,7 @@ export function RailFacts({ rows }: { rows: { label: string; value: React.ReactN
|
||||
{rows.map((r) => (
|
||||
<div key={r.label} className="flex justify-between gap-2.5 text-[0.82rem]">
|
||||
<dt className="text-ink-3">{r.label}</dt>
|
||||
<dd className="m-0 truncate font-mono text-[0.78rem] tabular-nums text-ink">
|
||||
{r.value}
|
||||
</dd>
|
||||
<dd className="m-0 truncate font-mono text-[0.78rem] tabular-nums text-ink">{r.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useState } from "react";
|
||||
|
||||
/*
|
||||
* One record-line entry. `copy` marks the value as worth lifting to the
|
||||
* clipboard — an instance UUID or a licence ID, the strings people paste into
|
||||
* clipboard an instance UUID or a licence ID, the strings people paste into
|
||||
* support tickets.
|
||||
*/
|
||||
export type RecordField = { key: string; value: string; copy?: boolean };
|
||||
@@ -60,10 +60,7 @@ export function PageHeader({
|
||||
return (
|
||||
<header className="grid gap-3">
|
||||
{back && (
|
||||
<Link
|
||||
href={back.href}
|
||||
className="justify-self-start font-mono text-[0.7rem] uppercase tracking-[0.1em] text-ink-3 hover:text-accent"
|
||||
>
|
||||
<Link href={back.href} className="justify-self-start font-mono text-[0.7rem] uppercase tracking-[0.1em] text-ink-3 hover:text-accent">
|
||||
← {back.label}
|
||||
</Link>
|
||||
)}
|
||||
@@ -80,12 +77,8 @@ export function PageHeader({
|
||||
<div className="flex flex-wrap items-center gap-x-5 gap-y-2.5 border-t border-rule pt-2.5">
|
||||
{record?.map((f) => (
|
||||
<span key={f.key} className="flex items-center gap-2">
|
||||
<span className="font-mono text-[0.64rem] uppercase tracking-[0.14em] text-ink-3">
|
||||
{f.key}
|
||||
</span>
|
||||
<span className="font-mono text-[0.78rem] tabular-nums text-ink-2">
|
||||
{f.value}
|
||||
</span>
|
||||
<span className="font-mono text-[0.64rem] uppercase tracking-[0.14em] text-ink-3">{f.key}</span>
|
||||
<span className="font-mono text-[0.78rem] tabular-nums text-ink-2">{f.value}</span>
|
||||
{f.copy && <CopyButton value={f.value} />}
|
||||
</span>
|
||||
))}
|
||||
|
||||
@@ -6,18 +6,7 @@ import { Field } from "./Field";
|
||||
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
export function RelinkPanel({
|
||||
used,
|
||||
max,
|
||||
onRelink,
|
||||
error,
|
||||
}: {
|
||||
instanceId: string;
|
||||
used: number;
|
||||
max: number;
|
||||
onRelink: (newId: string) => void;
|
||||
error?: string;
|
||||
}) {
|
||||
export function RelinkPanel({ used, max, onRelink, error }: { instanceId: string; used: number; max: number; onRelink: (newId: string) => void; error?: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [value, setValue] = useState("");
|
||||
const remaining = Math.max(0, max - used);
|
||||
@@ -26,32 +15,14 @@ export function RelinkPanel({
|
||||
return (
|
||||
<section className="grid gap-3 border-t border-rule-soft pt-5">
|
||||
<h2 className="text-xl">Moved to a new server?</h2>
|
||||
<p className="text-[0.82rem] text-ink-2">
|
||||
Relinking issues a replacement licence for the new install, covering the rest of
|
||||
your current term.
|
||||
</p>
|
||||
{open && !exhausted && (
|
||||
<Field
|
||||
label="New instance ID"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
error={error}
|
||||
hint="From Settings → Licence on the new install."
|
||||
/>
|
||||
)}
|
||||
<p className="text-[0.82rem] text-ink-2">Relinking issues a replacement licence for the new install, covering the rest of your current term.</p>
|
||||
{open && !exhausted && <Field label="New instance ID" value={value} onChange={(e) => setValue(e.target.value)} error={error} hint="From Settings → Licence on the new install." />}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="line"
|
||||
disabled={exhausted || (open && !UUID_RE.test(value.trim()))}
|
||||
onClick={() => (open ? onRelink(value.trim()) : setOpen(true))}
|
||||
>
|
||||
<Button type="button" variant="line" disabled={exhausted || (open && !UUID_RE.test(value.trim()))} onClick={() => (open ? onRelink(value.trim()) : setOpen(true))}>
|
||||
Relink to a new install
|
||||
</Button>
|
||||
<span className="text-[0.82rem] text-ink-3">
|
||||
{exhausted
|
||||
? "You have used every relink for this term — contact support and we will sort it out."
|
||||
: `${remaining} of ${max} relinks left this term`}
|
||||
{exhausted ? "You have used every relink for this term contact support and we will sort it out." : `${remaining} of ${max} relinks left this term`}
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -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)}`,
|
||||
|
||||
@@ -31,7 +31,7 @@ export function useTheme(): [ThemePref, (p: ThemePref) => void] {
|
||||
// Starts at "system" on both server and first client render so hydration
|
||||
// matches; the real value lands in the effect below. The inline script in
|
||||
// app/layout.tsx has already painted the correct colours by then, so there
|
||||
// is no flash — only this control's own highlight settles a tick late.
|
||||
// is no flash only this control's own highlight settles a tick late.
|
||||
const [pref, setPref] = useState<ThemePref>("system");
|
||||
|
||||
useEffect(() => setPref(readTheme()), []);
|
||||
@@ -51,6 +51,4 @@ export function useTheme(): [ThemePref, (p: ThemePref) => void] {
|
||||
* Runs before first paint, so a dark-preferring user never sees a white flash.
|
||||
* Inlined as a string because it has to execute ahead of React.
|
||||
*/
|
||||
export const THEME_BOOT_SCRIPT = `try{var t=localStorage.getItem(${JSON.stringify(
|
||||
KEY,
|
||||
)});if(t==="light"||t==="dark")document.documentElement.setAttribute("data-theme",t)}catch(e){}`;
|
||||
export const THEME_BOOT_SCRIPT = `try{var t=localStorage.getItem(${JSON.stringify(KEY)});if(t==="light"||t==="dark")document.documentElement.setAttribute("data-theme",t)}catch(e){}`;
|
||||
|
||||
+1
-1
@@ -3,13 +3,13 @@ module gitea.hostxtra.co.uk/mrhid6/vantage/agent
|
||||
go 1.26
|
||||
|
||||
require (
|
||||
golang.org/x/sys v0.20.0
|
||||
google.golang.org/grpc v1.64.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
golang.org/x/net v0.25.0 // indirect
|
||||
golang.org/x/sys v0.20.0 // indirect
|
||||
golang.org/x/text v0.15.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240521202816-d264139d666e // indirect
|
||||
google.golang.org/protobuf v1.34.1 // indirect
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -144,11 +144,14 @@ func partitions() []pb.PartitionReport {
|
||||
if syscall.Statfs(fields[1], &st) != nil {
|
||||
continue
|
||||
}
|
||||
total := st.Blocks * uint64(st.Bsize)
|
||||
free := st.Bavail * uint64(st.Bsize)
|
||||
bsize := uint64(st.Bsize)
|
||||
total := st.Blocks * bsize
|
||||
// Bfree, not Bavail: the difference is the root-reserved 5% on ext4,
|
||||
// which is not used space. df counts it the same way.
|
||||
used := (st.Blocks - st.Bfree) * bsize
|
||||
out = append(out, pb.PartitionReport{
|
||||
Device: fields[0], Mountpoint: fields[1], Fstype: fields[2],
|
||||
TotalBytes: total, UsedBytes: total - free,
|
||||
TotalBytes: total, UsedBytes: used,
|
||||
})
|
||||
}
|
||||
return out
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//go:build !linux
|
||||
//go:build !linux && !windows
|
||||
|
||||
// Inventory collection is Linux-only. This no-op stands in everywhere else.
|
||||
// Inventory collection has Linux and Windows implementations. This no-op stands
|
||||
// in everywhere else.
|
||||
//
|
||||
// The build constraint above is load-bearing: "_other" is not a GOOS suffix, so
|
||||
// without it this file compiles on Linux too and collides with collect_linux.go.
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
package inventory
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
"golang.org/x/sys/windows/registry"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/grpc/pb"
|
||||
)
|
||||
|
||||
var (
|
||||
kernel32 = windows.NewLazySystemDLL("kernel32.dll")
|
||||
procGetSystemTimes = kernel32.NewProc("GetSystemTimes")
|
||||
// x/sys/windows exposes neither of these two, so they are bound by hand.
|
||||
procGlobalMemoryStatusEx = kernel32.NewProc("GlobalMemoryStatusEx")
|
||||
)
|
||||
|
||||
func collect(r *pb.InventoryReport, includeStatic bool) {
|
||||
r.CPU.UsagePct = cpuUsage()
|
||||
// Windows has no load average. Left at zero; the UI already treats it as
|
||||
// optional because it is omitempty on the wire.
|
||||
|
||||
m := memoryStatus()
|
||||
if m.TotalPhys > m.AvailPhys {
|
||||
r.Memory.UsedBytes = m.TotalPhys - m.AvailPhys
|
||||
}
|
||||
// TotalPageFile is the commit limit — physical memory plus the pagefile —
|
||||
// so the pagefile alone is the difference.
|
||||
swapTotal := sub(m.TotalPageFile, m.TotalPhys)
|
||||
swapUsed := sub(sub(m.TotalPageFile, m.AvailPageFile), sub(m.TotalPhys, m.AvailPhys))
|
||||
if swapUsed > swapTotal {
|
||||
swapUsed = swapTotal
|
||||
}
|
||||
r.SwapUsed = swapUsed
|
||||
|
||||
if includeStatic {
|
||||
r.Memory.TotalBytes = m.TotalPhys
|
||||
r.SwapTotal = swapTotal
|
||||
r.CPU.Model, r.CPU.Cores = cpuStatic()
|
||||
r.Kernel = kernel()
|
||||
r.Partitions = partitions()
|
||||
}
|
||||
}
|
||||
|
||||
func sub(a, b uint64) uint64 {
|
||||
if a > b {
|
||||
return a - b
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
type memoryStatusEx struct {
|
||||
Length uint32
|
||||
MemoryLoad uint32
|
||||
TotalPhys uint64
|
||||
AvailPhys uint64
|
||||
TotalPageFile uint64
|
||||
AvailPageFile uint64
|
||||
TotalVirtual uint64
|
||||
AvailVirtual uint64
|
||||
AvailExtendedVirtual uint64
|
||||
}
|
||||
|
||||
func memoryStatus() memoryStatusEx {
|
||||
var m memoryStatusEx
|
||||
m.Length = uint32(unsafe.Sizeof(m))
|
||||
r, _, _ := procGlobalMemoryStatusEx.Call(uintptr(unsafe.Pointer(&m)))
|
||||
if r == 0 {
|
||||
return memoryStatusEx{}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func systemTimes() (idle, total uint64, ok bool) {
|
||||
var idleFT, kernelFT, userFT windows.Filetime
|
||||
r, _, _ := procGetSystemTimes.Call(
|
||||
uintptr(unsafe.Pointer(&idleFT)),
|
||||
uintptr(unsafe.Pointer(&kernelFT)),
|
||||
uintptr(unsafe.Pointer(&userFT)),
|
||||
)
|
||||
if r == 0 {
|
||||
return 0, 0, false
|
||||
}
|
||||
ft := func(f windows.Filetime) uint64 {
|
||||
return uint64(f.HighDateTime)<<32 | uint64(f.LowDateTime)
|
||||
}
|
||||
// Kernel time already includes idle time, so kernel+user is the whole.
|
||||
return ft(idleFT), ft(kernelFT) + ft(userFT), true
|
||||
}
|
||||
|
||||
func cpuUsage() float64 {
|
||||
i1, t1, ok := systemTimes()
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
i2, t2, ok := systemTimes()
|
||||
if !ok || t2 <= t1 {
|
||||
return 0
|
||||
}
|
||||
return (1 - float64(i2-i1)/float64(t2-t1)) * 100
|
||||
}
|
||||
|
||||
func cpuStatic() (model string, cores int) {
|
||||
cores = runtime.NumCPU()
|
||||
k, err := registry.OpenKey(registry.LOCAL_MACHINE,
|
||||
`HARDWARE\DESCRIPTION\System\CentralProcessor\0`, registry.QUERY_VALUE)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer k.Close()
|
||||
if s, _, err := k.GetStringValue("ProcessorNameString"); err == nil {
|
||||
model = s
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func kernel() string {
|
||||
v := windows.RtlGetVersion()
|
||||
return fmt.Sprintf("%d.%d.%d", v.MajorVersion, v.MinorVersion, v.BuildNumber)
|
||||
}
|
||||
|
||||
func partitions() []pb.PartitionReport {
|
||||
buf := make([]uint16, 256)
|
||||
n, err := windows.GetLogicalDriveStrings(uint32(len(buf)), &buf[0])
|
||||
if err != nil || n == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var out []pb.PartitionReport
|
||||
for _, root := range splitNullStrings(buf[:n]) {
|
||||
rootPtr, err := windows.UTF16PtrFromString(root)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
// Fixed disks only: network shares can hang, and removable drives
|
||||
// would appear and vanish between snapshots.
|
||||
if windows.GetDriveType(rootPtr) != windows.DRIVE_FIXED {
|
||||
continue
|
||||
}
|
||||
|
||||
var free, total, totalFree uint64
|
||||
if err := windows.GetDiskFreeSpaceEx(rootPtr, &free, &total, &totalFree); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
fsBuf := make([]uint16, 32)
|
||||
var fstype string
|
||||
if err := windows.GetVolumeInformation(rootPtr, nil, 0, nil, nil, nil, &fsBuf[0], uint32(len(fsBuf))); err == nil {
|
||||
fstype = windows.UTF16ToString(fsBuf)
|
||||
}
|
||||
|
||||
out = append(out, pb.PartitionReport{
|
||||
Device: root,
|
||||
Mountpoint: root,
|
||||
Fstype: fstype,
|
||||
TotalBytes: total,
|
||||
UsedBytes: total - totalFree,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// splitNullStrings splits the NUL-separated, double-NUL-terminated block that
|
||||
// GetLogicalDriveStrings writes.
|
||||
func splitNullStrings(b []uint16) []string {
|
||||
var out []string
|
||||
start := 0
|
||||
for i, c := range b {
|
||||
if c != 0 {
|
||||
continue
|
||||
}
|
||||
if i > start {
|
||||
out = append(out, windows.UTF16ToString(b[start:i]))
|
||||
}
|
||||
start = i + 1
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -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, " ", "_"))
|
||||
@@ -412,13 +439,55 @@ func handleUpdateAgentWindows(cmd *pb.ServerCommand) {
|
||||
}
|
||||
|
||||
logPath := filepath.Join(os.TempDir(), "vantage-agent-msi.log")
|
||||
log.Printf("launching msiexec for upgrade to v%s (cmd=%s)", u.Version, cmd.CommandId)
|
||||
|
||||
up := exec.Command("cmd", "/c", "start", "", "/wait", "msiexec", "/i", msiPath, "/qn", "/norestart", "/l*v", logPath)
|
||||
if err := up.Start(); err != nil {
|
||||
// The MSI stops the vantage-agent service as part of the upgrade. Anything
|
||||
// descended from this process is killed with it, so msiexec must not be a
|
||||
// child: run it from a scheduled task, which is parented to the Task
|
||||
// Scheduler service instead.
|
||||
if err := launchDetachedUpdate(msiPath, logPath, cmd.CommandId); err != nil {
|
||||
log.Printf("failed to launch msiexec (cmd=%s): %v", cmd.CommandId, err)
|
||||
return
|
||||
}
|
||||
log.Printf("scheduled msiexec for upgrade to v%s (cmd=%s)", u.Version, cmd.CommandId)
|
||||
}
|
||||
|
||||
const updateTaskName = "VantageAgentUpdate"
|
||||
|
||||
func launchDetachedUpdate(msiPath, logPath, commandID string) error {
|
||||
scriptPath := filepath.Join(os.TempDir(), "vantage-agent-update.cmd")
|
||||
script := fmt.Sprintf("@echo off\r\n"+
|
||||
"timeout /t 5 /nobreak >nul\r\n"+
|
||||
"msiexec /i \"%s\" /qn /norestart /l*v \"%s\"\r\n"+
|
||||
"schtasks /delete /tn %s /f >nul 2>&1\r\n"+
|
||||
"del /f /q \"%s\" >nul 2>&1\r\n"+
|
||||
"(goto) 2>nul & del /f /q \"%%~f0\"\r\n",
|
||||
msiPath, logPath, updateTaskName, msiPath)
|
||||
if err := os.WriteFile(scriptPath, []byte(script), 0o600); err != nil {
|
||||
return fmt.Errorf("write update script: %w", err)
|
||||
}
|
||||
|
||||
// Stale task from a previous attempt would make /create fail even with /f
|
||||
// if it is still running, so tear it down first and ignore the result.
|
||||
exec.Command("schtasks", "/end", "/tn", updateTaskName).Run()
|
||||
exec.Command("schtasks", "/delete", "/tn", updateTaskName, "/f").Run()
|
||||
|
||||
create := exec.Command("schtasks", "/create",
|
||||
"/tn", updateTaskName,
|
||||
"/tr", `"`+scriptPath+`"`,
|
||||
"/sc", "once",
|
||||
// Already in the past: the task never fires on its own, only via /run.
|
||||
"/st", "00:00",
|
||||
"/ru", "SYSTEM",
|
||||
"/rl", "HIGHEST",
|
||||
"/f")
|
||||
if out, err := create.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("schtasks create: %v: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
|
||||
if out, err := exec.Command("schtasks", "/run", "/tn", updateTaskName).CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("schtasks run: %v: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func downloadFile(url, dest string) error {
|
||||
|
||||
@@ -94,6 +94,11 @@ vantage/
|
||||
│ ├── app/(staff)/staff/ # operations, accounts, licences, plans, audit
|
||||
│ ├── components/ # AppBar, PageHeader, PageFrame, InstanceRecord
|
||||
│ └── lib/ # api client, session guards, formatters
|
||||
├── docsite/ # user documentation (Docusaurus, static)
|
||||
│ ├── docs/ # getting-started, vantage, hq, reference, operations
|
||||
│ ├── src/css/custom.css # site/'s tokens, copied, mapped onto --ifm-*
|
||||
│ ├── sidebars.ts # authored by hand, not autogenerated
|
||||
│ └── nginx.conf # serves the build under /docs
|
||||
├── shared/ # imported by server, sitesvc and admin
|
||||
│ ├── mail/ # the one email system: transport + tmpl templates
|
||||
│ ├── license/ # payload, sign, verify, trusted keys, plans
|
||||
@@ -117,7 +122,7 @@ Upload a public key, assign it per server, revoke softly. The agent diffs desire
|
||||
|
||||
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.
|
||||
|
||||
Default steps are seeded per org at boot (`SeedDefaultSteps`). Logs are swept by retention (`workflow_log_retention_days`; nil = 30 days, 0 = forever).
|
||||
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).
|
||||
|
||||
### Monitors
|
||||
|
||||
@@ -133,7 +138,22 @@ 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.
|
||||
|
||||
### Inventory and OS updates
|
||||
|
||||
@@ -147,19 +167,25 @@ Agents report CPU/memory/swap/partitions/kernel — metrics every 30s, full stat
|
||||
|
||||
`site/` is a separate Next.js app built exactly like `web/` — `output: "standalone"`, run by Node in a `node:26-alpine` image, listening on `3000` and published as `3003`. The contact form posts to `sitesvc`; account signup posts to `admin` (`NEXT_PUBLIC_ADMIN_API_URL`), which creates an HQ account, not an org — the control plane is not touched until the customer later creates a cloud instance from the portal.
|
||||
|
||||
`adminsite/` is built the same way and published as `3004`, served at **`vantage-hq.hostxtra.co.uk`** — deliberately *outside* `*.vantage.hostxtra.co.uk`, because that namespace is per-tenant instance subdomains and `APP_ROOT_LABEL` resolves an org from the label before `vantage`. It shares `site/`'s design tokens verbatim (see Frontend below) and, unlike `web/`, does **not** proxy through a Next rewrite: the browser calls `admin` directly, so `ADMIN_API_URL` must be browser-reachable. Authenticated requests work cross-origin only because both hosts share the registrable domain `hostxtra.co.uk`, which keeps `admin_session`'s `SameSite=Lax` cookie in play.
|
||||
`adminsite/` is built the same way and published as `3004`, served at **`vantage-hq.hostxtra.co.uk`** — deliberately _outside_ `*.vantage.hostxtra.co.uk`, because that namespace is per-tenant instance subdomains and `APP_ROOT_LABEL` resolves an org from the label before `vantage`. It shares `site/`'s design tokens verbatim (see Frontend below) and, unlike `web/`, does **not** proxy through a Next rewrite: the browser calls `admin` directly, so `ADMIN_API_URL` must be browser-reachable. Authenticated requests work cross-origin only because both hosts share the registrable domain `hostxtra.co.uk`, which keeps `admin_session`'s `SameSite=Lax` cookie in play.
|
||||
|
||||
**`ADMIN_ORIGIN` must list every browser origin that calls admin — currently two**: `https://vantage-hq.hostxtra.co.uk` for the console, and `https://vantage.hostxtra.co.uk` because the marketing site's `/start` form posts account signups to admin directly. It is comma-separated. A missing origin does not produce a 403: `cors()` simply omits the `Access-Control-Allow-Origin` header and still answers the preflight `204`, so the browser blocks the request and **admin logs nothing at all**. Symptom is a CORS preflight failure on an endpoint that works fine under curl.
|
||||
|
||||
`sitesvc/` (port `8082`) now owns only the contact flow:
|
||||
|
||||
| Form | Endpoint | Effect |
|
||||
| ------- | --------------------- | ----------------------------------------------------------------------- |
|
||||
| Contact | `POST /api/contact` | Emails `support@hostxtra.co.uk`, `Reply-To` the sender. Nothing stored. |
|
||||
| Form | Endpoint | Effect |
|
||||
| ------- | ------------------- | ----------------------------------------------------------------------- |
|
||||
| Contact | `POST /api/contact` | Emails `support@hostxtra.co.uk`, `Reply-To` the sender. Nothing stored. |
|
||||
|
||||
Account signup lives in `admin` instead (`POST /auth/signup`, `GET /auth/verify?token=…`) — see Signup and verification below.
|
||||
|
||||
`site`, `sitesvc` and `admin` are deliberately **excluded from the self-hosted deployment**: `deploy/docker-compose.yml` mentions none of them, and they live in `deploy/docker-compose.site.yml` instead.
|
||||
`site`, `sitesvc`, `admin` and `docsite` are deliberately **excluded from the self-hosted deployment**: `deploy/docker-compose.yml` mentions none of them, and they live in `deploy/docker-compose.site.yml` instead.
|
||||
|
||||
### Documentation site
|
||||
|
||||
`docsite/` is the user-facing documentation — Docusaurus 3 in docs-only mode (`routeBasePath: "/"`, no blog), one version tracking `main`, search indexed at build time by `@easyops-cn/docusaurus-search-local` so nothing external is keyed or called. It documents the **product**, not the codebase: this file remains the contributor's map, and the two are allowed to differ in altitude but not in fact. Five sections — Getting started, Vantage, Vantage HQ, Reference, Operations — with `sidebars.ts` authored by hand so ordering is a decision rather than a filename accident.
|
||||
|
||||
Unlike the three Next apps it builds to static files, so its runtime stage is `nginx:alpine-slim` rather than Node, and it listens on `80`. See the compose note below for the `/docs` prefix, which is the one thing about it that is easy to get wrong.
|
||||
|
||||
```bash
|
||||
# self-hosted install — no marketing site, no sitesvc
|
||||
@@ -234,7 +260,7 @@ instance authenticates it exactly as it authenticates anyone else, with **no
|
||||
runtime dependency on admin**. Revoking deletes that row — the control plane has
|
||||
no disabled state, and a row that exists is a row that can sign in.
|
||||
|
||||
`instance_members` in admin's database is only admin's *index* of those
|
||||
`instance_members` in admin's database is only admin's _index_ of those
|
||||
projections; the control-plane row is the access. That is why a failed
|
||||
`instance_members` insert unwinds the projection, and why the boot backfill can
|
||||
rebuild the index from the control plane but never the other way round.
|
||||
@@ -266,7 +292,7 @@ password-change endpoint at all, so there is no competing writer for the hash.
|
||||
- **Host/org guard** — `APP_ROOT_LABEL` (default `vantage`) defines the app root label. A request to `<slug>.vantage.<tld>` resolves that org from the slug and rejects sessions belonging to a different one. Org lookups are cached for 60s.
|
||||
|
||||
Unique indexes are a **security property**, not an optimisation. `users` is
|
||||
unique on `(instance_id, email)` — one address is one user *within* an instance,
|
||||
unique on `(instance_id, email)` — one address is one user _within_ an instance,
|
||||
and the same address may hold a user in several instances, because an account's
|
||||
people are projected into each instance they are granted. This is sufficient only
|
||||
because **every lookup by email is scoped by instance**; there is deliberately no
|
||||
@@ -406,7 +432,7 @@ GET /health/injection · /health/billing
|
||||
|
||||
Paddle is merchant of record; `admin/internal/paddle` is a thin REST client (no vendor SDK) and the only place that talks to it. **Free is entirely outside Paddle** — the shipped self-serve Free flow owns its own renewal, so no £0 subscription exists; an account learns its `paddle_customer_id` from its first paid webhook. Checkout happens in the browser (`@paddle/paddle-js`, token baked into the adminsite build); the server only updates a live subscription (`PUT /instances/:id/entitlement`) and mints a portal session.
|
||||
|
||||
`POST /api/paddle/webhook` is the **only** issuing path for paid plans: signature-verified with `PADDLE_WEBHOOK_SECRET` (boot-required), idempotent via `paddle_events`, and a function of the subscription's *current* line items — resolved back to a plan and configuration by `catalogue.ResolveItems`, so out-of-order delivery is correct by construction. A confirmed webhook promotes the entitlement `desired`→`granted` and signs from `granted` **only**; a checkout is built from `desired`. `subscription.canceled` and `past_due` take **no licence action** — the licence runs to its (grace-padded) expiry, then the existing lifecycle sweep lapses the instance. A renewal (`transaction.completed`, origin `subscription_recurring`) is the only moment a scheduled reduction collapses `desired` into `granted`. Self-hosted purchase creates a placeholder instance before payment (`POST /instances/self-hosted`); the licence is issued only once the customer pastes the install's real UUID (`POST /instances/:id/claim-link`), because a licence binds to that UUID.
|
||||
`POST /api/paddle/webhook` is the **only** issuing path for paid plans: signature-verified with `PADDLE_WEBHOOK_SECRET` (boot-required), idempotent via `paddle_events`, and a function of the subscription's _current_ line items — resolved back to a plan and configuration by `catalogue.ResolveItems`, so out-of-order delivery is correct by construction. A confirmed webhook promotes the entitlement `desired`→`granted` and signs from `granted` **only**; a checkout is built from `desired`. `subscription.canceled` and `past_due` take **no licence action** — the licence runs to its (grace-padded) expiry, then the existing lifecycle sweep lapses the instance. A renewal (`transaction.completed`, origin `subscription_recurring`) is the only moment a scheduled reduction collapses `desired` into `granted`. Self-hosted purchase creates a placeholder instance before payment (`POST /instances/self-hosted`); the licence is issued only once the customer pastes the install's real UUID (`POST /instances/:id/claim-link`), because a licence binds to that UUID.
|
||||
|
||||
## MongoDB Collections
|
||||
|
||||
@@ -424,7 +450,7 @@ Notes that are not obvious from the structs:
|
||||
- `console_sessions.token_consumed_at` is set atomically to enforce one-time use.
|
||||
- `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.
|
||||
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.
|
||||
|
||||
`plans` is keyed on `(deployment, tier)` — six rows, two deployments times three tiers — and holds base allowances only. **Every Paddle price ID lives in `catalogue`**, one row per priceable component (`base`, `limit`, `feature`), because a metered plan is priced by several prices and one map on a plan row cannot express that. `entitlements` holds one row per instance with `desired` beside `granted`: the checkout is built from `desired`, a licence is only ever signed from `granted`, and an abandoned checkout therefore leaves a `desired` that reached nothing. The two Free plans have **no catalogue rows at all**, which is what keeps Free outside Paddle.
|
||||
|
||||
@@ -498,32 +524,35 @@ Windows: MSI built by CI (WiX), or `installer/setup.ps1` registering the agent a
|
||||
|
||||
## Environment Variables (server)
|
||||
|
||||
| Name | Required | Notes |
|
||||
| -------------------------- | --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `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_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. |
|
||||
| `GITEA_HOST` | yes | used to build install scripts and agent download URLs |
|
||||
| `GUACD_ADDR` | no | default `guacd:4822` |
|
||||
| `APP_ROOT_LABEL` | no | default `vantage`; wrong value disables the host/session org guard |
|
||||
| `VANTAGE_WORKFLOW_LOG_DIR` | no | where run logs are written |
|
||||
| Name | Required | Notes |
|
||||
| -------------------------- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `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_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 |
|
||||
| `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):
|
||||
|
||||
| Name | Required | Notes |
|
||||
| --------------------------------- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| Name | Required | Notes |
|
||||
| --------------------------------- | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `MONGO_URI` | yes | **must point at the control plane's database.** sitesvc no longer provisions orgs itself, but it still refuses to start (`RequireMigratedDatabase`) against a database that has not run migration `0004` (the `orgs` → `instances` rename), and it (re)declares the shared `users.email` / `instances.slug` indexes at boot. The database name is read from the URI path; a URI without one is refused rather than defaulted. Note this differs from the server, which takes `MONGO_DB` separately. |
|
||||
| `SMTP_HOST` / `SMTP_FROM` | yes | without them the contact form refuses (503) rather than silently dropping |
|
||||
| `SMTP_TO` | no | default `support@hostxtra.co.uk`; contact enquiries only |
|
||||
| `SMTP_PORT` | no | default `587`; `465` uses implicit TLS |
|
||||
| `SMTP_USERNAME` / `SMTP_PASSWORD` | no | auth skipped when 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 |
|
||||
| `SMTP_HOST` / `SMTP_FROM` | yes | without them the contact form refuses (503) rather than silently dropping |
|
||||
| `SMTP_TO` | no | default `support@hostxtra.co.uk`; contact enquiries only |
|
||||
| `SMTP_PORT` | no | default `587`; `465` uses implicit TLS |
|
||||
| `SMTP_USERNAME` / `SMTP_PASSWORD` | no | auth skipped when 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 |
|
||||
|
||||
`deploy/docker-compose.yml` runs four services: `redis`, `guacd`, `server` (8080 + 9090), `web` (3000). MongoDB is external. `deploy/docker-compose.site.yml` adds four more — `site` (3003), `sitesvc` (8082), `admin` (8083) and `adminsite` (3004) — and is only used on vantage.hostxtra.co.uk.
|
||||
`deploy/docker-compose.yml` runs four services: `redis`, `guacd`, `server` (8080 + 9090), `web` (3000). MongoDB is external. `deploy/docker-compose.site.yml` adds five more — `site` (3003), `sitesvc` (8082), `admin` (8083), `adminsite` (3004) and `docsite` (3005) — and is only used on vantage.hostxtra.co.uk.
|
||||
|
||||
`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.
|
||||
|
||||
@@ -547,17 +576,20 @@ Windows: MSI built by CI (WiX), or `installer/setup.ps1` registering the agent a
|
||||
|
||||
Next.js 16 (App Router) + React 18, Tailwind 3, TanStack Query. Guacamole client bundled locally in `web/lib/guacamole-common.js`.
|
||||
|
||||
All three apps are **one visual system**, anchored on the logo navy. What differs between them is which end of it they stand on:
|
||||
All four apps are **one visual system**, anchored on the logo navy. What differs between them is which end of it they stand on:
|
||||
|
||||
| App | Ground | Accent | Themes |
|
||||
| --- | --- | --- | --- |
|
||||
| `web/` | `--ground` dark, `#071628` | `#5b9be8` | dark only, locked |
|
||||
| `site/` | token-based | `#0b2a58` light / `#5b9be8` dark | light + dark |
|
||||
| `adminsite/` | token-based | `#0b2a58` light / `#5b9be8` dark | light + dark, light default |
|
||||
| App | Ground | Accent | Themes |
|
||||
| ------------ | -------------------------- | -------------------------------- | --------------------------- |
|
||||
| `web/` | `--ground` dark, `#071628` | `#5b9be8` | dark only, locked |
|
||||
| `site/` | token-based | `#0b2a58` light / `#5b9be8` dark | light + dark |
|
||||
| `adminsite/` | token-based | `#0b2a58` light / `#5b9be8` dark | light + dark, light default |
|
||||
| `docsite/` | token-based | `#0b2a58` light / `#5b9be8` dark | light + dark, light default |
|
||||
|
||||
`adminsite/app/globals.css` holds `site/app/globals.css`'s token blocks **copied verbatim** — same names, same values. `web/app/globals.css` holds the same tokens too, but only the **dark** values, since it does not switch. **Change a token in all three files in the same commit; nothing enforces the match automatically**, the same shape of hazard as sitesvc's mirrored slug rules.
|
||||
`adminsite/app/globals.css` and `docsite/src/css/custom.css` hold `site/app/globals.css`'s token blocks **copied verbatim** — same names, same values. `web/app/globals.css` holds the same tokens too, but only the **dark** values, since it does not switch. **Change a token in all four files in the same commit; nothing enforces the match automatically**, the same shape of hazard as sitesvc's mirrored slug rules.
|
||||
|
||||
There is a **fourth** copy, and it is the one people forget: `shared/mail/templates/layout.html.tmpl` carries web/'s dark values as literal hex. Email clients support neither `var()` nor a reliable `prefers-color-scheme`, so the token indirection is simply not available there — an email is read before the recipient clicks through to the control plane, and the two should not look like different products. Every colour in the email system is in that one file, in the same way no component in the three web apps carries a hex.
|
||||
`docsite/` is the one place the tokens are not consumed through Tailwind: everything below its token block maps Docusaurus's `--ifm-*` variables onto them. Docusaurus already stamps `data-theme` on `<html>`, which is the selector `site/`'s dark block keys on, so the built-in toggle needed no wiring. The rule holds all the same — no rule in that file outside the token blocks carries a hex. The one concession is `docsite/static/img/favicon.svg`, which must, for the same reason the email layout must: a browser tab cannot read a token.
|
||||
|
||||
There is a **fifth** copy, and it is the one people forget: `shared/mail/templates/layout.html.tmpl` carries web/'s dark values as literal hex. Email clients support neither `var()` nor a reliable `prefers-color-scheme`, so the token indirection is simply not available there — an email is read before the recipient clicks through to the control plane, and the two should not look like different products. Every colour in the email system is in that one file, in the same way no component in the four web apps carries a hex.
|
||||
|
||||
Tailwind in all three maps `var(--…)` references only, so **no component in any of them may carry a hex value**. The names differ per app on purpose, because each app has its own subject: `site/` calls the semantic three `--up`/`--pend`/`--down` for monitor state, `adminsite/` aliases them to `valid`/`warn`/`expired` for licence state, and `web/` to `success`/`warning`/`danger`. Same colours, honest names on each side.
|
||||
|
||||
@@ -614,7 +646,7 @@ GOOS=linux GOARCH=amd64 go build \
|
||||
|
||||
### `server-deploy.yml` — triggered on every push to `main`
|
||||
|
||||
Builds and pushes six images to the Gitea container registry: `server`, `web`, `site`, `sitesvc`, `admin` and `adminsite`.
|
||||
Builds and pushes seven images to the Gitea container registry: `server`, `web`, `site`, `sitesvc`, `admin`, `adminsite` and `docsite`.
|
||||
|
||||
Note that despite the name, **this workflow does not deploy** — it only builds and pushes. There is no SSH step. Rolling images out is a separate manual step on the host:
|
||||
|
||||
@@ -625,16 +657,16 @@ cd /opt/vantage && docker compose -f docker-compose.yml -f docker-compose.site.y
|
||||
|
||||
**Each image only rebuilds when its own inputs changed.** A `git diff` against `github.event.before` decides, which is why the checkout uses `fetch-depth: 0` — the default shallow clone has one commit and nothing to diff — and why `git` is installed in the `docker:dind` container. The mapping follows the build contexts exactly:
|
||||
|
||||
| Image | Rebuilds when |
|
||||
| --- | --- |
|
||||
| `server` | `server/`, `shared/`, `proto/`, `go.work` |
|
||||
| `admin` | `admin/`, `shared/`, `go.work` |
|
||||
| `sitesvc` | `sitesvc/`, `shared/`, `go.work` |
|
||||
| `web` · `site` · `adminsite` | their own directory only |
|
||||
| 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 `shared/` from a root context — **if a fourth service ever imports `shared/`, add it to that list or it will ship stale**. A change to the workflow file rebuilds everything, since a build arg is baked into the image. So does anything that leaves no trustworthy base commit: a manual `workflow_dispatch`, a new branch, or a force-push whose old head is gone.
|
||||
|
||||
The gap this leaves: **changing a repo variable pushes no commit, so nothing rebuilds.** After editing `API_URL`, `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.
|
||||
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.
|
||||
|
||||
### Tagging
|
||||
|
||||
@@ -645,23 +677,26 @@ git push origin main # server + web deploy
|
||||
|
||||
### Secrets / variables
|
||||
|
||||
| Name | Type | Value |
|
||||
| -------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `RELEASE_TOKEN` | Secret | Gitea API token, `write:release` |
|
||||
| `REGISTRY_USER` | Secret | Gitea username |
|
||||
| `REGISTRY_PASSWORD` | Secret | Gitea token, `write:packages` |
|
||||
| `GITEA_HOST` | Variable | `gitea.hostxtra.co.uk` |
|
||||
| `DOCKER_HOST` | Variable | registry host used for image tags |
|
||||
| `API_URL` | Variable | baked into the `web` image at build time |
|
||||
| `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 |
|
||||
| `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. |
|
||||
| `PADDLE_CLIENT_TOKEN`| Variable | **browser** Paddle token, baked into the `adminsite` image for checkout. A repo-variable change pushes no commit, so rebuild `adminsite` manually via `workflow_dispatch` after editing it. |
|
||||
| `PADDLE_ENV` | Variable | `sandbox` or `production`; baked into `adminsite` AND read by `admin` at runtime. Selects which `catalogue` price IDs are served, and must match on both sides. |
|
||||
| `PADDLE_API_KEY` | Secret | server-side Paddle key, read by `admin` at runtime. Boot-required. |
|
||||
| `PADDLE_WEBHOOK_SECRET` | Secret | webhook signature verification, read by `admin`. Boot-required — an unverified endpoint is one anyone can issue licences through. |
|
||||
| Name | Type | Value |
|
||||
| ----------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `RELEASE_TOKEN` | Secret | Gitea API token, `write:release` |
|
||||
| `REGISTRY_USER` | Secret | Gitea username |
|
||||
| `REGISTRY_PASSWORD` | Secret | Gitea token, `write:packages` |
|
||||
| `DOCKER_HOST` | Variable | registry host used for image tags |
|
||||
| `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. |
|
||||
| `PADDLE_CLIENT_TOKEN` | Variable | **browser** Paddle token, baked into the `adminsite` image for checkout. A repo-variable change pushes no commit, so rebuild `adminsite` manually via `workflow_dispatch` after editing it. |
|
||||
| `PADDLE_ENV` | Variable | `sandbox` or `production`; baked into `adminsite` AND read by `admin` at runtime. Selects which `catalogue` price IDs are served, and must match on both sides. |
|
||||
| `PADDLE_API_KEY` | Secret | server-side Paddle key, read by `admin` at runtime. Boot-required. |
|
||||
| `PADDLE_WEBHOOK_SECRET` | Secret | webhook signature verification, read by `admin`. Boot-required — an unverified endpoint is one anyone can issue licences through. |
|
||||
| `DOCS_URL` | Variable | site `url` baked into `docsite`; `https://vantage.hostxtra.co.uk`. Empty falls back to that default rather than breaking the build. |
|
||||
| `DOCS_BASE_URL` | Variable | `/docs/`. Must match the NPM location and the directory the image serves from — all three, or the HTML loads and every asset 404s. |
|
||||
| `APP_URL` | Variable | control-plane link in `docsite`'s navbar. |
|
||||
|
||||
---
|
||||
|
||||
@@ -681,3 +716,13 @@ git push origin main # server + web deploy
|
||||
- **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.
|
||||
- **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).
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"kind": "vantage.step/v1",
|
||||
"name": "Get Host Name",
|
||||
"description": "",
|
||||
"interpreter": "bash",
|
||||
"script": "HOSTNAME=$(hostname)\necho $HOSTNAME\necho \"HOSTNAME=$HOSTNAME\" \u003e\u003e $WORKFLOW_ENV",
|
||||
"declared_outputs": [
|
||||
"HOSTNAME"
|
||||
],
|
||||
"declared_inputs": [],
|
||||
"secret_refs": []
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"kind": "vantage.step/v1",
|
||||
"name": "List Directory",
|
||||
"description": "",
|
||||
"interpreter": "bash",
|
||||
"script": "if [ ! -e $path ]; then\n echo \"file or directory doesn't exist: $path\"\n exit 1\nfi\nls -l $path",
|
||||
"declared_outputs": [],
|
||||
"declared_inputs": [
|
||||
{
|
||||
"name": "path",
|
||||
"default": "./",
|
||||
"description": ""
|
||||
}
|
||||
],
|
||||
"secret_refs": []
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"kind": "vantage.step/v1",
|
||||
"name": "Start Linux Service",
|
||||
"description": "",
|
||||
"interpreter": "bash",
|
||||
"script": "echo \"starting service $serviceName\"\nsystemctl start $serviceName\nretVal=$?\nif [ $retVal -ne 0 ]; then\n echo \"failed to start service\"\n exit 1\nfi",
|
||||
"declared_outputs": [],
|
||||
"declared_inputs": [
|
||||
{
|
||||
"name": "serviceName",
|
||||
"default": "",
|
||||
"description": ""
|
||||
}
|
||||
],
|
||||
"secret_refs": []
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"kind": "vantage.step/v1",
|
||||
"name": "Stop Linux Service",
|
||||
"description": "",
|
||||
"interpreter": "bash",
|
||||
"script": "echo \"stopping service $serviceName\"\nsystemctl stop $serviceName\nretVal=$?\nif [ $retVal -ne 0 ]; then\n echo \"failed to stop service\"\n exit 1\nfi",
|
||||
"declared_outputs": [],
|
||||
"declared_inputs": [
|
||||
{
|
||||
"name": "serviceName",
|
||||
"default": "",
|
||||
"description": ""
|
||||
}
|
||||
],
|
||||
"secret_refs": []
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"kind": "vantage.step/v1",
|
||||
"name": "WGET",
|
||||
"description": "",
|
||||
"interpreter": "bash",
|
||||
"script": "out=$(mktemp -p ./)\necho \"Downloading file from $url\"\nwget -q $url -O $out\nretVal=$?\nif [ $retVal -ne 0 ]; then\n echo \"failed to download file from url\"\n exit 1\nfi\necho \"FILE_PATH=$out\" \u003e\u003e $WORKFLOW_ENV",
|
||||
"declared_outputs": [
|
||||
"FILE_PATH"
|
||||
],
|
||||
"declared_inputs": [
|
||||
{
|
||||
"name": "url",
|
||||
"default": "",
|
||||
"description": ""
|
||||
}
|
||||
],
|
||||
"secret_refs": []
|
||||
}
|
||||
@@ -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,17 @@
|
||||
Vantage has been deployed as release "{{ .Release.Name }}" in namespace "{{ .Release.Namespace }}".
|
||||
|
||||
Services created:
|
||||
- {{ .Release.Name }}-redis (ClusterIP {{ .Values.redis.port }})
|
||||
- {{ .Release.Name }}-mongo (ClusterIP {{ .Values.mongo.port }})
|
||||
- {{ .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 }})
|
||||
|
||||
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,11 @@
|
||||
{{/*
|
||||
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 -}}
|
||||
@@ -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,90 @@
|
||||
{{- 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 }}
|
||||
@@ -0,0 +1,90 @@
|
||||
{{- 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 }}
|
||||
@@ -0,0 +1,123 @@
|
||||
{{- 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: 1
|
||||
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 }}
|
||||
# Wait for redis & mongo to be reachable, approximating compose's
|
||||
# `depends_on: condition: service_healthy`
|
||||
initContainers:
|
||||
- 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
|
||||
- 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
|
||||
containers:
|
||||
- name: server
|
||||
image: "{{ .Values.server.image.repository }}:{{ .Values.server.image.tag }}"
|
||||
ports:
|
||||
- containerPort: {{ .Values.server.service.httpPort }}
|
||||
- containerPort: {{ .Values.server.service.grpcPort }}
|
||||
env:
|
||||
- name: MONGO_URI
|
||||
value: {{ tpl .Values.server.env.mongoUri . | quote }}
|
||||
- name: REDIS_ADDR
|
||||
value: "{{ .Release.Name }}-redis:{{ .Values.redis.port }}"
|
||||
- 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: VANTAGE_WORKFLOW_LOG_DIR
|
||||
value: {{ .Values.server.env.vantageWorkflowLogDir | 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 }}
|
||||
volumeMounts:
|
||||
- name: server-data
|
||||
mountPath: /data
|
||||
volumes:
|
||||
- name: server-data
|
||||
{{- if .Values.server.persistence.enabled }}
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ .Release.Name }}-server-data
|
||||
{{- else }}
|
||||
emptyDir: {}
|
||||
{{- 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,57 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-web
|
||||
labels:
|
||||
{{- include "vantage.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: web
|
||||
spec:
|
||||
replicas: 1
|
||||
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 }}
|
||||
---
|
||||
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,66 @@
|
||||
# Default values for the vantage chart.
|
||||
|
||||
redis:
|
||||
image:
|
||||
repository: redis
|
||||
tag: "8"
|
||||
persistence:
|
||||
enabled: true
|
||||
size: 1Gi
|
||||
storageClass: ""
|
||||
accessMode: ReadWriteOnce
|
||||
port: 6379
|
||||
|
||||
mongo:
|
||||
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:
|
||||
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: ""
|
||||
vantageWorkflowLogDir: ""
|
||||
appRootLabel: vantage
|
||||
proxyAdvertiseHost: "{{ .Release.Name }}-server"
|
||||
proxyListenHost: "0.0.0.0"
|
||||
persistence:
|
||||
enabled: true
|
||||
size: 1Gi
|
||||
storageClass: ""
|
||||
accessMode: ReadWriteOnce
|
||||
hostPath: /data
|
||||
|
||||
web:
|
||||
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,10 +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
|
||||
|
||||
# Gitea host used to build agent install scripts and download URLs.
|
||||
GITEA_HOST=gitea.hostxtra.co.uk
|
||||
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.
|
||||
@@ -55,4 +55,13 @@ services:
|
||||
- 3004:3000
|
||||
depends_on:
|
||||
- admin
|
||||
# Static docs, served by nginx at vantage.hostxtra.co.uk/docs through its own
|
||||
# proxy location. That location must sort ABOVE the catch-all forwarding to
|
||||
# site:3003, or Next serves its own 404 for /docs. The container serves from
|
||||
# /usr/share/nginx/html/docs because the proxy forwards the full path.
|
||||
docsite:
|
||||
image: gitea.hostxtra.co.uk/mrhid6/vantage/docsite:latest
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- 3005:80
|
||||
networks: {}
|
||||
@@ -41,14 +41,13 @@ services:
|
||||
environment:
|
||||
MONGO_URI: ${MONGO_URI:-mongodb://mongo:27017/vantage}
|
||||
REDIS_ADDR: redis:6379
|
||||
GITEA_HOST: ${GITEA_HOST}
|
||||
GRPC_HOST: ${GRPC_HOST}
|
||||
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
|
||||
@@ -61,6 +60,8 @@ services:
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- 3000:3000
|
||||
environment:
|
||||
API_URL: ${API_URL:-http://server:8080}
|
||||
depends_on:
|
||||
- server
|
||||
volumes:
|
||||
@@ -1830,7 +1830,6 @@ sleep 6
|
||||
MSYS_NO_PATHCONV=1 docker run -d --name p2-server -p 8092:8080 \
|
||||
-e MONGO_URI=mongodb://host.docker.internal:27024 -e MONGO_DB=p2 \
|
||||
-e GRPC_HOST=localhost:9090 -e REDIS_ADDR=host.docker.internal:6390 \
|
||||
-e GITEA_HOST=example.invalid \
|
||||
--add-host host.docker.internal:host-gateway vantage-server:p2
|
||||
sleep 6
|
||||
MSYS_NO_PATHCONV=1 docker logs p2-server 2>&1 | grep -i reaper
|
||||
@@ -2012,7 +2011,6 @@ MSYS_NO_PATHCONV=1 docker rm -f p2-server
|
||||
MSYS_NO_PATHCONV=1 docker run -d --name p2-server -p 8092:8080 \
|
||||
-e MONGO_URI=mongodb://host.docker.internal:27024 -e MONGO_DB=p2 \
|
||||
-e GRPC_HOST=localhost:9090 -e REDIS_ADDR=host.docker.internal:6390 \
|
||||
-e GITEA_HOST=example.invalid -e FREE_INSTANCE_REAP_AFTER=336h \
|
||||
--add-host host.docker.internal:host-gateway vantage-server:p2
|
||||
sleep 6
|
||||
MSYS_NO_PATHCONV=1 docker logs p2-server 2>&1 | grep -i reaper
|
||||
|
||||
@@ -780,7 +780,6 @@ Expected: output includes `email_1`. This reproduces a database that predates th
|
||||
MSYS_NO_PATHCONV=1 docker run -d --name vantage-idx-server -p 8091:8080 \
|
||||
-e MONGO_URI=mongodb://host.docker.internal:27023 -e MONGO_DB=vantage_idx \
|
||||
-e GRPC_HOST=localhost:9090 -e REDIS_ADDR=host.docker.internal:6389 \
|
||||
-e GITEA_HOST=example.invalid \
|
||||
--add-host host.docker.internal:host-gateway vantage-server:test
|
||||
|
||||
MSYS_NO_PATHCONV=1 docker run --rm --add-host host.docker.internal:host-gateway mongo:7 \
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,162 @@
|
||||
# Documentation site — design
|
||||
|
||||
**Date:** 2026-07-28
|
||||
**Status:** approved
|
||||
|
||||
## Problem
|
||||
|
||||
Vantage has no user-facing documentation. Everything an operator needs — how to
|
||||
install self-hosted, how to enrol an agent, what a workflow step is, how a
|
||||
licence gets issued — lives either in `CLAUDE.md` (written for contributors, not
|
||||
users) or in the code. The marketing site sells the product and the control
|
||||
plane runs it; neither explains it.
|
||||
|
||||
## Solution
|
||||
|
||||
A fourth Next-adjacent frontend, `docsite/`, built with Docusaurus v3 and shipped
|
||||
alongside the marketing site.
|
||||
|
||||
### Placement and deployment
|
||||
|
||||
- Lives at repo root as `docsite/`.
|
||||
- Served at **`vantage.hostxtra.co.uk/docs`** — a path on the marketing site's
|
||||
host, routed by a separate Nginx Proxy Manager custom location rather than by
|
||||
`site/`. It is a path, not a subdomain, deliberately: `*.vantage.hostxtra.co.uk`
|
||||
is the per-tenant instance namespace and `APP_ROOT_LABEL` resolves an org from
|
||||
the label before `vantage`, so a `docs.` label there would be read as a tenant
|
||||
slug.
|
||||
|
||||
This makes `baseUrl: "/docs/"` load-bearing. An NPM custom location forwards
|
||||
the **full** request path upstream — it does not strip the `/docs` prefix — so
|
||||
the container serves the build from `/usr/share/nginx/html/docs`, not from the
|
||||
document root. Prefix, asset URLs and upstream paths then agree with no
|
||||
rewrite rule to keep in step. Getting this wrong is quiet: the HTML loads and
|
||||
every stylesheet and script 404s.
|
||||
- Added to **`deploy/docker-compose.site.yml` only**, published as `3005`.
|
||||
`deploy/docker-compose.yml` (the self-hosted install) must never mention it,
|
||||
exactly as it never mentions `site`, `sitesvc`, `admin` or `adminsite`.
|
||||
- Added to `.gitea/workflows/server-deploy.yml` as a seventh image, rebuilding
|
||||
on `^docsite/` only, build context `docsite/`.
|
||||
|
||||
### Runtime
|
||||
|
||||
Docusaurus emits a fully static site, so unlike `site/`, `web/` and
|
||||
`adminsite/` there is no Node server at runtime. Two stages:
|
||||
|
||||
1. `node:26-alpine` builder — `npm ci && npm run build` → `/app/build`.
|
||||
2. `nginx:alpine-slim` runner — copies `build/` to
|
||||
`/usr/share/nginx/html/docs` (see the `/docs` prefix note above), plus a
|
||||
small `nginx.conf` giving `try_files` a 404 fallback to Docusaurus's
|
||||
`404.html` and long cache headers on `/docs/assets/`. A bare `/` request
|
||||
redirects to `/docs/`, so hitting the container directly is not a blank 403.
|
||||
|
||||
`nginx:alpine-slim` is roughly 12MB against `caddy:alpine`'s ~50MB, and nothing
|
||||
here needs Caddy's automatic TLS — the host proxy already terminates it.
|
||||
|
||||
Build args, baked at build time the same way `site/`'s are:
|
||||
|
||||
| Arg | Purpose |
|
||||
| --- | --- |
|
||||
| `DOCS_URL` | site `url`; defaults to `https://vantage.hostxtra.co.uk` |
|
||||
| `DOCS_BASE_URL` | site `baseUrl`; defaults to `/docs/`. Must match the NPM location and the runner's copy target |
|
||||
| `APP_URL` | navbar link to the control plane |
|
||||
| `HQ_URL` | navbar link to the HQ portal |
|
||||
|
||||
### Theme
|
||||
|
||||
`docsite/src/css/custom.css` carries `site/app/globals.css`'s token blocks
|
||||
**copied verbatim** — same names, same values — and maps Docusaurus's `--ifm-*`
|
||||
variables onto them. Docusaurus stamps `data-theme="light|dark"` on `<html>`,
|
||||
which is the same selector `site/`'s dark block already keys on, so the built-in
|
||||
toggle works with no extra wiring. Light is the default, matching `site/` and
|
||||
`adminsite/`.
|
||||
|
||||
This makes a **fifth** copy of the token block (`site/`, `adminsite/`, `web/`
|
||||
dark-only, `shared/mail/templates/layout.html.tmpl` as literal hex, and now
|
||||
`docsite/`). Nothing enforces the match; `CLAUDE.md`'s Frontend section is
|
||||
updated to say so. No component in `docsite/` may carry a hex value.
|
||||
|
||||
Search is `@easyops-cn/docusaurus-search-local` — index built at compile time,
|
||||
served from the same origin. No Algolia account, no external host, nothing to
|
||||
key or rotate.
|
||||
|
||||
Docs-only mode: `routeBasePath: "/"`, blog disabled, no tutorial scaffolding.
|
||||
|
||||
## Content
|
||||
|
||||
Sidebar is authored explicitly in `sidebars.ts` rather than autogenerated, so
|
||||
ordering is a decision rather than a filename accident.
|
||||
|
||||
### Getting Started
|
||||
|
||||
| Page | Covers |
|
||||
| --- | --- |
|
||||
| `what-is-vantage` | The control plane, the agent, what problem each solves |
|
||||
| `cloud-vs-self-hosted` | The two deployments, what differs (licensing, HQ-managed users, reaping) |
|
||||
| `self-hosted-install` | Prereqs (Docker, external MongoDB, DNS, TLS), `docker-compose.yml`, required env, `docker compose up -d` |
|
||||
| `first-login` | `/setup` bootstrap, first org and owner |
|
||||
| `first-server` | `POST /servers/new`, the install one-liner, Linux and Windows, watching it flip to `active` |
|
||||
| `claim-free-licence` | Linking the install to an HQ account, `claim-free` |
|
||||
|
||||
`self-hosted-install` is the page the section exists for; it names every
|
||||
required environment variable with its consequence-of-omission, in particular
|
||||
`GRPC_HOST` (boot fails, no default is safe) and `KEY_ENCRYPTION_KEY`.
|
||||
|
||||
### Vantage (the application)
|
||||
|
||||
`servers` (agent install Linux/Windows, inventory, OS updates, agent
|
||||
self-update) · `ssh-keys` (upload, generate-on-server, assign, revoke, what the
|
||||
agent writes and when) · `workflows` (step library, default steps and why they
|
||||
are read-only, the designer, running, live logs, `on_failure`, `output_env`,
|
||||
workspaces, log retention) · `monitors` (the four check types, server vs agent
|
||||
runner, retries, incidents, uptime rollups) · `notification-channels` (five
|
||||
types, testing) · `secrets` (vault, `secret_refs` in steps, the ESO read path
|
||||
and its bearer token) · `browser-console` (SSH/RDP/VNC, one-time tokens) ·
|
||||
`audit-log` · `settings` (members and roles, OIDC per org, retention, ESO token,
|
||||
licence).
|
||||
|
||||
### Vantage HQ (the portal)
|
||||
|
||||
`accounts-and-signup` (account-first signup, email verification, an account is
|
||||
a team) · `people-and-roles` (owner/admin/member, invitations, accepting) ·
|
||||
`cloud-instances` (create, overview, granting members and what a grant actually
|
||||
is) · `self-hosted-instances` (purchase creates a placeholder, claim-link binds
|
||||
the real UUID, relink) · `licensing-and-entitlements` (tiers, metered server
|
||||
count, feature toggles, desired vs granted) · `billing` (Paddle as merchant of
|
||||
record, checkout, the customer portal, changing configuration) · `free-tier`
|
||||
(limits, the renewal window, reaping on cloud).
|
||||
|
||||
### Reference
|
||||
|
||||
`environment-variables` (server, sitesvc, admin, agent) · `rest-api` (the route
|
||||
tables, grouped as in `CLAUDE.md`) · `grpc-api` (the eight RPCs, the command
|
||||
stream) · `agent-config` (config.yaml, paths, permissions) ·
|
||||
`ports-and-networking` (which ports, which direction, what needs to be
|
||||
reachable) · `troubleshooting`.
|
||||
|
||||
### Operations
|
||||
|
||||
`upgrading` (pull and recreate) · `backups` (MongoDB is the durable state; Redis
|
||||
is sessions only) · `agent-updates` · `ci-cd` (which image rebuilds when, and
|
||||
the repo-variable gap that pushes no commit).
|
||||
|
||||
## Writing rules
|
||||
|
||||
- Every guide is task-shaped: numbered steps, real paths and commands taken from
|
||||
the repository, never invented UI.
|
||||
- Behaviour that is a hard refusal gets an admonition, not a paragraph: default
|
||||
steps are read-only (409 `ErrDefaultStep`), `POST /license` answers 409
|
||||
`cloud_managed` on cloud, HQ-sourced users cannot have their role changed
|
||||
locally (409 `ErrHQManaged`).
|
||||
- Where the UI enforces something, say that the API is the boundary and the UI
|
||||
is the courtesy — the same phrasing the codebase uses.
|
||||
- No screenshots in this pass. They rot faster than prose and there is no
|
||||
capture pipeline.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Versioned documentation. One version, tracking `main`. Docusaurus versioning
|
||||
can be switched on later without restructuring.
|
||||
- Internationalisation.
|
||||
- Screenshots and diagrams beyond what Mermaid renders inline.
|
||||
- A docs search backed by an external service.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
build
|
||||
.docusaurus
|
||||
.git
|
||||
.gitignore
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
build
|
||||
.docusaurus
|
||||
.cache-loader
|
||||
@@ -0,0 +1,40 @@
|
||||
# Build stage
|
||||
FROM node:26-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
|
||||
# Baked in at build time. DOCS_BASE_URL must agree with three things at once:
|
||||
# the Nginx Proxy Manager location that routes to this container, the directory
|
||||
# the runtime stage serves from below, and this value. When they disagree the
|
||||
# HTML loads and every stylesheet and script 404s.
|
||||
ARG DOCS_URL="https://vantage.hostxtra.co.uk"
|
||||
ARG DOCS_BASE_URL="/docs/"
|
||||
ARG APP_URL="https://vantage.hostxtra.co.uk"
|
||||
ARG HQ_URL="https://vantage-hq.hostxtra.co.uk"
|
||||
ENV DOCS_URL=$DOCS_URL
|
||||
ENV DOCS_BASE_URL=$DOCS_BASE_URL
|
||||
ENV APP_URL=$APP_URL
|
||||
ENV HQ_URL=$HQ_URL
|
||||
|
||||
RUN npm run build
|
||||
|
||||
# Runtime stage
|
||||
#
|
||||
# Docusaurus emits a fully static site, so unlike web/, site/ and adminsite/
|
||||
# there is no Node server at runtime. alpine-slim is roughly a quarter the size
|
||||
# of caddy:alpine, and nothing here needs automatic TLS — the host proxy
|
||||
# terminates it.
|
||||
FROM nginx:alpine-slim AS runner
|
||||
|
||||
# NPM forwards the FULL request path upstream; it does not strip the /docs
|
||||
# prefix. Serving from a matching subdirectory means prefix, asset URLs and
|
||||
# upstream paths agree with no rewrite rule to keep in step.
|
||||
COPY --from=builder /app/build /usr/share/nginx/html/docs
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80
|
||||
@@ -0,0 +1,80 @@
|
||||
---
|
||||
id: claim-free-licence
|
||||
title: Claim a Free licence
|
||||
sidebar_label: Claim a Free licence
|
||||
---
|
||||
|
||||
A self-hosted install runs unlicensed until you give it a licence. Free is a
|
||||
real tier in both deployments, and you can claim one for your install from the
|
||||
HQ portal.
|
||||
|
||||
## What a licence is
|
||||
|
||||
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
|
||||
does not need HQ to be reachable.
|
||||
|
||||
Signing happens in exactly one place, in HQ. The control plane can only verify.
|
||||
|
||||
## 1. Find your instance UUID
|
||||
|
||||
In the control plane, go to **Settings → Licence**. The instance UUID is shown
|
||||
there. It is the identity your licence binds to.
|
||||
|
||||
## 2. Link the install to your HQ account
|
||||
|
||||
1. Sign in at [Vantage HQ](https://vantage-hq.hostxtra.co.uk). If you have no
|
||||
account, see [Accounts and signup](../hq/accounts-and-signup.md).
|
||||
2. Choose **Link an instance**.
|
||||
3. Paste the instance UUID and give it a name you will recognise.
|
||||
|
||||
Linking claims the UUID for your account. A UUID already linked elsewhere is
|
||||
refused with a conflict rather than silently moved.
|
||||
|
||||
## 3. Claim Free
|
||||
|
||||
With the instance linked, choose **Claim Free** on it. HQ issues a Free licence
|
||||
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 stricter than the issuer would refuse something that would actually
|
||||
have worked.
|
||||
:::
|
||||
|
||||
## 4. Install the licence
|
||||
|
||||
Download the licence from HQ and paste it in the control plane at
|
||||
**Settings → Licence**.
|
||||
|
||||
The instance validates the signature, checks the UUID matches its own, and
|
||||
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,
|
||||
not through the endpoint.
|
||||
:::
|
||||
|
||||
## Renewing
|
||||
|
||||
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
|
||||
is exempt from the licence check, because it is the way out of degraded mode.
|
||||
|
||||
## Moving the install to new hardware
|
||||
|
||||
Rebuilding produces a new instance UUID, and a licence binds to a UUID. Use
|
||||
**Relink** in HQ to move the licence across. The number of relinks per term is
|
||||
capped; the portal shows how many you have left.
|
||||
|
||||
## Next
|
||||
|
||||
- [Licensing and entitlements](../hq/licensing-and-entitlements.md)
|
||||
- [Buying a paid self-hosted licence](../hq/self-hosted-instances.md)
|
||||
@@ -0,0 +1,67 @@
|
||||
---
|
||||
id: cloud-vs-self-hosted
|
||||
title: Cloud or self-hosted
|
||||
sidebar_label: Cloud or self-hosted
|
||||
---
|
||||
|
||||
Vantage runs in two deployments. They are the same software; what differs is
|
||||
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
|
||||
|
||||
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
|
||||
password, role and existence.
|
||||
|
||||
:::info The instance does not phone home
|
||||
A grant writes a user row into the control plane once. After that the instance
|
||||
authenticates that person entirely on its own. HQ being down does not stop
|
||||
anyone signing in to a running instance.
|
||||
:::
|
||||
|
||||
Cloud instances on the Free tier are reaped after their licence expires, with
|
||||
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
|
||||
signed file, verified locally.
|
||||
|
||||
Two ways to get one:
|
||||
|
||||
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
|
||||
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.
|
||||
|
||||
## 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
|
||||
deployment at creation, and a licence binds to an instance UUID.
|
||||
|
||||
## Next
|
||||
|
||||
- [Self-hosted install](./self-hosted-install.md)
|
||||
- [Accounts and signup](../hq/accounts-and-signup.md) if you are going cloud
|
||||
@@ -0,0 +1,75 @@
|
||||
---
|
||||
id: first-login
|
||||
title: First login
|
||||
sidebar_label: First login
|
||||
---
|
||||
|
||||
A fresh install has no users and no organisation. The first visit creates both.
|
||||
|
||||
## 1. Bootstrap
|
||||
|
||||
Open the control plane in a browser. Because no user exists, you land on
|
||||
`/setup`.
|
||||
|
||||
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 |
|
||||
|
||||
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
|
||||
one exists, `/setup` redirects to the login page and the bootstrap endpoint
|
||||
refuses. There is no second chance to create the first owner, so record the
|
||||
credentials before you close the tab.
|
||||
:::
|
||||
|
||||
## 2. Sign in
|
||||
|
||||
You are taken to `/login`. Sign in with the email and password you just set.
|
||||
|
||||
Sessions are an opaque 32-byte token in the `km_session` cookie, with the body
|
||||
held in Redis for 24 hours. Restarting Redis signs everyone out and loses
|
||||
nothing else.
|
||||
|
||||
## 3. Look around
|
||||
|
||||
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 |
|
||||
|
||||
## 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 |
|
||||
|
||||
Settings and organisation management require `owner` or `admin`.
|
||||
|
||||
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.
|
||||
|
||||
## Next
|
||||
|
||||
[Add your first server](./first-server.md).
|
||||
@@ -0,0 +1,111 @@
|
||||
---
|
||||
id: first-server
|
||||
title: Add your first server
|
||||
sidebar_label: Add your first server
|
||||
---
|
||||
|
||||
Enrolling a machine means running one command on it. The control plane issues a
|
||||
short-lived token, the install script fetches the agent and writes a config, and
|
||||
the machine registers itself.
|
||||
|
||||
## 1. Create the enrolment
|
||||
|
||||
In the UI, go to **Servers → Add server**. That calls `POST /api/servers/new`,
|
||||
which generates a server ID and a pre-registration token and hands back a ready
|
||||
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.
|
||||
:::
|
||||
|
||||
## 2. Run the one-liner
|
||||
|
||||
### Linux
|
||||
|
||||
```bash
|
||||
curl -fsSL "https://vantage.example.com/install?server_id=<id>&token=<token>" | bash
|
||||
```
|
||||
|
||||
Run it as root. The script:
|
||||
|
||||
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.
|
||||
4. Installs to `/usr/local/bin/vantage-agent`, mode `0755`.
|
||||
5. Writes `/etc/vantage/config.yaml` (directory `0700`, file `0600`) containing
|
||||
the server ID, the pre-registration token and the gRPC host.
|
||||
6. Writes `/etc/systemd/system/vantage-agent.service` with `Restart=always`, and
|
||||
runs `systemctl enable --now vantage-agent`.
|
||||
|
||||
### Windows
|
||||
|
||||
```powershell
|
||||
irm "https://vantage.example.com/install.ps1?server_id=<id>&token=<token>" | iex
|
||||
```
|
||||
|
||||
Run from an elevated PowerShell. The agent is registered as a service through
|
||||
NSSM, with the config at `%ProgramData%\vantage\config.yaml`. There is also an
|
||||
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
|
||||
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`.
|
||||
|
||||
On the machine:
|
||||
|
||||
```bash
|
||||
systemctl status vantage-agent
|
||||
journalctl -u vantage-agent -f
|
||||
```
|
||||
|
||||
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
|
||||
3. Reconnect with the permanent token
|
||||
4. Start: command stream · hourly update check · inventory · monitors
|
||||
5. Enter the key poll loop
|
||||
```
|
||||
|
||||
After registration the config no longer contains the pre-registration token; it
|
||||
contains a permanent agent token instead. The control plane stores only the
|
||||
SHA-256 of that token, never the token itself.
|
||||
|
||||
## 4. Confirm it works
|
||||
|
||||
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
|
||||
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 |
|
||||
|
||||
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.
|
||||
|
||||
## Next
|
||||
|
||||
- [Assign an SSH key](../vantage/ssh-keys.md)
|
||||
- [Run a workflow](../vantage/workflows.md)
|
||||
- [Claim a Free licence](./claim-free-licence.md)
|
||||
@@ -0,0 +1,136 @@
|
||||
---
|
||||
id: self-hosted-install
|
||||
title: Install Vantage (self-hosted)
|
||||
sidebar_label: Self-hosted install
|
||||
---
|
||||
|
||||
This installs the control plane on a host you own. Budget about fifteen minutes.
|
||||
|
||||
## Before you start
|
||||
|
||||
You need:
|
||||
|
||||
- A Linux host with **Docker** and the **Compose plugin**.
|
||||
- A DNS name pointing at it. You will use it for both the web UI and, with a
|
||||
port, for agents.
|
||||
- A reverse proxy terminating TLS in front of the web UI. gRPC on `:9090` is
|
||||
reached directly by agents.
|
||||
- Two ports reachable from every machine you intend to manage: the web port for
|
||||
people, and **9090** for agents.
|
||||
- Outbound access from the control plane, and from every managed machine, to
|
||||
`gitea.hostxtra.co.uk`, which serves the agent releases.
|
||||
|
||||
The stack itself brings MongoDB, Redis and guacd with it. You do not need to
|
||||
provide a database.
|
||||
|
||||
## 1. Get the compose file
|
||||
|
||||
Put `deploy/docker-compose.yml` from the repository in a working directory, for
|
||||
example `/opt/vantage`.
|
||||
|
||||
```bash
|
||||
mkdir -p /opt/vantage/data && cd /opt/vantage
|
||||
# copy docker-compose.yml here
|
||||
```
|
||||
|
||||
The `server` service bind-mounts `./data`, which is where workflow run logs are
|
||||
written. Create it before first boot so it is not owned by root-in-container in
|
||||
a way you did not intend.
|
||||
|
||||
## 2. Write the environment file
|
||||
|
||||
Create `/opt/vantage/.env`:
|
||||
|
||||
```bash
|
||||
# The host:port agents dial. NOT the web URL this port speaks gRPC.
|
||||
GRPC_HOST=vantage.example.com:9090
|
||||
|
||||
|
||||
# 32 bytes as 64 hex characters. Generate with the command below.
|
||||
KEY_ENCRYPTION_KEY=
|
||||
|
||||
# Optional: where workflow run logs are written inside the container.
|
||||
VANTAGE_WORKFLOW_LOG_DIR=/data/workflow-logs
|
||||
```
|
||||
|
||||
Generate the encryption key:
|
||||
|
||||
```bash
|
||||
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
|
||||
the server it protects, and never rotate it without a planned re-encryption.
|
||||
:::
|
||||
|
||||
:::warning `GRPC_HOST` has no default
|
||||
The server refuses to boot without it. There is deliberately no fallback to the
|
||||
web host: that would hand every agent a port that does not speak gRPC, and the
|
||||
failure would only surface later, on each agent, as a connection error.
|
||||
:::
|
||||
|
||||
## 3. Start the stack
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
Five services come up: `mongo`, `redis`, `guacd`, `server` and `web`.
|
||||
|
||||
Check the server got through boot:
|
||||
|
||||
```bash
|
||||
docker compose logs -f server
|
||||
```
|
||||
|
||||
Boot runs database migrations, builds indexes and seeds the default workflow
|
||||
step library. Index builders for auth and settings are **fatal on failure** —
|
||||
they enforce tenant isolation, so the server would rather not start than start
|
||||
without them.
|
||||
|
||||
## 4. Put a proxy in front
|
||||
|
||||
Point your reverse proxy at `web` on port `3000` and terminate TLS there. The
|
||||
web app calls the REST API through a Next rewrite, so you do not need to expose
|
||||
`8080` publicly.
|
||||
|
||||
Do **not** proxy `9090`. Agents connect to it directly over TLS.
|
||||
|
||||
## 5. First sign-in
|
||||
|
||||
Open your hostname in a browser. With no users in the database, you are sent to
|
||||
`/setup`.
|
||||
|
||||
Continue with [First login](./first-login.md).
|
||||
|
||||
## Verifying the install
|
||||
|
||||
| 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 |
|
||||
|
||||
## Common install problems
|
||||
|
||||
**Server exits immediately.** Almost always a missing `GRPC_HOST`. The log line
|
||||
names it.
|
||||
|
||||
**Agents register but never go active.** They reached `:9090` for `Register` but
|
||||
cannot sustain the poll, or `GRPC_HOST` names a host they resolve differently.
|
||||
Check from the managed machine, not from the control plane host.
|
||||
|
||||
**Secrets pages error.** `KEY_ENCRYPTION_KEY` is empty or not 64 hex characters.
|
||||
|
||||
More in [Troubleshooting](../reference/troubleshooting.md).
|
||||
|
||||
## What this install does not include
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,63 @@
|
||||
---
|
||||
id: what-is-vantage
|
||||
title: What is Vantage
|
||||
sidebar_label: What is Vantage
|
||||
---
|
||||
|
||||
Vantage manages a fleet of servers from one place. It began as SSH key
|
||||
management and grew outwards: key assignment, scripted workflow execution,
|
||||
service monitoring, a secrets vault, a browser-based console and OS update
|
||||
management.
|
||||
|
||||
## The pieces
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
W["Web UI<br/>servers · keys · workflows · monitors<br/>secrets · audit · console · settings"]
|
||||
S["Server<br/>REST :8080 · gRPC :9090<br/>MongoDB · Redis · guacd"]
|
||||
A["Agent<br/>one per managed server<br/>Linux and Windows"]
|
||||
W -->|REST, cookie session| S
|
||||
S -->|gRPC over TLS| A
|
||||
A -.->|outbound only| S
|
||||
```
|
||||
|
||||
**The server** holds all state and does all decision-making. It exposes a REST
|
||||
API on `:8080` for the web UI and a gRPC API on `:9090` for agents. MongoDB
|
||||
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.
|
||||
|
||||
**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
|
||||
convenient.
|
||||
|
||||
## How agents connect
|
||||
|
||||
The agent dials **out** to the control plane. There is no inbound listener on a
|
||||
managed server, no port to open and no NAT traversal to arrange. If the machine
|
||||
can reach your Vantage host on the gRPC port, it can be managed.
|
||||
|
||||
That direction is why `GRPC_HOST` exists as an explicit setting: the agent has
|
||||
to be told a `host:port` it can reach, and there is no safe default the server
|
||||
could guess on its behalf.
|
||||
|
||||
## Two request patterns
|
||||
|
||||
| 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.
|
||||
|
||||
## Next
|
||||
|
||||
- [Cloud or self-hosted](./cloud-vs-self-hosted.md) which one you want
|
||||
- [Self-hosted install](./self-hosted-install.md) stand it up
|
||||
@@ -0,0 +1,78 @@
|
||||
---
|
||||
id: accounts-and-signup
|
||||
title: Accounts and signup
|
||||
sidebar_label: Accounts and signup
|
||||
---
|
||||
|
||||
Vantage HQ, at `vantage-hq.hostxtra.co.uk`, is where you manage the **account**
|
||||
behind your instances: your team, your instances, their licences and billing.
|
||||
|
||||
## An account is a team, not a person
|
||||
|
||||
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 |
|
||||
|
||||
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
|
||||
instance governs that instance.
|
||||
|
||||
## Signing up
|
||||
|
||||
Signup is **account-first**. Creating an account creates the account and you;
|
||||
it does not create a Vantage instance. Nothing exists in any control plane until
|
||||
you later create or link one.
|
||||
|
||||
1. Go to the signup form.
|
||||
2. Enter your name, email and a password.
|
||||
3. Check your email and click the verification link.
|
||||
|
||||
:::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
|
||||
there is nothing to protect by being vague.
|
||||
:::
|
||||
|
||||
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.
|
||||
|
||||
## Signing in
|
||||
|
||||
Email and password. The session is a cookie, separate from the control plane's:
|
||||
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) |
|
||||
| To understand tiers and limits | [Licensing and entitlements](./licensing-and-entitlements.md) |
|
||||
|
||||
## The portal layout
|
||||
|
||||
Three destinations: **Overview**, **People**, **Billing**.
|
||||
|
||||
Settings lives in the account menu rather than the nav, because it is your
|
||||
password rather than a place. The appearance toggle is there too.
|
||||
|
||||
Overview lists your instances. Each is one record, closed to a row and open to
|
||||
its licence contents, members and actions. It opens by default when it is your
|
||||
only instance or when it needs attention, and your manual choice is remembered.
|
||||
|
||||
There is deliberately no "your plan" card in the sidebar: tier, limits and
|
||||
expiry belong to a **licence**, and a licence belongs to one instance. An
|
||||
account with a Free cloud instance and a Professional self-hosted one has no
|
||||
single plan to show.
|
||||
@@ -0,0 +1,83 @@
|
||||
---
|
||||
id: billing
|
||||
title: Billing
|
||||
sidebar_label: Billing
|
||||
---
|
||||
|
||||
Paid plans are billed through **Paddle**, which is the merchant of record. Your
|
||||
invoice, your card details and your tax handling are all Paddle's; HQ holds a
|
||||
customer reference and nothing sensitive.
|
||||
|
||||
Billing is **owner-only**.
|
||||
|
||||
## Buying
|
||||
|
||||
### Cloud
|
||||
|
||||
Open the instance, change its configuration to what you want, and check out.
|
||||
Checkout runs in the browser.
|
||||
|
||||
### Self-hosted
|
||||
|
||||
**Buy self-hosted**, then bind the purchase to your install's UUID. See
|
||||
[Self-hosted instances](./self-hosted-instances.md).
|
||||
|
||||
## What you are buying
|
||||
|
||||
A subscription's line items are the configuration: the plan base, the metered
|
||||
server count above the base, and any per-instance features. Changing the
|
||||
configuration changes the line items.
|
||||
|
||||
## Changing configuration
|
||||
|
||||
**Instance → Configuration**, adjust servers or features, and save.
|
||||
|
||||
- **Increases** take effect when the payment confirms.
|
||||
- **Reductions** are scheduled for the end of the term. The portal shows the
|
||||
date and the new value.
|
||||
|
||||
## The customer portal
|
||||
|
||||
**Billing → Manage** mints a Paddle customer-portal session where you can
|
||||
update your payment method, see invoices and cancel.
|
||||
|
||||
## How a licence follows a payment
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
C["Checkout / change"] --> P["Paddle"]
|
||||
P -->|signed webhook| H["HQ"]
|
||||
H --> G["Entitlement: desired → granted"]
|
||||
G --> L["Licence signed from granted"]
|
||||
```
|
||||
|
||||
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
|
||||
answer rather than replaying a stale state.
|
||||
|
||||
A licence is signed from **granted** only. A checkout you abandon changes
|
||||
nothing.
|
||||
|
||||
## Cancelling and failed payments
|
||||
|
||||
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
|
||||
[Free tier](./free-tier.md). Paid instances are not reaped.
|
||||
|
||||
## Renewals
|
||||
|
||||
At renewal the subscription bills again and the licence is reissued for the new
|
||||
term. It is also the only moment a scheduled **reduction** takes effect.
|
||||
|
||||
Self-hosted customers: download and paste the reissued licence. Cloud customers:
|
||||
nothing to do.
|
||||
|
||||
## Free is not in Paddle at all
|
||||
|
||||
Free has no subscription, no £0 line item and no Paddle record. It has its own
|
||||
renewal, in the portal. An account only acquires a Paddle customer reference
|
||||
with its first paid purchase.
|
||||
@@ -0,0 +1,78 @@
|
||||
---
|
||||
id: cloud-instances
|
||||
title: Cloud instances
|
||||
sidebar_label: Cloud instances
|
||||
---
|
||||
|
||||
A cloud instance is a Vantage control plane we run for you, reachable at
|
||||
`<your-slug>.vantage.hostxtra.co.uk`.
|
||||
|
||||
## Creating one
|
||||
|
||||
1. **Overview → New instance**.
|
||||
2. Choose a name and a slug.
|
||||
3. Create.
|
||||
|
||||
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).
|
||||
|
||||
### Slugs
|
||||
|
||||
The slug becomes your hostname label, so it is lowercase, and some names are
|
||||
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.
|
||||
:::
|
||||
|
||||
## Using it
|
||||
|
||||
Sign in at your instance's hostname with the email and password you use for HQ.
|
||||
It is a normal Vantage install from that point: see
|
||||
[Getting started](../getting-started/first-login.md) and the
|
||||
[Vantage](../vantage/servers.md) section.
|
||||
|
||||
The instance does not depend on HQ at runtime. HQ being unreachable does not
|
||||
affect anyone signing in or any agent syncing.
|
||||
|
||||
## The instance record
|
||||
|
||||
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.
|
||||
|
||||
## Members
|
||||
|
||||
Granting access writes a real user into the instance. Covered fully in
|
||||
[People and roles](./people-and-roles.md).
|
||||
|
||||
## 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
|
||||
[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.
|
||||
|
||||
## What happens if the licence lapses
|
||||
|
||||
A cloud instance whose Free licence expires enters degraded mode, then, after a
|
||||
grace period, the instance and all its data are deleted. Warning emails go out
|
||||
first. See [Free tier](./free-tier.md).
|
||||
|
||||
Paid instances do not get reaped by that mechanism. A cancelled subscription
|
||||
runs to its grace-padded expiry and then lapses.
|
||||
|
||||
## Deleting
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,78 @@
|
||||
---
|
||||
id: free-tier
|
||||
title: Free tier
|
||||
sidebar_label: Free tier
|
||||
---
|
||||
|
||||
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 |
|
||||
|
||||
Browser console and single sign-on are not included; they are per-instance
|
||||
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.
|
||||
|
||||
## Free is outside Paddle
|
||||
|
||||
There is no subscription, no £0 line item and no invoice. Your account acquires
|
||||
a Paddle customer reference only with its first paid purchase.
|
||||
|
||||
## Renewing
|
||||
|
||||
Free licences have a term and must be renewed from the portal.
|
||||
|
||||
- The renew button appears **7 days before expiry**.
|
||||
- It stays available **after** expiry, right up until the instance is reaped —
|
||||
so the same button rescues a lapsed instance rather than needing a second
|
||||
mechanism.
|
||||
- Renewing outside that window is refused, and the message names the date it
|
||||
opens.
|
||||
|
||||
:::tip Put the expiry in a calendar
|
||||
Warning emails go to the account address. If nobody watches that inbox, a Free
|
||||
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.
|
||||
|
||||
**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
|
||||
the date.
|
||||
|
||||
:::danger Deletion is permanent
|
||||
There is no restore. If a cloud Free instance is approaching that date and you
|
||||
want to keep it, renew it, or move it to a paid plan.
|
||||
:::
|
||||
|
||||
Deletion is carried out by the control plane rather than by HQ. HQ 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 the
|
||||
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
|
||||
anything.
|
||||
|
||||
## Relinks
|
||||
|
||||
Free instances get the same relink allowance as paid ones: three per term. That
|
||||
cap exists to put a human in front of a fourth attempt, not to obstruct a
|
||||
genuine rebuild.
|
||||
@@ -0,0 +1,95 @@
|
||||
---
|
||||
id: licensing-and-entitlements
|
||||
title: Licensing and entitlements
|
||||
sidebar_label: Licensing and entitlements
|
||||
---
|
||||
|
||||
A **licence** is a signed statement of what one instance may do. An
|
||||
**entitlement** is the configuration a licence is cut from.
|
||||
|
||||
## 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.
|
||||
|
||||
| | 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
|
||||
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) |
|
||||
|
||||
No tier includes them by default; you enable them on the instances that need
|
||||
them.
|
||||
|
||||
## Entitlements: desired and granted
|
||||
|
||||
Each instance has one entitlement row holding two configurations:
|
||||
|
||||
| | 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
|
||||
**granted**. An abandoned checkout therefore leaves a desired that reached
|
||||
nothing and changed nothing.
|
||||
|
||||
### Increases and reductions
|
||||
|
||||
An increase takes effect when payment confirms, and the entitlement is promoted
|
||||
desired → granted.
|
||||
|
||||
A **reduction** is scheduled rather than immediate: you keep what you paid for
|
||||
until the end of the term, and the portal shows the date it drops. The collapse
|
||||
happens at renewal.
|
||||
|
||||
## What a licence carries
|
||||
|
||||
Instance UUID, deployment, tier, resolved limits, features, term and expiry —
|
||||
all signed.
|
||||
|
||||
Two properties follow from that:
|
||||
|
||||
- **A licence is bound to one instance UUID.** Moving it takes a
|
||||
[relink](./self-hosted-instances.md#relinking).
|
||||
- **A licence is a snapshot.** Editing a plan later never rewrites an issued
|
||||
licence, the same way editing a workflow step never rewrites a past run.
|
||||
|
||||
Verification is local. Your instance does not call HQ to check a licence, and
|
||||
signing happens only in HQ.
|
||||
|
||||
## Expiry and grace
|
||||
|
||||
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)
|
||||
or let it be written for you (cloud).
|
||||
|
||||
## Server limits in practice
|
||||
|
||||
When you exceed your server allowance, enrolling another one is refused. The
|
||||
existing fleet is unaffected. Raise the allowance in the portal, or remove a
|
||||
server you are not using.
|
||||
|
||||
## Legacy tiers
|
||||
|
||||
An older `self_hosted` tier is mapped forward to self-hosted Professional
|
||||
wherever it appears. Nothing needs doing about it.
|
||||
@@ -0,0 +1,98 @@
|
||||
---
|
||||
id: people-and-roles
|
||||
title: People and roles
|
||||
sidebar_label: People and roles
|
||||
---
|
||||
|
||||
Two separate things live here: who is in your **account**, and who has access to
|
||||
each **instance**.
|
||||
|
||||
## Account members
|
||||
|
||||
**People** lists everyone in the account.
|
||||
|
||||
| Role | Can |
|
||||
| -------- | ----------------------------------------------- |
|
||||
| `owner` | Everything, including billing |
|
||||
| `admin` | Invite, create instances, grant instance access |
|
||||
| `member` | Read |
|
||||
|
||||
Owners and admins invite; billing is owner-only.
|
||||
|
||||
### Inviting someone
|
||||
|
||||
1. **People → Invite**.
|
||||
2. Enter their email and pick a role.
|
||||
3. They receive a link and set their own password at `/accept-invite`.
|
||||
|
||||
:::info Why you cannot set their password
|
||||
An invitation creates a person with an **empty password hash**, which cannot
|
||||
authenticate at all until they set one. If the inviter chose it, that password
|
||||
would be a shared credential to every instance the person is later granted
|
||||
access to.
|
||||
|
||||
The verification endpoint knows the difference: a token belonging to a
|
||||
passwordless person reports that a password is needed and is left unspent, so
|
||||
the link still works when they get to it.
|
||||
:::
|
||||
|
||||
### Removing someone
|
||||
|
||||
Removing them from the account removes their portal access. See below for what
|
||||
happens to their instance access.
|
||||
|
||||
## Instance access
|
||||
|
||||
Granting access to a **cloud** instance creates a real user inside that
|
||||
instance's control plane, with `auth_source: "hq"`.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
P["HQ account member"] -->|grant| U["Control-plane user<br/>auth_source: hq"]
|
||||
U --> I["The instance authenticates<br/>this user like any other"]
|
||||
```
|
||||
|
||||
The instance authenticates that user exactly as it authenticates anyone else,
|
||||
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
|
||||
|
||||
On an instance record, **Members → Add**, choose an account member and an
|
||||
instance role (`owner`, `admin`, `member`).
|
||||
|
||||
One person holds at most one user per instance, so granting twice is refused
|
||||
rather than quietly creating a second user.
|
||||
|
||||
### Roles inside an instance
|
||||
|
||||
Independent of the account role. Someone can be an account `member` and an
|
||||
instance `owner`, or the reverse.
|
||||
|
||||
### Revoking
|
||||
|
||||
Removes the user from the instance immediately. Any live session ends with the
|
||||
session, since the user row backing it is gone.
|
||||
|
||||
:::warning Self-hosted instances cannot be granted from HQ
|
||||
All three member endpoints refuse when the instance is self-hosted. Manage those
|
||||
users in the instance itself, at **Settings → Access**.
|
||||
:::
|
||||
|
||||
## Passwords
|
||||
|
||||
Your HQ password is the single source of truth for every user projected from it.
|
||||
Changing it in the portal rehashes it and copies the hash to every instance you
|
||||
have been granted.
|
||||
|
||||
Propagation is best-effort and immediate; a background pass compares and repairs
|
||||
every 15 minutes, so a temporarily unreachable instance catches up on its own.
|
||||
|
||||
There is no local password-change endpoint for those users in the control plane,
|
||||
so there is never a second writer for the hash.
|
||||
|
||||
:::warning HQ-managed users are read-only in the instance
|
||||
Changing the role of, or deleting, an `hq`-sourced user inside the control plane
|
||||
is refused with `409`. Do it from the portal. The UI shows those rows read-only
|
||||
with a link back here, but the API is the boundary; the UI is the courtesy.
|
||||
:::
|
||||
@@ -0,0 +1,74 @@
|
||||
---
|
||||
id: self-hosted-instances
|
||||
title: Self-hosted instances
|
||||
sidebar_label: Self-hosted instances
|
||||
---
|
||||
|
||||
A self-hosted instance is your install, licensed through HQ. HQ never touches
|
||||
it: it issues a signed file that your install verifies locally.
|
||||
|
||||
## Free
|
||||
|
||||
Install first, then link and claim. Step by step in
|
||||
[Claim a Free licence](../getting-started/claim-free-licence.md).
|
||||
|
||||
## Paid
|
||||
|
||||
Buying happens **before** the install has to exist, because you may well be
|
||||
buying in order to build it.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["Buy in HQ"] --> B["Placeholder instance<br/>awaiting_link, no licence"]
|
||||
B --> C["Install Vantage<br/>get its instance UUID"]
|
||||
C --> D["Paste the UUID<br/>claim-link"]
|
||||
D --> E["Licence issued<br/>bound to that UUID"]
|
||||
```
|
||||
|
||||
1. **Overview → Buy self-hosted**, choose tier, term and configuration.
|
||||
2. Complete checkout. HQ creates a **placeholder** instance in state
|
||||
`awaiting_link` with no licence attached.
|
||||
3. [Install Vantage](../getting-started/self-hosted-install.md) if you have not
|
||||
already, and find its instance UUID at **Settings → Licence**.
|
||||
4. Back in HQ, open the placeholder and paste the UUID.
|
||||
5. The licence is issued, bound to that UUID. Download it and paste it into your
|
||||
install.
|
||||
|
||||
:::info Why there is a placeholder at all
|
||||
A licence binds to an instance UUID, and at the moment of payment that UUID may
|
||||
not exist yet. Issuing early would mean issuing to nothing; refusing to sell
|
||||
until you had installed would be the wrong order. The placeholder holds the
|
||||
purchase until there is something to bind to.
|
||||
:::
|
||||
|
||||
## Linking an existing install
|
||||
|
||||
If the install already exists, **Link an instance** takes the UUID directly. A
|
||||
UUID already claimed by another account is refused with a conflict.
|
||||
|
||||
## Relinking
|
||||
|
||||
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
|
||||
them for a real reason, ask support.
|
||||
|
||||
## Installing the licence
|
||||
|
||||
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
|
||||
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.
|
||||
|
||||
:::warning Nothing reminds your install
|
||||
The control plane knows only what its licence says. Expiry emails come from HQ,
|
||||
to the account's address. Make sure someone reads them.
|
||||
:::
|
||||
@@ -0,0 +1,37 @@
|
||||
---
|
||||
id: index
|
||||
title: Vantage documentation
|
||||
sidebar_label: Overview
|
||||
slug: /
|
||||
---
|
||||
|
||||
# Vantage documentation
|
||||
|
||||
Vantage is a self-hosted, multi-tenant infrastructure control plane. It manages
|
||||
SSH keys, runs scripted workflows, watches services, stores secrets, opens
|
||||
browser consoles and applies OS updates across a fleet of servers.
|
||||
|
||||
A central server drives a lightweight agent installed on each managed machine.
|
||||
The agent connects **outbound only**, so managed servers need no inbound
|
||||
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) |
|
||||
|
||||
## The two products
|
||||
|
||||
**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.
|
||||
|
||||
**Vantage HQ** is the portal at `vantage-hq.hostxtra.co.uk` where you manage the
|
||||
account behind those instances: who is on your team, which instances exist, what
|
||||
licence each one holds and how it is billed. HQ never manages your servers, and
|
||||
a Vantage instance never depends on HQ being reachable in order to run.
|
||||
@@ -0,0 +1,77 @@
|
||||
---
|
||||
id: agent-updates
|
||||
title: Agent updates
|
||||
sidebar_label: Agent updates
|
||||
---
|
||||
|
||||
Agents are versioned and released independently of the control plane, and update
|
||||
themselves on command.
|
||||
|
||||
## Checking the current version
|
||||
|
||||
Each server's detail page shows the version it reported at its last sync.
|
||||
`GET /api/agent/latest-version` reports the newest release available.
|
||||
|
||||
## Updating from the UI
|
||||
|
||||
**Servers → _a server_ → Update agent** pushes `UpdateAgentCmd` with a target
|
||||
version. The agent then:
|
||||
|
||||
1. Downloads the binary for its platform from the release.
|
||||
2. Verifies the SHA-256 against `checksums.txt`.
|
||||
3. Stops itself, replaces the binary in place, and starts again.
|
||||
|
||||
`Restart=always` on the systemd unit is what makes the last step work.
|
||||
|
||||
The server briefly goes `offline` and comes back within a poll interval or two.
|
||||
|
||||
## Updating from the machine
|
||||
|
||||
There is a dynamic update script, the counterpart to the install one:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://vantage.example.com/update | bash
|
||||
```
|
||||
|
||||
```powershell
|
||||
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
|
||||
reachable but its command stream is not.
|
||||
|
||||
## Rolling out across a fleet
|
||||
|
||||
There is no built-in bulk update. Two reasonable approaches:
|
||||
|
||||
- Update from each server's page, a few at a time.
|
||||
- Build a [workflow](../vantage/workflows.md) whose step runs the update script,
|
||||
and target it at the machines you want. That gives you ordering, failure
|
||||
handling and a log.
|
||||
|
||||
:::tip Update a canary first
|
||||
An agent that fails to start after replacing itself needs hands on that machine.
|
||||
Do one, confirm it returns to `active`, then do the rest.
|
||||
:::
|
||||
|
||||
## Version compatibility
|
||||
|
||||
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 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` |
|
||||
| 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
|
||||
back with the same identity and token.
|
||||
@@ -0,0 +1,89 @@
|
||||
---
|
||||
id: backups
|
||||
title: Backups
|
||||
sidebar_label: Backups
|
||||
---
|
||||
|
||||
Two things matter: **MongoDB** and **`KEY_ENCRYPTION_KEY`**. A backup missing
|
||||
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** |
|
||||
|
||||
:::danger The database alone is not a backup
|
||||
Private keys, vault secrets, OIDC client secrets and console credentials are
|
||||
encrypted with `KEY_ENCRYPTION_KEY`, which lives in your environment file and
|
||||
nowhere in the database. Restore the database without it and every one of those
|
||||
values is permanently unreadable.
|
||||
|
||||
Store the key somewhere other than the server it protects.
|
||||
:::
|
||||
|
||||
## Backing up MongoDB
|
||||
|
||||
With the bundled Mongo container:
|
||||
|
||||
```bash
|
||||
docker compose exec -T mongo mongodump --archive --gzip --db vantage \
|
||||
> /backups/vantage-$(date +%F).archive.gz
|
||||
```
|
||||
|
||||
Restoring:
|
||||
|
||||
```bash
|
||||
docker compose exec -T mongo mongorestore --archive --gzip --drop \
|
||||
< /backups/vantage-2026-07-28.archive.gz
|
||||
```
|
||||
|
||||
`--drop` replaces existing collections. Stop the `server` container first, so
|
||||
nothing writes during the restore.
|
||||
|
||||
## Backing up the environment file
|
||||
|
||||
```bash
|
||||
cp /opt/vantage/.env /secure-location/vantage.env
|
||||
```
|
||||
|
||||
Treat it as a credential in its own right it holds the encryption key.
|
||||
|
||||
## Run logs
|
||||
|
||||
Workflow run logs live in the `./data` bind mount, not in the database. They are
|
||||
swept on the retention schedule anyway, so most people do not back them up. If
|
||||
you keep them for compliance, set retention to `0` (forever) and include the
|
||||
directory.
|
||||
|
||||
## What a restore gives you
|
||||
|
||||
Everything: fleet, keys, assignments, workflows and their history, monitors and
|
||||
incidents, secrets, settings and the audit log.
|
||||
|
||||
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.
|
||||
- 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 |
|
||||
|
||||
The rehearsal is the part that gets skipped and the part that finds the
|
||||
problems.
|
||||
|
||||
## Cloud instances
|
||||
|
||||
We back these up. You do not need to.
|
||||
@@ -0,0 +1,71 @@
|
||||
---
|
||||
id: upgrading
|
||||
title: Upgrading
|
||||
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).
|
||||
|
||||
:::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
|
||||
|
||||
```bash
|
||||
cd /opt/vantage
|
||||
docker compose pull
|
||||
docker compose up -d --remove-orphans
|
||||
```
|
||||
|
||||
`--remove-orphans` clears containers for services that no longer exist in the
|
||||
Compose file, which is what leaves a stale container running after a service is
|
||||
renamed or removed.
|
||||
|
||||
## What happens on boot
|
||||
|
||||
1. **Migrations** run, recording markers so each runs once.
|
||||
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.
|
||||
|
||||
Watch it:
|
||||
|
||||
```bash
|
||||
docker compose logs -f server
|
||||
```
|
||||
|
||||
## Before you upgrade
|
||||
|
||||
- **Back up MongoDB.** See [Backups](./backups.md). Migrations are one-way.
|
||||
- **Read the release notes** for anything about migrations or environment
|
||||
variables.
|
||||
- **Check your `.env`** still supplies everything required. A newly required
|
||||
variable stops the boot rather than defaulting to something unsafe.
|
||||
|
||||
## Downgrading
|
||||
|
||||
There is no automatic downgrade. Migrations do not roll back, so returning to an
|
||||
older image means restoring the database backup taken before the upgrade. This
|
||||
is the reason the backup is not optional.
|
||||
|
||||
## Zero-downtime
|
||||
|
||||
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.
|
||||
- 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.
|
||||
|
||||
## 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
|
||||
`KEY_ENCRYPTION_KEY` came through.
|
||||
@@ -0,0 +1,111 @@
|
||||
---
|
||||
id: agent-config
|
||||
title: Agent configuration
|
||||
sidebar_label: Agent config
|
||||
---
|
||||
|
||||
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` |
|
||||
|
||||
Directory `0700`, file `0600`. The install script sets both.
|
||||
|
||||
## Contents
|
||||
|
||||
```yaml
|
||||
server_url: "vantage.yourdomain.com:9090"
|
||||
server_id: "<uuid>"
|
||||
pre_reg_token: "<token>" # removed after the first successful Register()
|
||||
agent_token: "" # written by the agent after Register()
|
||||
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 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
|
||||
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`.
|
||||
:::
|
||||
|
||||
## Startup sequence
|
||||
|
||||
```
|
||||
1. Load the config
|
||||
2. pre_reg_token present → register → save agent_token,
|
||||
clear pre_reg_token, reconnect
|
||||
3. Start: command stream · hourly update check · inventory · monitors
|
||||
4. Enter the key poll loop
|
||||
```
|
||||
|
||||
## The poll loop
|
||||
|
||||
```
|
||||
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, rename it over the real one, chmod 0600
|
||||
```
|
||||
|
||||
## Service management
|
||||
|
||||
### Linux
|
||||
|
||||
Unit at `/etc/systemd/system/vantage-agent.service`, `Restart=always`, running
|
||||
as root.
|
||||
|
||||
```bash
|
||||
systemctl status vantage-agent
|
||||
systemctl restart vantage-agent
|
||||
journalctl -u vantage-agent -f
|
||||
```
|
||||
|
||||
### Windows
|
||||
|
||||
A service registered through NSSM, or installed by the MSI that CI builds.
|
||||
|
||||
```powershell
|
||||
Get-Service vantage-agent
|
||||
Restart-Service vantage-agent
|
||||
```
|
||||
|
||||
## Command-line flags
|
||||
|
||||
```
|
||||
vantage-agent -generate-key
|
||||
```
|
||||
|
||||
Generates a keypair locally. Normal operation takes no flags.
|
||||
|
||||
## Moving an agent to a new control plane
|
||||
|
||||
Change `server_url`, clear `agent_token`, set a fresh `pre_reg_token` from a new
|
||||
enrolment, and restart. The old control plane still holds a server record that
|
||||
will go `offline`; delete it there.
|
||||
|
||||
## Uninstalling
|
||||
|
||||
```bash
|
||||
systemctl disable --now vantage-agent
|
||||
rm -f /usr/local/bin/vantage-agent /etc/systemd/system/vantage-agent.service
|
||||
rm -rf /etc/vantage
|
||||
systemctl daemon-reload
|
||||
```
|
||||
|
||||
Keys already written to `authorized_keys` remain on disk the agent is no
|
||||
longer running to remove them. Revoke first if that matters.
|
||||
@@ -0,0 +1,45 @@
|
||||
---
|
||||
id: environment-variables
|
||||
title: Environment variables
|
||||
sidebar_label: Environment variables
|
||||
---
|
||||
|
||||
Everything the control plane reads from the environment, and what happens when
|
||||
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 |
|
||||
| `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 |
|
||||
| `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 |
|
||||
|
||||
:::danger `KEY_ENCRYPTION_KEY` has no recovery path
|
||||
It encrypts SSH private keys, vault secrets, OIDC client secrets and console
|
||||
credentials. Lose it and all of them are unreadable. Back it up separately from
|
||||
the database it protects.
|
||||
:::
|
||||
|
||||
:::info A wrong `APP_ROOT_LABEL` fails quietly
|
||||
It does not error. It simply stops matching, and the host/session guard stops
|
||||
protecting anything.
|
||||
:::
|
||||
|
||||
### Not configurable
|
||||
|
||||
The HTTP port (`8080`) and the gRPC port (`9090`) are fixed in the server. The
|
||||
`HTTP_PORT` and `GRPC_PORT` entries in the shipped Compose file are inert —
|
||||
remap with Docker's port publishing instead.
|
||||
|
||||
## Agent
|
||||
|
||||
The agent reads no environment variables. Everything is in its
|
||||
[config file](./agent-config.md).
|
||||
@@ -0,0 +1,92 @@
|
||||
---
|
||||
id: ports-and-networking
|
||||
title: Ports and networking
|
||||
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 |
|
||||
|
||||
## Direction of travel
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
B["Browser"] -->|HTTPS| P["Reverse proxy"]
|
||||
P --> W["web :3000"]
|
||||
W --> S["server :8080"]
|
||||
A["Agent on a managed server"] -->|"gRPC/TLS :9090, outbound"| S
|
||||
S --> G["guacd :4822"]
|
||||
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.
|
||||
|
||||
**Agents connect outbound.** No inbound rule is needed on a managed server, and
|
||||
NAT is not an obstacle. The only requirement is that the machine can reach
|
||||
`GRPC_HOST`.
|
||||
|
||||
**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
|
||||
|
||||
### On your firewall, inbound to the control plane
|
||||
|
||||
- Your web port, from wherever people are.
|
||||
- `9090`, from every network holding managed machines.
|
||||
|
||||
### Outbound from the control plane
|
||||
|
||||
- `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.
|
||||
|
||||
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
|
||||
|
||||
- `GRPC_HOST`.
|
||||
- `gitea.hostxtra.co.uk`, for install and self-update.
|
||||
- Its package mirrors, for OS updates.
|
||||
|
||||
## TLS
|
||||
|
||||
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
|
||||
register and then fail to hold the command stream.
|
||||
|
||||
## Reverse proxy notes
|
||||
|
||||
- Point the proxy at `web:3000`. The web app reaches the REST API internally, so
|
||||
`8080` does not need publishing.
|
||||
- The console uses a **WebSocket** at `/api/console/tunnel`. A proxy that does
|
||||
not forward upgrade headers breaks the console and nothing else.
|
||||
- Workflow log streaming is a long-lived response. A short proxy read timeout
|
||||
truncates live logs while the run itself continues.
|
||||
|
||||
## Air-gapped and restricted networks
|
||||
|
||||
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.
|
||||
|
||||
Licence verification is entirely local, so a licensed install works with no
|
||||
outbound access to HQ at all.
|
||||
@@ -0,0 +1,149 @@
|
||||
---
|
||||
id: rest-api
|
||||
title: REST API
|
||||
sidebar_label: REST API
|
||||
---
|
||||
|
||||
The control plane's HTTP API, on port `8080`. The web UI is a client of it and
|
||||
has no privileges it does not.
|
||||
|
||||
## Authentication
|
||||
|
||||
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
|
||||
path.
|
||||
|
||||
## Unauthenticated
|
||||
|
||||
```
|
||||
GET /install /install.ps1 # dynamic agent install scripts
|
||||
GET /update /update.ps1
|
||||
GET /auth/bootstrap-status
|
||||
POST /auth/bootstrap /auth/login /auth/logout
|
||||
GET /auth/me /auth/oidc/start /auth/oidc/callback
|
||||
GET /api/secrets/:group/values # bearer token (ESO)
|
||||
```
|
||||
|
||||
`/install` and `/install.ps1` take `server_id` and `token` as query parameters
|
||||
and return a shell script with the newest agent version substituted in.
|
||||
|
||||
`POST /auth/bootstrap` works only while the database has no users.
|
||||
|
||||
## Session-authenticated, under `/api`
|
||||
|
||||
### Servers
|
||||
|
||||
```
|
||||
GET,POST /servers
|
||||
GET,POST /servers/new
|
||||
GET,DELETE /servers/:id
|
||||
POST /servers/:id/generate-key
|
||||
POST /servers/:id/update-agent
|
||||
POST /servers/:id/apply-updates
|
||||
```
|
||||
|
||||
### Keys
|
||||
|
||||
```
|
||||
GET,POST /keys
|
||||
GET,DELETE /keys/:id
|
||||
GET /keys/:id/private-key
|
||||
POST /keys/:id/assign
|
||||
DELETE /keys/:id/assign/:serverId
|
||||
```
|
||||
|
||||
### Workflows and steps
|
||||
|
||||
```
|
||||
GET,POST /steps
|
||||
PUT,DELETE /steps/:id
|
||||
GET /steps/:id/export
|
||||
POST /steps/import · /steps/seed-defaults · /steps/parse
|
||||
GET /steps/usage
|
||||
GET,POST /workflows
|
||||
GET,PUT,DELETE /workflows/:id
|
||||
POST /workflows/:id/run
|
||||
GET /workflows/:id/runs
|
||||
GET /runs/:runId
|
||||
POST /runs/:runId/cancel
|
||||
GET /runs/:runId/servers/:serverId/logs
|
||||
GET /runs/:runId/servers/:serverId/logs/stream
|
||||
```
|
||||
|
||||
`PUT` and `DELETE` on a step whose source is `default` answer `409`. See
|
||||
[Workflows](../vantage/workflows.md#default-steps).
|
||||
|
||||
### Monitors and channels
|
||||
|
||||
```
|
||||
GET,POST /monitors
|
||||
GET,PUT,DELETE /monitors/:id
|
||||
GET /monitors/:id/incidents · /monitors/:id/uptime
|
||||
GET,POST /channels
|
||||
PUT,DELETE /channels/:id
|
||||
POST /channels/:id/test
|
||||
```
|
||||
|
||||
### Secrets
|
||||
|
||||
```
|
||||
GET,POST /secrets
|
||||
GET,PUT,DELETE /secrets/:group
|
||||
POST /secrets/:group/reveal
|
||||
DELETE /secrets/:group/:key
|
||||
```
|
||||
|
||||
### Console
|
||||
|
||||
```
|
||||
POST /console/connect
|
||||
GET /console/tunnel # websocket
|
||||
```
|
||||
|
||||
### Other
|
||||
|
||||
```
|
||||
GET /audit
|
||||
GET /agent/latest-version
|
||||
GET,PUT /settings (owner|admin)
|
||||
POST /settings/secrets-token (owner|admin)
|
||||
GET /license
|
||||
POST /license (self-hosted only)
|
||||
GET,POST /org/users
|
||||
PUT /org/users/:id/role
|
||||
DELETE /org/users/:id
|
||||
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` |
|
||||
|
||||
`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.
|
||||
|
||||
## Multi-tenancy
|
||||
|
||||
Every request is scoped to the instance resolved from the session. On a
|
||||
multi-tenant deployment, a request arriving at `<slug>.vantage.<tld>` also has
|
||||
its host checked against the session's instance, and a mismatch is rejected.
|
||||
|
||||
## Errors
|
||||
|
||||
Errors are JSON with an `error` field. Customer-facing endpoints in the HQ API
|
||||
answer `404` rather than `403` for another account's resource, because a `403`
|
||||
confirms the resource exists; the control plane's own API is single-tenant per
|
||||
session and does not need that distinction.
|
||||
|
||||
## Admin API
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,133 @@
|
||||
---
|
||||
id: troubleshooting
|
||||
title: Troubleshooting
|
||||
sidebar_label: Troubleshooting
|
||||
---
|
||||
|
||||
Symptoms, in the order people hit them.
|
||||
|
||||
## The server will not start
|
||||
|
||||
**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
|
||||
on failure by design: those unique indexes are what enforce tenant isolation,
|
||||
so starting without them is worse than not starting. Check the MongoDB user's
|
||||
permissions and whether a conflicting index already exists.
|
||||
|
||||
**Starts, but every secret operation errors.** `KEY_ENCRYPTION_KEY` is missing
|
||||
or is not 64 hex characters.
|
||||
|
||||
## Nobody can sign in
|
||||
|
||||
**`/setup` appears when users already exist.** The server is pointed at a
|
||||
different database than you think. Check the database name in `MONGO_URI` —
|
||||
it comes from the URI path, not a separate variable.
|
||||
|
||||
**Sessions do not stick.** Redis is unreachable, or the cookie is being dropped
|
||||
because the site is served over plain HTTP.
|
||||
|
||||
**"Wrong organisation" style rejections.** The host and session guard is
|
||||
comparing the request host's label against the session's organisation. Check
|
||||
`APP_ROOT_LABEL`.
|
||||
|
||||
**OIDC redirects and then fails.** The callback URL registered with the provider
|
||||
must match exactly. Keep one local owner account so a broken provider is not a
|
||||
lockout.
|
||||
|
||||
## A server never becomes active
|
||||
|
||||
Work through it in this order:
|
||||
|
||||
1. Is the agent running? `systemctl status vantage-agent`.
|
||||
2. What does it say? `journalctl -u vantage-agent -f`.
|
||||
3. Can that machine reach the endpoint? Test `GRPC_HOST` **from the machine**,
|
||||
not from the control plane host.
|
||||
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 |
|
||||
| --------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
|
||||
| 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 |
|
||||
|
||||
Remember the offline sweep runs every two minutes, so status is never
|
||||
instantaneous.
|
||||
|
||||
## Keys are not appearing on a machine
|
||||
|
||||
- **It is a Windows server.** Key management is Linux-only, by design.
|
||||
- **The agent is not running.** Nothing polls, nothing writes.
|
||||
- **The key is assigned but revoked.** Revocation is soft; check the assignment
|
||||
state rather than the key.
|
||||
- **Someone edited `authorized_keys` by hand.** The agent rewrites the file to
|
||||
match the desired set; hand-added keys disappear on the next change.
|
||||
|
||||
## A workflow run fails or hangs
|
||||
|
||||
- **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.
|
||||
- **A value does not reach the next step.** Values pass through the file at
|
||||
`$WORKFLOW_ENV`, one `KEY=value` per line. Declaring an output does not export
|
||||
it.
|
||||
- **A secret is empty.** The group is not in the step's `secret_refs`, or the
|
||||
key name differs from the environment variable you are reading.
|
||||
- **Logs stop mid-run.** A reverse proxy read timeout cut the stream. The run
|
||||
itself continues; reload the page.
|
||||
|
||||
## 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, 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
|
||||
|
||||
- The check is running from the control plane and the endpoint is only reachable
|
||||
internally. Switch the runner to an agent on a machine that can see it.
|
||||
- The keyword no longer appears in the response body.
|
||||
- Retries are `0`, so a single dropped packet flips the state.
|
||||
|
||||
## Notifications are not arriving
|
||||
|
||||
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
|
||||
needs `host`, `port`, `from` and `to`, and Telegram needs both `token` and
|
||||
`chat_id`.
|
||||
|
||||
## 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 |
|
||||
|
||||
## HQ portal problems
|
||||
|
||||
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
|
||||
|
||||
```bash
|
||||
docker compose ps
|
||||
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
|
||||
support works from.
|
||||
@@ -0,0 +1,51 @@
|
||||
---
|
||||
id: audit-log
|
||||
title: Audit log
|
||||
sidebar_label: Audit log
|
||||
---
|
||||
|
||||
Every mutating API path writes an audit event. The log is at **Audit**.
|
||||
|
||||
## What an event carries
|
||||
|
||||
| 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 |
|
||||
|
||||
## What is recorded
|
||||
|
||||
Creation, modification and deletion across the product: servers and enrolments,
|
||||
keys and assignments, workflow and step changes, runs triggered, monitors and
|
||||
channels, secret groups and reveals, console sessions opened, settings and
|
||||
member changes, licence installs.
|
||||
|
||||
Reads are not recorded, with one deliberate exception: **revealing a secret**
|
||||
writes an event, because reading that particular thing is an act rather than a
|
||||
lookup.
|
||||
|
||||
## What is not recorded
|
||||
|
||||
- Sign-ins and sign-out.
|
||||
- Anything inside a console session.
|
||||
- Step output. That lives in the run log, kept under the workflow retention
|
||||
setting rather than with the audit log.
|
||||
|
||||
## Retention
|
||||
|
||||
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
|
||||
The audit log tells you what happened. It does not restrict what can happen, and
|
||||
an admin can do anything an admin can do. Use roles for restriction and the log
|
||||
for accountability.
|
||||
:::
|
||||
|
||||
## Getting events out
|
||||
|
||||
`GET /api/audit` returns recent events as JSON and accepts a `limit`. There is
|
||||
no streaming or push export; if you need events in a SIEM, poll that endpoint.
|
||||
@@ -0,0 +1,73 @@
|
||||
---
|
||||
id: browser-console
|
||||
title: Browser console
|
||||
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
|
||||
to a **guacd** daemon and manages credentials around it.
|
||||
|
||||
## Requirements
|
||||
|
||||
- `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.
|
||||
- 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. 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.
|
||||
|
||||
## Credentials
|
||||
|
||||
### SSH
|
||||
|
||||
Authenticates with a private key stored in the [key library](./ssh-keys.md). The
|
||||
key must have its private half uploaded; a public-only key cannot open a
|
||||
session.
|
||||
|
||||
### RDP and VNC
|
||||
|
||||
You supply credentials when connecting. They are encrypted, **single-use**, and
|
||||
consumed when the tunnel opens. They are not retained for the next session.
|
||||
|
||||
:::info Why single-use
|
||||
A stored console credential is a standing grant to that machine for anyone who
|
||||
can reach the endpoint. Consuming it at tunnel-open means a leaked session token
|
||||
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.
|
||||
|
||||
## 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
|
||||
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, 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 |
|
||||
@@ -0,0 +1,78 @@
|
||||
---
|
||||
id: monitors
|
||||
title: Monitors
|
||||
sidebar_label: Monitors
|
||||
---
|
||||
|
||||
Monitors check that something is answering. Four types, two places they can run
|
||||
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 |
|
||||
|
||||
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.
|
||||
|
||||
## Where a check runs
|
||||
|
||||
Every monitor has a **runner**:
|
||||
|
||||
| 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,
|
||||
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.
|
||||
|
||||
:::tip Agent-run monitors measure what your users can't
|
||||
A check run from the control plane tells you the service is reachable from
|
||||
there. A check run on the machine tells you the process is alive. Those are
|
||||
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
|
||||
flips.
|
||||
|
||||
A monitor sits in `pending` until its first result. Failures accumulate; once
|
||||
they exceed `retries`, the monitor goes `down`, an **incident** opens and the
|
||||
attached notification channels fire. A subsequent success closes the incident.
|
||||
|
||||
Retries are what keeps one dropped packet from paging you. Set them to at least
|
||||
`1` for anything crossing the public internet.
|
||||
|
||||
## Notifications
|
||||
|
||||
Attach one or more [notification channels](./notification-channels.md) to a
|
||||
monitor. Channels are shared, so one Slack destination can serve every monitor
|
||||
you have.
|
||||
|
||||
Notification state is tracked per monitor, so a service that is down for six
|
||||
hours does not send a message per interval.
|
||||
|
||||
## Uptime and incidents
|
||||
|
||||
The monitor detail page shows:
|
||||
|
||||
- **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
|
||||
moment it opened.
|
||||
|
||||
## 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
|
||||
you wanted.
|
||||
@@ -0,0 +1,101 @@
|
||||
---
|
||||
id: notification-channels
|
||||
title: Notification channels
|
||||
sidebar_label: Notification channels
|
||||
---
|
||||
|
||||
A channel is a destination for alerts. [Monitors](./monitors.md) reference
|
||||
channels by ID, so one destination serves as many monitors as you like.
|
||||
|
||||
Manage them at **Settings → Notifications**.
|
||||
|
||||
## Types
|
||||
|
||||
### Webhook
|
||||
|
||||
Posts JSON to a URL you choose.
|
||||
|
||||
| Setting | |
|
||||
| ------- | -------- |
|
||||
| `url` | Required |
|
||||
|
||||
```json
|
||||
{
|
||||
"monitor": "API front door",
|
||||
"type": "http",
|
||||
"old_status": "up",
|
||||
"new_status": "down",
|
||||
"message": "HTTP 502",
|
||||
"time": "2026-07-28T09:14:02Z"
|
||||
}
|
||||
```
|
||||
|
||||
Any response of 300 or above counts as a delivery failure. The request times out
|
||||
after 10 seconds.
|
||||
|
||||
### Discord
|
||||
|
||||
| Setting | |
|
||||
| ------- | ------------------- |
|
||||
| `url` | Discord webhook URL |
|
||||
|
||||
Posts the alert as message content.
|
||||
|
||||
### Slack
|
||||
|
||||
| Setting | |
|
||||
| ------- | -------------------------- |
|
||||
| `url` | Slack incoming webhook URL |
|
||||
|
||||
### Telegram
|
||||
|
||||
| Setting | |
|
||||
| --------- | ----------- |
|
||||
| `token` | Bot token |
|
||||
| `chat_id` | Target chat |
|
||||
|
||||
### SMTP
|
||||
|
||||
| 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.
|
||||
|
||||
Alert emails are rendered by the same email system that sends licence and
|
||||
account mail, so a monitor alert and an account email look like the same
|
||||
product.
|
||||
|
||||
## The message
|
||||
|
||||
Non-webhook channels all send the same one-line title:
|
||||
|
||||
```
|
||||
[Vantage] API front door (http) is DOWN: HTTP 502
|
||||
```
|
||||
|
||||
Recoveries read `recovered` in place of `is DOWN`. The webhook payload carries
|
||||
the same information as fields, which is the one to use if you are routing into
|
||||
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
|
||||
arrives proves the credentials, the network path and the destination, not just
|
||||
the configuration form.
|
||||
|
||||
:::tip Test after every change
|
||||
Channel settings are only exercised when something breaks, which is the worst
|
||||
time to discover a stale webhook URL. Re-test after rotating a token.
|
||||
:::
|
||||
|
||||
## Choosing destinations
|
||||
|
||||
- Use a **chat channel** for awareness, and make sure someone owns it.
|
||||
- Use **SMTP** where a durable record matters.
|
||||
- Use a **webhook** to reach an on-call system that does escalation properly.
|
||||
Vantage does not do escalation, rotas or acknowledgement; a webhook into
|
||||
something that does is the intended answer.
|
||||
@@ -0,0 +1,74 @@
|
||||
---
|
||||
id: secrets
|
||||
title: Secrets vault
|
||||
sidebar_label: Secrets
|
||||
---
|
||||
|
||||
Key/value pairs, grouped by name, encrypted at rest with AES-256-GCM under
|
||||
`KEY_ENCRYPTION_KEY`. Two things consume them: workflow steps, and Kubernetes
|
||||
External Secrets Operator.
|
||||
|
||||
## Groups and values
|
||||
|
||||
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
|
||||
and the unit ESO reads, so a group that matches one consumer is one reference;
|
||||
a group holding everything is over-sharing to every step that needs any of it.
|
||||
|
||||
## Managing them
|
||||
|
||||
**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
|
||||
looking at a secret is a recorded act.
|
||||
|
||||
Deleting a single key and deleting the whole group are separate operations.
|
||||
|
||||
## Using secrets in workflows
|
||||
|
||||
Add the group name to a step's `secret_refs`. At execution the group's pairs are
|
||||
injected into the step's environment:
|
||||
|
||||
```bash
|
||||
# secret_refs: ["registry"]
|
||||
echo "$REGISTRY_PASSWORD" | docker login registry.example.com -u "$REGISTRY_USER" --password-stdin
|
||||
```
|
||||
|
||||
A workflow can also override `secret_refs` per step, without changing the
|
||||
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
|
||||
in the UI. Vantage does not scrub step output.
|
||||
:::
|
||||
|
||||
## Kubernetes External Secrets Operator
|
||||
|
||||
`GET /api/secrets/:group/values` returns a group's pairs for ESO, authenticated
|
||||
with a **bearer token** rather than a session.
|
||||
|
||||
1. Generate the token at **Settings → Integrations**. It is shown once; only its
|
||||
SHA-256 is stored.
|
||||
2. Put it in a Kubernetes secret.
|
||||
3. Point an ESO `SecretStore` at the endpoint with that bearer token.
|
||||
|
||||
The token is rotatable: generating a new one replaces the stored hash and
|
||||
invalidates the old one immediately.
|
||||
|
||||
:::danger This token reads every group
|
||||
It is instance-wide, not scoped to one group. Treat it as a credential to the
|
||||
whole vault: store it as a secret in the cluster, never in a manifest in git,
|
||||
and rotate it when anyone with access leaves.
|
||||
:::
|
||||
|
||||
## What the vault is not
|
||||
|
||||
- **Not a password manager.** There is no sharing, expiry or per-user
|
||||
visibility. Anyone who can sign in and reveal, can reveal.
|
||||
- **Not versioned.** Overwriting a value loses the previous one.
|
||||
- **Not recoverable without the key.** If `KEY_ENCRYPTION_KEY` is lost, so is
|
||||
every value. Back it up separately from the database.
|
||||
@@ -0,0 +1,98 @@
|
||||
---
|
||||
id: servers
|
||||
title: Servers
|
||||
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.
|
||||
|
||||
## Enrolling a server
|
||||
|
||||
Covered step by step in [Add your first server](../getting-started/first-server.md).
|
||||
In short: **Servers → Add server** issues a single-use, one-hour token and shows
|
||||
a one-liner to run as root on the target machine.
|
||||
|
||||
## Lifecycle
|
||||
|
||||
| Status | Meaning |
|
||||
| --------- | --------------------------------------------------- |
|
||||
| `pending` | Enrolment created; the agent has not registered yet |
|
||||
| `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
|
||||
missed poll is not an outage.
|
||||
|
||||
## The server detail page
|
||||
|
||||
### Keys
|
||||
|
||||
Which SSH keys are assigned to this machine, and their state. See
|
||||
[SSH keys](./ssh-keys.md).
|
||||
|
||||
### Inventory
|
||||
|
||||
Agents report:
|
||||
|
||||
| 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
|
||||
metrics is normal rather than a fault.
|
||||
|
||||
### OS updates
|
||||
|
||||
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
|
||||
agent runs the platform's package manager and reports back.
|
||||
- **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
|
||||
for.
|
||||
:::
|
||||
|
||||
### Console
|
||||
|
||||
Opens a browser SSH, RDP or VNC session. See [Browser console](./browser-console.md).
|
||||
|
||||
## 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
|
||||
on any non-Linux host. This is a deliberate scope decision, not a gap being
|
||||
worked on.
|
||||
|
||||
## Removing a server
|
||||
|
||||
Deleting the server record removes it from the fleet. It does **not** uninstall
|
||||
the agent, which will keep trying to sync and failing. Uninstall it on the
|
||||
machine too:
|
||||
|
||||
```bash
|
||||
systemctl disable --now vantage-agent
|
||||
rm -f /usr/local/bin/vantage-agent /etc/systemd/system/vantage-agent.service
|
||||
rm -rf /etc/vantage
|
||||
systemctl daemon-reload
|
||||
```
|
||||
|
||||
Keys previously written to `authorized_keys` stay on disk, because the agent is
|
||||
no longer running to remove them. Revoke and let the agent apply the change
|
||||
**before** you delete the server if that matters to you.
|
||||
|
||||
## Agent tokens
|
||||
|
||||
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
|
||||
machine.
|
||||
@@ -0,0 +1,113 @@
|
||||
---
|
||||
id: settings
|
||||
title: Settings
|
||||
sidebar_label: Settings
|
||||
---
|
||||
|
||||
One page, three groups: **Access**, **Monitoring** and **Integrations**. Plus
|
||||
the licence, which has its own page.
|
||||
|
||||
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
|
||||
this instance behaves" produced two half-pages and a nav entry nobody could
|
||||
distinguish from Settings. The old path still redirects.
|
||||
:::
|
||||
|
||||
## Access
|
||||
|
||||
### Members
|
||||
|
||||
Add, remove and re-role the people who can sign in.
|
||||
|
||||
| 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.
|
||||
|
||||
#### Members managed by Vantage HQ
|
||||
|
||||
On a cloud instance, people granted access from the HQ portal appear here as
|
||||
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
|
||||
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).
|
||||
:::
|
||||
|
||||
### Single sign-on (OIDC)
|
||||
|
||||
Configured per organisation:
|
||||
|
||||
| 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`.
|
||||
|
||||
Local and OIDC users coexist. Keep at least one local owner: if the provider is
|
||||
misconfigured or unreachable, a local account is the way back in.
|
||||
|
||||
## Monitoring
|
||||
|
||||
- **Alert defaults** for monitors.
|
||||
- **Notification channels** their own page. See
|
||||
[Notification channels](./notification-channels.md).
|
||||
|
||||
## Integrations
|
||||
|
||||
### Workflow log retention
|
||||
|
||||
How long run logs are kept.
|
||||
|
||||
| Value | Meaning |
|
||||
| -------- | -------------- |
|
||||
| unset | 30 days |
|
||||
| a number | that many days |
|
||||
| `0` | forever |
|
||||
|
||||
### ESO read token
|
||||
|
||||
The bearer token External Secrets Operator uses to read secret groups. Shown
|
||||
once, stored as a SHA-256 hash, rotatable. See
|
||||
[Secrets](./secrets.md#kubernetes-external-secrets-operator).
|
||||
|
||||
## Licence
|
||||
|
||||
`/settings/license` shows the deployment, tier, server allowance, enabled
|
||||
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.
|
||||
|
||||
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
|
||||
instead.
|
||||
|
||||
See [Licensing and entitlements](../hq/licensing-and-entitlements.md).
|
||||
|
||||
## Sessions
|
||||
|
||||
Sessions are an opaque token in the `km_session` cookie, held in Redis with a
|
||||
24-hour TTL. There is no per-session management UI; restarting Redis signs
|
||||
everyone out and affects nothing else.
|
||||
|
||||
## Host and organisation guard
|
||||
|
||||
On a multi-tenant deployment, a request to `<slug>.vantage.<tld>` resolves the
|
||||
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
|
||||
protecting anything. If you serve the UI on a custom domain, set it to match.
|
||||
:::
|
||||
@@ -0,0 +1,81 @@
|
||||
---
|
||||
id: ssh-keys
|
||||
title: SSH keys
|
||||
sidebar_label: SSH keys
|
||||
---
|
||||
|
||||
Vantage holds a library of public keys and decides, per server, which ones
|
||||
belong in `/root/.ssh/authorized_keys`. The agent makes the file match.
|
||||
|
||||
:::info root only
|
||||
Vantage manages `/root/.ssh/authorized_keys` and nothing else. There is no
|
||||
per-user key management. The agent runs as root because writing that file
|
||||
requires it.
|
||||
:::
|
||||
|
||||
## Adding a key
|
||||
|
||||
### Upload one you already have
|
||||
|
||||
**Keys → Add key**, paste the public half. Vantage stores the public key and its
|
||||
fingerprint, and never needs the private half for this path.
|
||||
|
||||
### Generate one on a server
|
||||
|
||||
Vantage can have an agent generate a keypair on a managed machine
|
||||
(`GenerateKeyCmd` over the command stream). The public half comes back to the
|
||||
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
|
||||
own endpoint and its own audit event.
|
||||
|
||||
:::tip Why store a private key at all
|
||||
The [browser console](./browser-console.md) needs one to open an SSH session. If
|
||||
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
|
||||
agent picks up the change.
|
||||
|
||||
## Revoking
|
||||
|
||||
Revocation is **soft**: the assignment gets a `revoked_at` timestamp rather than
|
||||
being deleted, so the history of who had access to what, and when, survives.
|
||||
|
||||
The agent treats a revoked assignment as "not desired" and removes the line from
|
||||
`authorized_keys` on its next sync.
|
||||
|
||||
:::warning Revoking does not close open sessions
|
||||
It removes the key from the file. An SSH session already established stays up
|
||||
until it ends. Kill sessions on the machine if that matters.
|
||||
:::
|
||||
|
||||
## What the agent actually does
|
||||
|
||||
Each poll:
|
||||
|
||||
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.
|
||||
4. If they differ, it writes a temporary file, then `os.Rename()`s it over the
|
||||
real one and sets mode `0600`.
|
||||
|
||||
The rename is atomic, so a machine that dies mid-write keeps the old file
|
||||
intact. There is no window in which `authorized_keys` is truncated or partial.
|
||||
|
||||
:::danger Vantage owns the whole file
|
||||
The agent rewrites `authorized_keys` to match the desired set. Keys added by
|
||||
hand on the machine are removed on the next change. If a key must survive, put
|
||||
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
|
||||
hosting provider. Vantage has no backdoor and does not keep a break-glass key.
|
||||
@@ -0,0 +1,141 @@
|
||||
---
|
||||
id: workflows
|
||||
title: Workflows and steps
|
||||
sidebar_label: Workflows
|
||||
---
|
||||
|
||||
A **step** is a reusable script with declared inputs, outputs and secret
|
||||
references. A **workflow** composes steps in order and targets a set of servers.
|
||||
Running one dispatches the steps to each target's agent and streams the output
|
||||
back live.
|
||||
|
||||
## Steps
|
||||
|
||||
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 |
|
||||
|
||||
### Passing values between steps
|
||||
|
||||
Each step runs with `WORKFLOW_ENV` set to a file path. Anything written there as
|
||||
`KEY=value` becomes an environment variable for the **later steps of the same
|
||||
run on the same server**.
|
||||
|
||||
```bash
|
||||
HOSTNAME=$(hostname)
|
||||
echo "$HOSTNAME"
|
||||
echo "HOSTNAME=$HOSTNAME" >> $WORKFLOW_ENV
|
||||
```
|
||||
|
||||
That is the whole mechanism. `declared_outputs` documents what a step exports so
|
||||
the designer can show it; the file is what actually carries the value.
|
||||
|
||||
### Secrets
|
||||
|
||||
List a vault group in `secret_refs` and its key/value pairs are injected as
|
||||
environment variables when the step runs. They are not written to the run log
|
||||
unless your own script echoes them. See [Secrets](./secrets.md).
|
||||
|
||||
### The workspace
|
||||
|
||||
Every run gets a per-run working directory on each target. Steps share it, so
|
||||
one step can leave a file for the next. The agent removes it at the end of the
|
||||
run (`CleanupWorkspaceCmd`).
|
||||
|
||||
Do not use it for anything that must outlive the run.
|
||||
|
||||
## Default steps
|
||||
|
||||
A small library is seeded into every organisation at boot from the image, so a
|
||||
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.
|
||||
|
||||
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
|
||||
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 API is the boundary; the UI is the courtesy.
|
||||
|
||||
## Building a workflow
|
||||
|
||||
1. **Workflows → New**.
|
||||
2. Add steps in order from the library.
|
||||
3. Set inputs per step.
|
||||
4. Set failure behaviour per step.
|
||||
5. Choose target servers.
|
||||
|
||||
### 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 |
|
||||
|
||||
### Per-step overrides
|
||||
|
||||
A workflow can override a step's script or its secret references without
|
||||
touching the library entry. This is how you adapt a default step, and it is
|
||||
scoped to that workflow.
|
||||
|
||||
## Running
|
||||
|
||||
**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.
|
||||
A run shows the script that actually executed, not the current library version.
|
||||
:::
|
||||
|
||||
Targets run **in parallel**; steps within one server run **in order**.
|
||||
|
||||
## Watching a run
|
||||
|
||||
Step stdout and stderr stream back as chunks, are appended to a log file on the
|
||||
server, and the UI follows them live. Each step records status, attempts, exit
|
||||
code and its exported environment.
|
||||
|
||||
**Cancel** stops a run in progress. Steps already running on an agent finish;
|
||||
nothing further is dispatched.
|
||||
|
||||
## Log retention
|
||||
|
||||
Run logs are swept on a schedule set by `workflow_log_retention_days` in
|
||||
Settings:
|
||||
|
||||
| Value | Meaning |
|
||||
| -------- | -------------- |
|
||||
| unset | 30 days |
|
||||
| a number | that many days |
|
||||
| `0` | keep forever |
|
||||
|
||||
## Import and export
|
||||
|
||||
Steps export to a JSON file (`vantage.step/v1`) and import back, which is how
|
||||
you move a step between instances or keep one in version control. There is also
|
||||
a parse endpoint that turns a pasted script into a draft step by reading its
|
||||
declared inputs and outputs.
|
||||
|
||||
## Practical notes
|
||||
|
||||
- A step is a script. It runs as root, on the target, with no sandbox. Review
|
||||
what you import.
|
||||
- Keep steps small and single-purpose; compose them in the workflow. That is
|
||||
what makes the library reusable rather than a folder of near-duplicates.
|
||||
- PowerShell steps only make sense on Windows targets and bash steps on Linux
|
||||
ones. Nothing stops you targeting the wrong one; the step simply fails.
|
||||
@@ -0,0 +1,123 @@
|
||||
import type * as Preset from "@docusaurus/preset-classic";
|
||||
import type { Config } from "@docusaurus/types";
|
||||
import { themes as prismThemes } from "prism-react-renderer";
|
||||
|
||||
// Served as a path on the marketing host (vantage.hostxtra.co.uk/docs), routed
|
||||
// by its own Nginx Proxy Manager location rather than by site/. A path and not
|
||||
// a subdomain on purpose: *.vantage.hostxtra.co.uk is the per-tenant instance
|
||||
// namespace, and APP_ROOT_LABEL resolves an org from the label before
|
||||
// "vantage", so a docs. label there would be read as a tenant slug.
|
||||
//
|
||||
// DOCS_BASE_URL has to agree with three things at once: the NPM location, the
|
||||
// directory the runtime image copies the build into, and this value. When they
|
||||
// disagree the HTML still loads and every asset 404s.
|
||||
const url = process.env.DOCS_URL || "https://vantage.hostxtra.co.uk";
|
||||
const baseUrl = process.env.DOCS_BASE_URL || "/docs/";
|
||||
const appUrl = process.env.APP_URL || "https://vantage.hostxtra.co.uk";
|
||||
const hqUrl = process.env.HQ_URL || "https://vantage-hq.hostxtra.co.uk";
|
||||
|
||||
const config: Config = {
|
||||
title: "Vantage Docs",
|
||||
tagline: "Fleet management for servers you actually own",
|
||||
favicon: "img/favicon.svg",
|
||||
|
||||
url,
|
||||
baseUrl,
|
||||
|
||||
organizationName: "hostxtra",
|
||||
projectName: "vantage",
|
||||
|
||||
onBrokenLinks: "throw",
|
||||
onBrokenAnchors: "throw",
|
||||
|
||||
i18n: { defaultLocale: "en", locales: ["en"] },
|
||||
|
||||
markdown: {
|
||||
mermaid: true,
|
||||
hooks: { onBrokenMarkdownLinks: "throw" },
|
||||
},
|
||||
themes: [
|
||||
"@docusaurus/theme-mermaid",
|
||||
[
|
||||
// Compile-time index served from this origin. No Algolia account,
|
||||
// no external host, nothing to key or rotate.
|
||||
"@easyops-cn/docusaurus-search-local",
|
||||
{
|
||||
hashed: true,
|
||||
indexBlog: false,
|
||||
docsRouteBasePath: "/",
|
||||
highlightSearchTermsOnTargetPage: true,
|
||||
},
|
||||
],
|
||||
],
|
||||
|
||||
presets: [
|
||||
[
|
||||
"classic",
|
||||
{
|
||||
docs: {
|
||||
// Docs-only mode: the documentation is the site.
|
||||
routeBasePath: "/",
|
||||
sidebarPath: "./sidebars.ts",
|
||||
},
|
||||
blog: false,
|
||||
theme: { customCss: "./src/css/custom.css" },
|
||||
} satisfies Preset.Options,
|
||||
],
|
||||
],
|
||||
|
||||
themeConfig: {
|
||||
colorMode: {
|
||||
// Light default, matching site/ and adminsite/. web/ is the only
|
||||
// app locked to dark, and that contrast is deliberate.
|
||||
defaultMode: "light",
|
||||
respectPrefersColorScheme: true,
|
||||
},
|
||||
navbar: {
|
||||
// Wordmark only. The logo mark is drawn with currentColor in
|
||||
// site/components/Logo.tsx, which an <img> src cannot inherit, and
|
||||
// baking a fill into the file would mean a hex that stops tracking
|
||||
// the theme.
|
||||
title: "Vantage",
|
||||
items: [
|
||||
{
|
||||
type: "docSidebar",
|
||||
sidebarId: "docs",
|
||||
position: "left",
|
||||
label: "Documentation",
|
||||
},
|
||||
{ href: appUrl, label: "Control plane", position: "right" },
|
||||
{ href: hqUrl, label: "Vantage HQ", position: "right" },
|
||||
],
|
||||
},
|
||||
footer: {
|
||||
style: "light",
|
||||
links: [
|
||||
{
|
||||
title: "Documentation",
|
||||
items: [
|
||||
{ label: "Getting started", to: "/getting-started/what-is-vantage" },
|
||||
{ label: "Vantage", to: "/vantage/servers" },
|
||||
{ label: "Vantage HQ", to: "/hq/accounts-and-signup" },
|
||||
{ label: "Reference", to: "/reference/environment-variables" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Product",
|
||||
items: [
|
||||
{ label: "Control plane", href: appUrl },
|
||||
{ label: "Vantage HQ", href: hqUrl },
|
||||
],
|
||||
},
|
||||
],
|
||||
copyright: `© ${new Date().getFullYear()} HostXtra.`,
|
||||
},
|
||||
prism: {
|
||||
theme: prismThemes.github,
|
||||
darkTheme: prismThemes.dracula,
|
||||
additionalLanguages: ["bash", "powershell", "yaml", "json", "protobuf", "nginx"],
|
||||
},
|
||||
} satisfies Preset.ThemeConfig,
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,32 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Hitting the container directly is a 403 on an empty document root
|
||||
# otherwise, which reads as a broken deploy rather than "wrong path".
|
||||
location = / {
|
||||
return 302 /docs/;
|
||||
}
|
||||
|
||||
# Hashed assets are immutable — the filename changes when the content does.
|
||||
location /docs/assets/ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location /docs/ {
|
||||
# Docusaurus emits a real page per route, so a miss is a genuine 404
|
||||
# rather than something a SPA fallback should paper over.
|
||||
try_files $uri $uri/ $uri.html /docs/404.html;
|
||||
}
|
||||
|
||||
error_page 404 /docs/404.html;
|
||||
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/javascript application/json image/svg+xml;
|
||||
gzip_min_length 1024;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user