Compare commits
24
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 |
@@ -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
|
||||
@@ -150,6 +150,7 @@ jobs:
|
||||
--build-arg NEXT_PUBLIC_ADMIN_ENV="${{ vars.ADMIN_ENV }}" \
|
||||
--build-arg NEXT_PUBLIC_PADDLE_CLIENT_TOKEN="${{ vars.PADDLE_CLIENT_TOKEN }}" \
|
||||
--build-arg NEXT_PUBLIC_PADDLE_ENV="${{ vars.PADDLE_ENV }}" \
|
||||
--build-arg NEXT_PUBLIC_SITE_URL="${{ vars.SITE_URL }}" \
|
||||
-t "$IMAGE" \
|
||||
-f adminsite/Dockerfile adminsite/
|
||||
docker push "$IMAGE"
|
||||
|
||||
+4
-1
@@ -3,6 +3,7 @@ dist
|
||||
build
|
||||
.env
|
||||
.env.bck
|
||||
.env.live
|
||||
docs/*
|
||||
!docs/superpowers/
|
||||
.superpowers
|
||||
@@ -11,4 +12,6 @@ installer/*.msi
|
||||
installer/nssm.zip
|
||||
installer/checksums-msi.txt
|
||||
.next
|
||||
*.tsbuildinfo
|
||||
*.tsbuildinfo
|
||||
graphify-out
|
||||
docker-compose.live.yml
|
||||
@@ -77,6 +77,16 @@ func Run(ctx context.Context) error {
|
||||
}
|
||||
}
|
||||
|
||||
if lic.ExpiresAt.Add(reapAfter).Before(now) {
|
||||
if _, err := db.Admin("admin_instances").DeleteOne(ctx, bson.M{"instance_id": inst.InstanceID}); err != nil {
|
||||
log.Printf("lifecycle: delete instance %s: %v", inst.InstanceID, err)
|
||||
}
|
||||
|
||||
if _, err := db.Admin("licenses").DeleteMany(ctx, bson.M{"instance_id": inst.InstanceID}); err != nil {
|
||||
log.Printf("lifecycle: delete licenses for instance %s: %v", inst.InstanceID, err)
|
||||
}
|
||||
}
|
||||
|
||||
due := dueNotice(now, lic.ExpiresAt, inst.NoticesSent)
|
||||
if due == "" {
|
||||
continue
|
||||
|
||||
@@ -26,6 +26,11 @@ ENV NEXT_PUBLIC_PADDLE_CLIENT_TOKEN=$NEXT_PUBLIC_PADDLE_CLIENT_TOKEN
|
||||
ARG NEXT_PUBLIC_PADDLE_ENV=sandbox
|
||||
ENV NEXT_PUBLIC_PADDLE_ENV=$NEXT_PUBLIC_PADDLE_ENV
|
||||
|
||||
# Marketing site origin. Signup lives there (/start), not here; empty renders no
|
||||
# link at all rather than one that 404s.
|
||||
ARG NEXT_PUBLIC_SITE_URL=
|
||||
ENV NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL
|
||||
|
||||
RUN npm run build
|
||||
|
||||
FROM node:26-alpine AS runner
|
||||
|
||||
@@ -10,7 +10,7 @@ export const metadata: Metadata = {
|
||||
|
||||
/*
|
||||
* The masthead deliberately does NOT live here. It belongs to the authenticated
|
||||
* layouts, so /login, /signup, /verify and /accept-invite stop rendering a bar
|
||||
* layouts, so /login, /verify and /accept-invite stop rendering a bar
|
||||
* whose navigation and account menu they cannot use.
|
||||
*/
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { API_BASE, ApiError, NotConnected, api } from "@/lib/api";
|
||||
import { NotConnectedPanel } from "@/components/NotConnected";
|
||||
import { Button } from "@/components/Button";
|
||||
import { Field } from "@/components/Field";
|
||||
|
||||
const SITE_URL = (process.env.NEXT_PUBLIC_SITE_URL ?? "").replace(/\/$/, "");
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const [email, setEmail] = useState("");
|
||||
@@ -44,46 +45,75 @@ export default function LoginPage() {
|
||||
|
||||
return (
|
||||
<Main>
|
||||
<h1 className="text-3xl">Sign in</h1>
|
||||
<form onSubmit={submit} className="mt-6 grid gap-4">
|
||||
<Field
|
||||
label="Email"
|
||||
type="email"
|
||||
autoComplete="username"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
<Field
|
||||
label="Password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
error={error ?? undefined}
|
||||
/>
|
||||
<label className="flex items-center gap-2 text-[0.82rem] text-ink-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={staff}
|
||||
onChange={(e) => setStaff(e.target.checked)}
|
||||
{/* The masthead's lockup, unlinked: there is nowhere to go yet. */}
|
||||
<div className="mb-7 flex flex-col items-center gap-2 text-center">
|
||||
<span className="flex items-baseline gap-2 text-[1.5rem] font-extrabold tracking-[-0.02em]">
|
||||
Vantage
|
||||
<span className="font-mono text-[0.78rem] font-normal uppercase tracking-[0.14em] text-ink-3">
|
||||
HQ
|
||||
</span>
|
||||
</span>
|
||||
<h1 className="text-[1.16rem]">Sign in</h1>
|
||||
<p className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">
|
||||
Licences · instances · billing
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded border border-rule bg-panel p-6 shadow-[var(--shadow)]">
|
||||
<form onSubmit={submit} className="grid gap-4">
|
||||
<Field
|
||||
label="Email"
|
||||
type="email"
|
||||
autoComplete="username"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
I work at Vantage
|
||||
</label>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button type="submit" disabled={busy}>
|
||||
<Field
|
||||
label="Password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
error={error ?? undefined}
|
||||
/>
|
||||
<label className="flex items-center gap-2 text-[0.82rem] text-ink-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={staff}
|
||||
onChange={(e) => setStaff(e.target.checked)}
|
||||
className="accent-[var(--accent)]"
|
||||
/>
|
||||
I work at Vantage
|
||||
</label>
|
||||
<Button type="submit" disabled={busy} className="w-full justify-center">
|
||||
{busy ? "Signing in…" : "Sign in"}
|
||||
</Button>
|
||||
<Link href="/signup" className="text-[0.82rem] text-accent underline">
|
||||
Create an account for a self-hosted licence
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
</form>
|
||||
|
||||
{SITE_URL && (
|
||||
<>
|
||||
<div className="my-5 h-px bg-rule-soft" />
|
||||
|
||||
{/* Signup lives on the marketing site's /start, not here. */}
|
||||
<p className="text-center text-[0.82rem] text-ink-3">
|
||||
No account?{" "}
|
||||
<a href={`${SITE_URL}/start`} className="text-accent underline">
|
||||
Create one
|
||||
</a>
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Main>
|
||||
);
|
||||
}
|
||||
|
||||
function Main({ children }: { children: React.ReactNode }) {
|
||||
return <main className="mx-auto max-w-rail px-5 py-12">{children}</main>;
|
||||
return (
|
||||
<main className="mx-auto flex min-h-screen w-full max-w-[26rem] flex-col justify-center px-5 py-12">
|
||||
{children}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { ApiError, NotConnected, api } from "@/lib/api";
|
||||
import { Button } from "@/components/Button";
|
||||
import { Field } from "@/components/Field";
|
||||
|
||||
export default function SignupPage() {
|
||||
const [form, setForm] = useState({ name: "", email: "", password: "", website: "" });
|
||||
const [state, setState] = useState<"idle" | "busy" | "sent">("idle");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setState("busy");
|
||||
setError(null);
|
||||
try {
|
||||
await api.signup(form);
|
||||
setState("sent");
|
||||
} catch (err) {
|
||||
setState("idle");
|
||||
setError(
|
||||
err instanceof NotConnected
|
||||
? "The licensing service is not reachable from this page."
|
||||
: err instanceof ApiError
|
||||
? err.message
|
||||
: "Could not create the account. Try again.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-rail px-5 py-12">
|
||||
{state === "sent" ? (
|
||||
<div className="grid max-w-xl gap-3">
|
||||
<h1 className="text-3xl">Check your email</h1>
|
||||
<p className="text-ink-2">
|
||||
We sent a link to {form.email}. Open it to finish setting up your account —
|
||||
it expires in 24 hours. Nothing is created until you do.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<h1 className="text-3xl">Create an account</h1>
|
||||
<p className="mt-2 max-w-xl text-ink-2">
|
||||
For self-hosted licences. If you run on our cloud, sign in with the same
|
||||
details you use for your Vantage instance.
|
||||
</p>
|
||||
<form onSubmit={submit} className="mt-6 grid gap-4">
|
||||
<Field
|
||||
label="Organisation"
|
||||
required
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
/>
|
||||
<Field
|
||||
label="Email"
|
||||
type="email"
|
||||
required
|
||||
value={form.email}
|
||||
onChange={(e) => setForm({ ...form, email: e.target.value })}
|
||||
/>
|
||||
<Field
|
||||
label="Password"
|
||||
type="password"
|
||||
required
|
||||
minLength={12}
|
||||
hint="At least 12 characters."
|
||||
value={form.password}
|
||||
onChange={(e) => setForm({ ...form, password: e.target.value })}
|
||||
error={error ?? undefined}
|
||||
/>
|
||||
{/* Honeypot: off-screen, unlabelled for humans, irresistible to bots. */}
|
||||
<input
|
||||
type="text"
|
||||
name="website"
|
||||
tabIndex={-1}
|
||||
autoComplete="off"
|
||||
aria-hidden="true"
|
||||
value={form.website}
|
||||
onChange={(e) => setForm({ ...form, website: e.target.value })}
|
||||
className="absolute left-[-9999px] h-0 w-0"
|
||||
/>
|
||||
<Button type="submit" disabled={state === "busy"}>
|
||||
{state === "busy" ? "Creating…" : "Create account"}
|
||||
</Button>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -296,8 +296,6 @@ export const api = {
|
||||
staffLogin: (email: string, password: string) =>
|
||||
post<Session>("/auth/staff/login", { email, password }),
|
||||
logout: () => post<{ ok: boolean }>("/auth/logout"),
|
||||
signup: (payload: { name: string; email: string; password: string; website?: string }) =>
|
||||
post<{ pending: boolean }>("/auth/signup", payload),
|
||||
verify: (token: string) =>
|
||||
req<{ verified: boolean; needs_password?: boolean }>(
|
||||
`/auth/verify?token=${encodeURIComponent(token)}`,
|
||||
|
||||
@@ -151,3 +151,7 @@ func (c *Client) ReportChecks(serverID, agentToken string, results []pb.CheckRes
|
||||
func (c *Client) CommandStream(ctx context.Context) (pb.Vantage_CommandStreamClient, error) {
|
||||
return c.client.CommandStream(ctx)
|
||||
}
|
||||
|
||||
func (c *Client) ProxyStream(ctx context.Context) (pb.Vantage_ProxyStreamClient, error) {
|
||||
return c.client.ProxyStream(ctx)
|
||||
}
|
||||
|
||||
@@ -131,6 +131,32 @@ type ReportChecksResponse struct{}
|
||||
|
||||
type ApplyUpdatesCmd struct{}
|
||||
|
||||
type OpenProxyCmd struct {
|
||||
ProxyId string `json:"proxy_id"`
|
||||
Port uint32 `json:"port"`
|
||||
}
|
||||
|
||||
type ProxyOpen struct {
|
||||
ServerId string `json:"server_id"`
|
||||
AgentToken string `json:"agent_token"`
|
||||
ProxyId string `json:"proxy_id"`
|
||||
}
|
||||
|
||||
type ProxyClose struct {
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
type ProxyClientMsg struct {
|
||||
Open *ProxyOpen `json:"open,omitempty"`
|
||||
Data []byte `json:"data,omitempty"`
|
||||
Close *ProxyClose `json:"close,omitempty"`
|
||||
}
|
||||
|
||||
type ProxyServerMsg struct {
|
||||
Data []byte `json:"data,omitempty"`
|
||||
Close *ProxyClose `json:"close,omitempty"`
|
||||
}
|
||||
|
||||
type ServerCommand struct {
|
||||
CommandId string `json:"command_id"`
|
||||
GenerateKey *GenerateKeyCmd `json:"generate_key,omitempty"`
|
||||
@@ -139,6 +165,7 @@ type ServerCommand struct {
|
||||
ApplyUpdates *ApplyUpdatesCmd `json:"apply_updates,omitempty"`
|
||||
RunStep *RunStepCmd `json:"run_step,omitempty"`
|
||||
CleanupWorkspace *CleanupWorkspaceCmd `json:"cleanup_workspace,omitempty"`
|
||||
OpenProxy *OpenProxyCmd `json:"open_proxy,omitempty"`
|
||||
}
|
||||
|
||||
|
||||
@@ -254,6 +281,51 @@ func (s *keyManagerCommandStreamServer) Recv() (*AgentMessage, error) {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
type Vantage_ProxyStreamServer interface {
|
||||
Send(*ProxyServerMsg) error
|
||||
Recv() (*ProxyClientMsg, error)
|
||||
grpc.ServerStream
|
||||
}
|
||||
|
||||
type vantageProxyStreamServer struct {
|
||||
grpc.ServerStream
|
||||
}
|
||||
|
||||
func (s *vantageProxyStreamServer) Send(m *ProxyServerMsg) error {
|
||||
return s.ServerStream.SendMsg(m)
|
||||
}
|
||||
|
||||
func (s *vantageProxyStreamServer) Recv() (*ProxyClientMsg, error) {
|
||||
m := new(ProxyClientMsg)
|
||||
if err := s.ServerStream.RecvMsg(m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
type Vantage_ProxyStreamClient interface {
|
||||
Send(*ProxyClientMsg) error
|
||||
Recv() (*ProxyServerMsg, error)
|
||||
CloseSend() error
|
||||
grpc.ClientStream
|
||||
}
|
||||
|
||||
type vantageProxyStreamClient struct {
|
||||
grpc.ClientStream
|
||||
}
|
||||
|
||||
func (c *vantageProxyStreamClient) Send(m *ProxyClientMsg) error {
|
||||
return c.ClientStream.SendMsg(m)
|
||||
}
|
||||
|
||||
func (c *vantageProxyStreamClient) Recv() (*ProxyServerMsg, error) {
|
||||
m := new(ProxyServerMsg)
|
||||
if err := c.ClientStream.RecvMsg(m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
type VantageClient interface {
|
||||
Register(ctx context.Context, in *RegisterRequest, opts ...grpc.CallOption) (*RegisterResponse, error)
|
||||
SyncKeys(ctx context.Context, in *SyncRequest, opts ...grpc.CallOption) (*SyncResponse, error)
|
||||
@@ -263,6 +335,7 @@ type VantageClient interface {
|
||||
SyncMonitors(ctx context.Context, in *SyncMonitorsRequest, opts ...grpc.CallOption) (*SyncMonitorsResponse, error)
|
||||
ReportChecks(ctx context.Context, in *ReportChecksRequest, opts ...grpc.CallOption) (*ReportChecksResponse, error)
|
||||
CommandStream(ctx context.Context, opts ...grpc.CallOption) (Vantage_CommandStreamClient, error)
|
||||
ProxyStream(ctx context.Context, opts ...grpc.CallOption) (Vantage_ProxyStreamClient, error)
|
||||
}
|
||||
|
||||
type UnimplementedVantageServer struct{}
|
||||
@@ -349,3 +422,12 @@ func (c *keyManagerClient) CommandStream(ctx context.Context, opts ...grpc.CallO
|
||||
}
|
||||
return &vantageCommandStreamClient{stream}, nil
|
||||
}
|
||||
|
||||
func (c *keyManagerClient) ProxyStream(ctx context.Context, opts ...grpc.CallOption) (Vantage_ProxyStreamClient, error) {
|
||||
desc := &grpc.StreamDesc{StreamName: "ProxyStream", ServerStreams: true, ClientStreams: true}
|
||||
stream, err := c.cc.NewStream(ctx, desc, "/vantage.v1.Vantage/ProxyStream", opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &vantageProxyStreamClient{stream}, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
// Package agentproxy relays a single TCP connection between a local service and
|
||||
// the control plane, so a control plane that cannot route to this host's network
|
||||
// can still open a console session.
|
||||
//
|
||||
// The dial host is hardcoded to loopback. The control plane supplies only a
|
||||
// port, and nothing in this package can be made to dial anywhere else.
|
||||
package agentproxy
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/grpc/pb"
|
||||
)
|
||||
|
||||
const (
|
||||
loopbackHost = "127.0.0.1"
|
||||
chunkSize = 32 * 1024
|
||||
dialTimeout = 10 * time.Second
|
||||
)
|
||||
|
||||
// Stream is the agent's half of a ProxyStream.
|
||||
type Stream interface {
|
||||
Send(*pb.ProxyClientMsg) error
|
||||
Recv() (*pb.ProxyServerMsg, error)
|
||||
CloseSend() error
|
||||
}
|
||||
|
||||
// Open dials the local port, announces itself on the stream, and relays until
|
||||
// either side ends. A refused dial is reported as an explicit close so the
|
||||
// operator sees a reason rather than a hang.
|
||||
func Open(stream Stream, serverID, agentToken, proxyID string, port uint32) error {
|
||||
conn, dialErr := net.DialTimeout("tcp",
|
||||
net.JoinHostPort(loopbackHost, strconv.Itoa(int(port))), dialTimeout)
|
||||
|
||||
if err := stream.Send(&pb.ProxyClientMsg{Open: &pb.ProxyOpen{
|
||||
ServerId: serverID,
|
||||
AgentToken: agentToken,
|
||||
ProxyId: proxyID,
|
||||
}}); err != nil {
|
||||
if conn != nil {
|
||||
_ = conn.Close()
|
||||
}
|
||||
return fmt.Errorf("send open: %w", err)
|
||||
}
|
||||
|
||||
if dialErr != nil {
|
||||
_ = stream.Send(&pb.ProxyClientMsg{Close: &pb.ProxyClose{
|
||||
Reason: "dial_refused: " + dialErr.Error(),
|
||||
}})
|
||||
_ = stream.CloseSend()
|
||||
return fmt.Errorf("dial 127.0.0.1:%d: %w", port, dialErr)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
return relay(conn, stream)
|
||||
}
|
||||
|
||||
func relay(conn net.Conn, stream Stream) error {
|
||||
errCh := make(chan error, 2)
|
||||
|
||||
// local service -> control plane
|
||||
go func() {
|
||||
buf := make([]byte, chunkSize)
|
||||
for {
|
||||
n, err := conn.Read(buf)
|
||||
if n > 0 {
|
||||
chunk := make([]byte, n)
|
||||
copy(chunk, buf[:n])
|
||||
if sendErr := stream.Send(&pb.ProxyClientMsg{Data: chunk}); sendErr != nil {
|
||||
errCh <- sendErr
|
||||
return
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
errCh <- err
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// control plane -> local service
|
||||
go func() {
|
||||
for {
|
||||
msg, err := stream.Recv()
|
||||
if err != nil {
|
||||
errCh <- err
|
||||
return
|
||||
}
|
||||
if msg.Close != nil {
|
||||
errCh <- fmt.Errorf("server closed relay: %s", msg.Close.Reason)
|
||||
return
|
||||
}
|
||||
if len(msg.Data) > 0 {
|
||||
if _, err := conn.Write(msg.Data); err != nil {
|
||||
errCh <- err
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
err := <-errCh
|
||||
_ = conn.Close()
|
||||
_ = stream.CloseSend()
|
||||
if errors.Is(err, io.EOF) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import (
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/inventory"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/keys"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/monitors"
|
||||
agentproxy "gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/proxy"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/updates"
|
||||
)
|
||||
|
||||
@@ -197,6 +198,9 @@ func connectAndHandleStream(ctx context.Context, cfg *config.Config) error {
|
||||
if cmd.CleanupWorkspace != nil {
|
||||
go handleCleanupWorkspace(cmd)
|
||||
}
|
||||
if cmd.OpenProxy != nil {
|
||||
go handleOpenProxy(ctx, cfg, cmd.OpenProxy)
|
||||
}
|
||||
if cmd.RunStep != nil {
|
||||
go func(rc *pb.RunStepCmd, cid string) {
|
||||
emit := func(seq uint64, data []byte) {
|
||||
@@ -326,6 +330,29 @@ func handleCleanupWorkspace(cmd *pb.ServerCommand) {
|
||||
log.Printf("removed run workspace %s (cmd=%s)", dir, cmd.CommandId)
|
||||
}
|
||||
|
||||
// handleOpenProxy relays one console connection. It uses its own gRPC
|
||||
// connection so console traffic never shares a stream with commands, key sync
|
||||
// or workflow output.
|
||||
func handleOpenProxy(ctx context.Context, cfg *config.Config, cmd *pb.OpenProxyCmd) {
|
||||
client, err := grpcclient.New(cfg.ServerURL, cfg.TLS)
|
||||
if err != nil {
|
||||
log.Printf("proxy %s: dial control plane: %v", cmd.ProxyId, err)
|
||||
return
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
stream, err := client.ProxyStream(ctx)
|
||||
if err != nil {
|
||||
log.Printf("proxy %s: open stream: %v", cmd.ProxyId, err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("proxy %s: relaying 127.0.0.1:%d", cmd.ProxyId, cmd.Port)
|
||||
if err := agentproxy.Open(stream, cfg.ServerID, cfg.AgentToken, cmd.ProxyId, cmd.Port); err != nil {
|
||||
log.Printf("proxy %s: %v", cmd.ProxyId, err)
|
||||
}
|
||||
}
|
||||
|
||||
func handleDeleteKey(cmd *pb.ServerCommand) {
|
||||
label := cmd.DeleteKey.Label
|
||||
keyPath := fmt.Sprintf("/root/.ssh/vantage_%s", strings.ReplaceAll(label, " ", "_"))
|
||||
|
||||
@@ -138,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
|
||||
|
||||
@@ -517,6 +532,8 @@ Windows: MSI built by CI (WiX), or `installer/setup.ps1` registering the agent a
|
||||
| `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 |
|
||||
@@ -669,6 +686,7 @@ git push origin main # server + web deploy
|
||||
| `API_URL` | **not** a CI variable | `web` reads it at **runtime**, from the container environment — `next.config.ts` is evaluated when `server.js` boots in standalone mode, and the rewrites it feeds are server-side, never browser-side. Default `http://localhost:8080`; compose sets `http://server:8080`. `NEXT_PUBLIC_API_URL` is still honoured as a fallback for existing deployments. |
|
||||
| `SITE_API_URL` | Variable | **browser-reachable** sitesvc URL, baked into the `site` image. Required — if empty, both forms report "not connected" and submit nowhere. Must also be in sitesvc's `SITE_ORIGIN`. |
|
||||
| `SITE_CONTACT_EMAIL` | Variable | optional; address shown when a form is misconfigured |
|
||||
| `SITE_URL` | Variable | browser URL of the marketing site, baked into `adminsite` so `/login` can point at `/start`. **Signup has no page in `adminsite` at all** — one signup form, on `site/`. Empty renders no link rather than one that 404s. |
|
||||
| `ADMIN_API_URL` | Variable | **browser-reachable** admin URL, baked into **both** the `adminsite` and `site` images — `site/start` posts account signups straight to admin. Same footgun as `SITE_API_URL`: wrong here and every request fails at runtime with the not-connected panel. |
|
||||
| `ADMIN_ENV` | Variable | `production` or `sandbox`; drives the persistent environment badge. Anything but `sandbox` reads as production. |
|
||||
| `HQ_URL` | Variable | optional; browser URL of the HQ portal, baked into `web` so an `hq`-sourced member links to where they are managed. Empty on self-hosted, which renders a plain label instead. |
|
||||
@@ -698,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).
|
||||
|
||||
@@ -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,7 +5,7 @@
|
||||
|
||||
# host:port agents dial for gRPC. No default; boot fails without it.
|
||||
# Must be reachable from managed servers. Use the public host, port 9090.
|
||||
GRPC_HOST=192.168.1.250:9090
|
||||
GRPC_HOST=vantage.yourdomain.com:9090
|
||||
|
||||
# 64-char hex (32 bytes) for AES-256-GCM. Required for private keys,
|
||||
# secrets, OIDC secrets, RDP/VNC credentials.
|
||||
@@ -47,7 +47,7 @@ services:
|
||||
KEY_ENCRYPTION_KEY: ${KEY_ENCRYPTION_KEY:-}
|
||||
VANTAGE_WORKFLOW_LOG_DIR: ${VANTAGE_WORKFLOW_LOG_DIR:-}
|
||||
GUACD_ADDR: guacd:4822
|
||||
APP_ROOT_LABEL: vantage
|
||||
PROXY_ADVERTISE_HOST: server
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,209 @@
|
||||
# Agent-relayed console proxy
|
||||
|
||||
Date: 2026-07-29
|
||||
Status: approved, not yet implemented
|
||||
|
||||
## Problem
|
||||
|
||||
`consoleTunnel` builds guacamole parameters from `srv.IPAddress` and hands them
|
||||
to guacd, which then dials the target itself. On a self-hosted deployment the
|
||||
control plane and the managed servers share a network, so that works. On Vantage
|
||||
Cloud they do not: guacd runs on the cloud host and the customer's server is on
|
||||
an RFC1918 address behind their NAT. Every cloud console session to a private
|
||||
address fails, for SSH, RDP and VNC alike.
|
||||
|
||||
Agents already hold an outbound gRPC connection to the control plane. The fix is
|
||||
to carry the console's TCP bytes over that existing path rather than asking guacd
|
||||
to route somewhere it cannot reach.
|
||||
|
||||
## Decisions
|
||||
|
||||
**Self-relay only.** The agent relays to its own host and nowhere else. It is
|
||||
never told a hostname; the host is hardcoded to `127.0.0.1` on the agent side and
|
||||
only the port comes from the server. A jump-host mode (reaching agentless devices
|
||||
through a neighbouring agent) was rejected: it would give an agent the power to
|
||||
dial arbitrary addresses on the customer's LAN, and the console today can only
|
||||
target servers that run an agent anyway.
|
||||
|
||||
**A dedicated bidirectional RPC, one stream per TCP connection.** Multiplexing
|
||||
console bytes onto the existing `CommandStream` was rejected — that stream
|
||||
already carries control commands and workflow stdout, and an RDP framebuffer
|
||||
would introduce head-of-line blocking against key sync and step output. A
|
||||
separate stream also gets connection lifetime, flow control and close semantics
|
||||
for free instead of needing a hand-rolled connection-ID demux.
|
||||
|
||||
**Always proxy, both deployments.** Direct dial is deleted rather than kept as a
|
||||
self-hosted fast path or a fallback. One code path means one tested code path,
|
||||
and the cloud path is the one no developer can reproduce locally. A
|
||||
try-direct-then-fall-back design was rejected outright: it puts a timeout in
|
||||
front of every private-network session and makes "which path did this session
|
||||
use" unanswerable from the audit log.
|
||||
|
||||
The cost is that the console now requires a live agent, where a self-hosted
|
||||
deployment could previously reach a server whose agent was down. In practice an
|
||||
offline agent almost always means an offline host, and the failure is now an
|
||||
immediate, explicit refusal instead of a hang.
|
||||
|
||||
## Architecture
|
||||
|
||||
Three parties rendezvous on a single `proxy_id`. Neither guacd nor the agent
|
||||
changes which direction it dials: guacd still makes an outbound TCP connection,
|
||||
the agent still only connects outbound to the control plane.
|
||||
|
||||
```
|
||||
consoleTunnel (server)
|
||||
1. proxy.Open(instance, server_id, port) -> proxy_id + ephemeral listener :N
|
||||
2. push OpenProxyCmd{proxy_id, port} down the existing CommandStream
|
||||
3. agent dials 127.0.0.1:port locally, then opens ProxyStream and sends
|
||||
ProxyOpen{server_id, agent_token, proxy_id}
|
||||
4. guacd dials PROXY_ADVERTISE_HOST:N (the params it was handed in step 1)
|
||||
5. registry holds both halves -> io.Copy in both directions
|
||||
6. either side EOFs -> close listener, close stream, drop the registry entry
|
||||
```
|
||||
|
||||
Steps 3 and 4 race, so a registry entry has two slots and starts piping when the
|
||||
second one arrives. Both waits share a single 10 second deadline; expiry closes
|
||||
everything and frees the entry.
|
||||
|
||||
The agent dials locally *before* opening the stream, so a refused connection
|
||||
arrives as an explicit `ProxyClose{reason}` rather than as a hang.
|
||||
|
||||
`BuildGuacParams` stops reading `srv.IPAddress` and takes the relay host and port
|
||||
instead. `IPAddress` remains in use for display and for monitors.
|
||||
|
||||
## Wire protocol
|
||||
|
||||
Additive only; no existing message changes shape.
|
||||
|
||||
```protobuf
|
||||
rpc ProxyStream(stream ProxyClientMsg) returns (stream ProxyServerMsg);
|
||||
|
||||
message OpenProxyCmd { // ServerCommand oneof field 8
|
||||
string proxy_id = 1;
|
||||
uint32 port = 2;
|
||||
}
|
||||
|
||||
message ProxyClientMsg {
|
||||
oneof payload {
|
||||
ProxyOpen open = 1; // first message only
|
||||
bytes data = 2;
|
||||
ProxyClose close = 3;
|
||||
}
|
||||
}
|
||||
message ProxyOpen { string server_id = 1; string agent_token = 2; string proxy_id = 3; }
|
||||
message ProxyServerMsg { oneof payload { bytes data = 1; ProxyClose close = 2; } }
|
||||
message ProxyClose { string reason = 1; }
|
||||
```
|
||||
|
||||
Two implementation facts about this repo shape the above. The `pb` packages are
|
||||
**hand-written Go, not protoc output** — `vantage.proto` is documentation, and
|
||||
both `server/internal/grpc/pb` and `agent/internal/grpc/pb` are edited by hand
|
||||
and kept in sync manually. And the registered codec is JSON, so a `bytes` field
|
||||
travels as a base64 string: roughly 33% overhead on relayed traffic. That is
|
||||
accepted rather than fixed here, because introducing a second codec for one RPC
|
||||
is a larger change than this feature warrants. Relay chunks are 32 KiB.
|
||||
|
||||
## Security
|
||||
|
||||
**The agent only ever dials `127.0.0.1`.** The port is the only field it takes
|
||||
from the server; the host is hardcoded agent-side. A compromised control plane
|
||||
cannot use an agent to reach anything else on the customer's network. This is the
|
||||
strongest property in the design and the reason self-relay was chosen.
|
||||
|
||||
**`proxy_id` is 32 random bytes, single-use and scoped.** On `ProxyOpen` the
|
||||
server checks three things together: the agent token hash matches that
|
||||
`server_id`, the `proxy_id` exists in the registry, and the entry's `server_id`
|
||||
and `instance_id` match the authenticated agent. Any mismatch closes the stream
|
||||
without revealing which check failed.
|
||||
|
||||
**The listener is the exposed surface and is narrowed four ways.** It binds an
|
||||
ephemeral port; it lives at most 10 seconds unclaimed; it accepts exactly one
|
||||
connection and closes immediately afterwards; and the accepted connection's
|
||||
remote address must resolve to a host named in `GUACD_ADDR`. Without that last
|
||||
check, any other container on the Docker network could claim the session during
|
||||
the window.
|
||||
|
||||
**Agent-offline is refused early.** `consoleConnect` checks
|
||||
`srv.Status == "active"` and returns 409 `agent_offline`, rather than letting the
|
||||
browser open a WebSocket that dies on a deadline.
|
||||
|
||||
**Audit.** `console.opened` gains the relay port and `proxy_id`. A relay that
|
||||
expires or is refused writes `console.proxy_failed` with a reason, so a failed
|
||||
console session stops being invisible.
|
||||
|
||||
Credentials are unchanged. Private keys and RDP passwords travel from the server
|
||||
to guacd inside the guacamole handshake and never reach the agent. The SSH and
|
||||
RDP sessions are negotiated end-to-end between guacd and the target daemon, so
|
||||
the agent relays bytes it cannot read.
|
||||
|
||||
## Components
|
||||
|
||||
New, server:
|
||||
|
||||
| Unit | Responsibility |
|
||||
| --- | --- |
|
||||
| `server/internal/proxy/registry.go` | `Open`, `AttachAgent`, `AttachTCP`, expiry sweep. Pure state — no net, no gRPC, testable alone |
|
||||
| `server/internal/proxy/session.go` | One relay: listener, deadline, the `io.Copy` pair, teardown-once |
|
||||
| `server/internal/grpc/proxystream.go` | The `ProxyStream` handler: authenticate, then hand the stream to the registry. No relay logic of its own |
|
||||
|
||||
New, agent:
|
||||
|
||||
| Unit | Responsibility |
|
||||
| --- | --- |
|
||||
| `agent/internal/proxy/proxy.go` | `Open(ctx, client, proxyID, port)` — dial loopback, open the stream, pump bytes. No build tags; Linux and Windows share it |
|
||||
|
||||
Changed:
|
||||
|
||||
- `proto/vantage/v1/vantage.proto`, and both generated pb trees
|
||||
- `server/internal/services/console.go` — `BuildGuacParams(srv, relayHost, relayPort, …)`
|
||||
- `server/internal/api/console.go` — offline pre-check in `consoleConnect`; open the relay before the guacd handshake in `consoleTunnel` and close it in `OnDisconnect`
|
||||
- `agent/internal/sync/sync.go` — handle `OpenProxyCmd`, one goroutine per proxy
|
||||
- `deploy/docker-compose.yml`, `deploy/docker-compose.site.yml` — `PROXY_ADVERTISE_HOST=server`
|
||||
|
||||
Two new optional environment variables on the server: `PROXY_ADVERTISE_HOST`
|
||||
(default `server`, the name guacd resolves the control plane by) and
|
||||
`PROXY_LISTEN_HOST` (default `0.0.0.0`).
|
||||
|
||||
Nothing new is opened on the customer's firewall — the relay rides the agent's
|
||||
existing outbound gRPC connection.
|
||||
`docsite/docs/reference/ports-and-networking.md` and
|
||||
`docsite/docs/vantage/browser-console.md` must say so, and must state the new
|
||||
requirement that the agent be online.
|
||||
|
||||
A secondary benefit beyond cloud: a VNC or RDP service bound only to `127.0.0.1`
|
||||
is now reachable, where a direct dial from guacd never could be.
|
||||
|
||||
## Failure modes
|
||||
|
||||
| Failure | Behaviour |
|
||||
| --- | --- |
|
||||
| Agent offline at connect | 409 `agent_offline` from `consoleConnect`, before any WebSocket is opened |
|
||||
| Agent never opens the stream | 10s deadline; listener closed; `console.proxy_failed{reason:"agent_timeout"}`; WebSocket closed with a message the UI surfaces |
|
||||
| Local dial refused (daemon down, wrong port) | `ProxyClose{reason}` relayed up as the same audit event, reason `dial_refused` |
|
||||
| guacd never dials | Same deadline path, reason `guacd_timeout` |
|
||||
| Bad token, unknown or foreign `proxy_id` | Stream closed with no detail leaked; `console.proxy_failed{reason:"rejected"}` |
|
||||
| Agent process dies mid-session | Stream EOF, relay torn down, console shows a disconnect |
|
||||
| CommandStream reconnects mid-session | No effect on live sessions — the relay is on its own stream. Only a new `OpenProxyCmd` needs the control stream |
|
||||
|
||||
Teardown is guarded by `sync.Once` on both sides: both `io.Copy` goroutines
|
||||
finish, and whichever finishes second must not double-close.
|
||||
|
||||
## Testing
|
||||
|
||||
Written test-first.
|
||||
|
||||
- `server/internal/proxy/registry_test.go` — the two halves pair in either
|
||||
order; expiry frees the entry; a second claim on a used `proxy_id` is
|
||||
rejected; a mismatched `instance_id` is rejected. No network.
|
||||
- `server/internal/proxy/session_test.go` — two `net.Pipe` halves; bytes flow
|
||||
both ways; EOF in each direction tears down; double-close is safe.
|
||||
- `server/internal/grpc/proxystream_test.go` — the authentication matrix: valid,
|
||||
wrong token, unknown `proxy_id`, `proxy_id` belonging to another instance.
|
||||
- `agent/internal/proxy` — a refused dial emits `ProxyClose`; the happy path
|
||||
echoes bytes.
|
||||
- End-to-end in `server`: a fake agent plus a `net.Listen` echo server, asserting
|
||||
bytes traverse listener → registry → stream → echo and back. This is the test
|
||||
that would have caught the original bug.
|
||||
|
||||
Manual verification, in this order: self-hosted SSH (proves no regression),
|
||||
cloud SSH to a private-network host, cloud RDP to a Windows agent.
|
||||
@@ -12,7 +12,7 @@ HQ portal.
|
||||
|
||||
A signed file. It carries the instance UUID it belongs to, the tier, the server
|
||||
allowance, feature toggles and an expiry. The control plane verifies the
|
||||
signature locally — checking a licence never contacts HQ, and a running instance
|
||||
signature locally checking a licence never contacts HQ, and a running instance
|
||||
does not need HQ to be reachable.
|
||||
|
||||
Signing happens in exactly one place, in HQ. The control plane can only verify.
|
||||
@@ -40,7 +40,7 @@ bound to that UUID and hands it back.
|
||||
:::info One Free per account, per deployment
|
||||
The limit is enforced per account **and** deployment, so a Free cloud instance
|
||||
does not stop you claiming Free on a self-hosted install. Both the friendly
|
||||
pre-check and the issuer apply the same rule — deliberately, because a
|
||||
pre-check and the issuer apply the same rule deliberately, because a
|
||||
pre-check stricter than the issuer would refuse something that would actually
|
||||
have worked.
|
||||
:::
|
||||
@@ -56,7 +56,7 @@ starts reporting the tier, allowance and expiry.
|
||||
:::warning Cloud instances cannot paste a licence
|
||||
On a cloud instance `POST /license` answers `409 cloud_managed`, and the UI
|
||||
hides the form entirely. A cloud licence is written directly by HQ. This is not
|
||||
a restriction the injection path has to work around — it writes to the database,
|
||||
a restriction the injection path has to work around it writes to the database,
|
||||
not through the endpoint.
|
||||
:::
|
||||
|
||||
@@ -65,7 +65,7 @@ not through the endpoint.
|
||||
Free licences are renewable from HQ within a renewal window near expiry;
|
||||
outside that window the renew call refuses. See [Free tier](../hq/free-tier.md).
|
||||
|
||||
Pasting a licence keeps working while the current one is expired — that endpoint
|
||||
Pasting a licence keeps working while the current one is expired that endpoint
|
||||
is exempt from the licence check, because it is the way out of degraded mode.
|
||||
|
||||
## Moving the install to new hardware
|
||||
|
||||
@@ -9,21 +9,21 @@ who operates it and how licensing, users and data lifecycle work.
|
||||
|
||||
## At a glance
|
||||
|
||||
| | Cloud | Self-hosted |
|
||||
| --- | --- | --- |
|
||||
| Who runs it | We do | You do |
|
||||
| Where you sign in | `<your-slug>.vantage.hostxtra.co.uk` | Your own hostname |
|
||||
| Database and backups | Ours | Yours |
|
||||
| Licence | Written for you when you buy or create the instance | Pasted in, or claimed from HQ |
|
||||
| Team members | Granted from HQ; the instance holds a projection | Created in the instance itself |
|
||||
| Free tier | Yes, one per account | Yes, one per account |
|
||||
| Expired Free instance | Eventually deleted, after warning | Never deleted |
|
||||
| | Cloud | Self-hosted |
|
||||
| --------------------- | --------------------------------------------------- | ------------------------------ |
|
||||
| Who runs it | We do | You do |
|
||||
| Where you sign in | `<your-slug>.vantage.hostxtra.co.uk` | Your own hostname |
|
||||
| Database and backups | Ours | Yours |
|
||||
| Licence | Written for you when you buy or create the instance | Pasted in, or claimed from HQ |
|
||||
| Team members | Granted from HQ; the instance holds a projection | Created in the instance itself |
|
||||
| Free tier | Yes, one per account | Yes, one per account |
|
||||
| Expired Free instance | Eventually deleted, after warning | Never deleted |
|
||||
|
||||
## Cloud
|
||||
|
||||
You create an instance from the HQ portal and it exists a few seconds later,
|
||||
already licensed. People you grant access to get a real user inside that
|
||||
instance — see [People and roles](../hq/people-and-roles.md) — but HQ owns their
|
||||
instance see [People and roles](../hq/people-and-roles.md) but HQ owns their
|
||||
password, role and existence.
|
||||
|
||||
:::info The instance does not phone home
|
||||
@@ -38,33 +38,27 @@ warning emails first. See [Free tier](../hq/free-tier.md).
|
||||
## Self-hosted
|
||||
|
||||
You run the Docker Compose stack on your own infrastructure. Nothing about the
|
||||
control plane requires an internet connection to HQ at runtime — a licence is a
|
||||
control plane requires an internet connection to HQ at runtime a licence is a
|
||||
signed file, verified locally.
|
||||
|
||||
Two ways to get one:
|
||||
|
||||
1. **Free** — link the install to an HQ account and claim it
|
||||
1. **Free** link the install to an HQ account and claim it
|
||||
([Claim a Free licence](./claim-free-licence.md)).
|
||||
2. **Paid** — buy from HQ, which creates a placeholder, then paste the install's
|
||||
2. **Paid** buy from HQ, which creates a placeholder, then paste the install's
|
||||
real instance UUID to bind and issue
|
||||
([Self-hosted instances](../hq/self-hosted-instances.md)).
|
||||
|
||||
Self-hosted users are local (or OIDC). There is no projection from HQ, and the
|
||||
three member endpoints in HQ refuse to touch a self-hosted instance at all.
|
||||
|
||||
:::warning Self-hosted instances are never deleted by us
|
||||
The reaper that removes expired Free cloud instances is disabled by default and
|
||||
must stay that way on a self-hosted install. See `FREE_INSTANCE_REAP_AFTER` in
|
||||
[Environment variables](../reference/environment-variables.md).
|
||||
:::
|
||||
|
||||
## Which should you pick
|
||||
|
||||
Pick cloud if you want the thing running now and do not want to own a MongoDB.
|
||||
Pick self-hosted if your policy requires the control plane inside your own
|
||||
network, or the servers you manage cannot reach the public internet.
|
||||
|
||||
Moving between them is a migration, not a switch — instances are bound to a
|
||||
Moving between them is a migration, not a switch instances are bound to a
|
||||
deployment at creation, and a licence binds to an instance UUID.
|
||||
|
||||
## Next
|
||||
|
||||
@@ -13,15 +13,15 @@ Open the control plane in a browser. Because no user exists, you land on
|
||||
|
||||
Fill in:
|
||||
|
||||
| Field | Notes |
|
||||
| --- | --- |
|
||||
| Organisation name | Display name. Shown throughout the UI |
|
||||
| Slug | Lowercase, used in the hostname on cloud. Some names are reserved |
|
||||
| Your name | |
|
||||
| Email | Becomes your sign-in identity |
|
||||
| Password | Stored bcrypt-hashed |
|
||||
| Field | Notes |
|
||||
| ----------------- | ----------------------------------------------------------------- |
|
||||
| Organisation name | Display name. Shown throughout the UI |
|
||||
| Slug | Lowercase, used in the hostname on cloud. Some names are reserved |
|
||||
| Your name | |
|
||||
| Email | Becomes your sign-in identity |
|
||||
| Password | Stored bcrypt-hashed |
|
||||
|
||||
Submitting creates the organisation and its **owner** — you.
|
||||
Submitting creates the organisation and its **owner** you.
|
||||
|
||||
:::warning Bootstrap works exactly once
|
||||
The endpoint is open only while the database has no users. As soon as the first
|
||||
@@ -43,30 +43,30 @@ nothing else.
|
||||
You land on the fleet dashboard, which is empty. The sidebar is the whole
|
||||
product:
|
||||
|
||||
| Section | What it does |
|
||||
| --- | --- |
|
||||
| Servers | The fleet — enrol, inspect, console, update |
|
||||
| Keys | SSH public keys and their assignments |
|
||||
| Workflows | Compose and run scripted work |
|
||||
| Steps | The reusable step library |
|
||||
| Monitors | HTTP, TCP, ICMP and TLS checks |
|
||||
| Secrets | The encrypted vault |
|
||||
| Audit | Every mutating action |
|
||||
| Settings | Members, SSO, alerts, retention, licence |
|
||||
| Section | What it does |
|
||||
| --------- | ----------------------------------------- |
|
||||
| Servers | The fleet enrol, inspect, console, update |
|
||||
| Keys | SSH public keys and their assignments |
|
||||
| Workflows | Compose and run scripted work |
|
||||
| Steps | The reusable step library |
|
||||
| Monitors | HTTP, TCP, ICMP and TLS checks |
|
||||
| Secrets | The encrypted vault |
|
||||
| Audit | Every mutating action |
|
||||
| Settings | Members, SSO, alerts, retention, licence |
|
||||
|
||||
## 4. Add the rest of your team
|
||||
|
||||
Go to **Settings → Access**. Add members with a role:
|
||||
|
||||
| Role | Can |
|
||||
| --- | --- |
|
||||
| `owner` | Everything, including billing-adjacent settings |
|
||||
| `admin` | Everything except owner-only settings |
|
||||
| `member` | Day-to-day work — servers, keys, workflows, monitors |
|
||||
| Role | Can |
|
||||
| -------- | -------------------------------------------------- |
|
||||
| `owner` | Everything, including billing-adjacent settings |
|
||||
| `admin` | Everything except owner-only settings |
|
||||
| `member` | Day-to-day work servers, keys, workflows, monitors |
|
||||
|
||||
Settings and organisation management require `owner` or `admin`.
|
||||
|
||||
If you would rather not manage passwords, configure OIDC instead — see
|
||||
If you would rather not manage passwords, configure OIDC instead see
|
||||
[Settings](../vantage/settings.md#single-sign-on-oidc). OIDC is configured per
|
||||
organisation, and the client secret is stored encrypted.
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ one-liner.
|
||||
:::warning The token is single-use and lives one hour
|
||||
It is the only credential in the flow, and it is spent the moment the agent
|
||||
calls `Register`. If you paste it somewhere and come back tomorrow, create a new
|
||||
enrolment instead — nothing is lost by doing so.
|
||||
enrolment instead nothing is lost by doing so.
|
||||
:::
|
||||
|
||||
## 2. Run the one-liner
|
||||
@@ -30,7 +30,7 @@ curl -fsSL "https://vantage.example.com/install?server_id=<id>&token=<token>" |
|
||||
|
||||
Run it as root. The script:
|
||||
|
||||
1. Detects architecture — `x86_64` and `aarch64` only; anything else exits.
|
||||
1. Detects architecture `x86_64` and `aarch64` only; anything else exits.
|
||||
2. Asks the Gitea API for the newest `agent/v*` release.
|
||||
3. Downloads the binary and `checksums.txt`, and **verifies the SHA-256**,
|
||||
aborting on a mismatch.
|
||||
@@ -52,14 +52,14 @@ MSI built by CI if you would rather deploy that.
|
||||
|
||||
:::info Windows agents are second-class on purpose
|
||||
They register, heartbeat, run workflow steps and report inventory. They do
|
||||
**not** manage `authorized_keys` — the key subsystem is Linux-only, and a
|
||||
**not** manage `authorized_keys` the key subsystem is Linux-only, and a
|
||||
Windows agent stops after the heartbeat portion of the poll.
|
||||
:::
|
||||
|
||||
## 3. Watch it come up
|
||||
|
||||
The server appears immediately as `pending`. Within one poll interval — 30
|
||||
seconds — it flips to `active`.
|
||||
The server appears immediately as `pending`. Within one poll interval 30
|
||||
seconds it flips to `active`.
|
||||
|
||||
On the machine:
|
||||
|
||||
@@ -72,10 +72,10 @@ What happens on that first run:
|
||||
|
||||
```
|
||||
1. Load /etc/vantage/config.yaml
|
||||
2. pre_reg_token present → Register() → save agent_token, clear pre_reg_token
|
||||
2. pre_reg_token present → register → save agent_token, clear pre_reg_token
|
||||
3. Reconnect with the permanent token
|
||||
4. Start: command stream · hourly update check · inventory · monitors
|
||||
5. Enter the SyncKeys poll loop
|
||||
5. Enter the key poll loop
|
||||
```
|
||||
|
||||
After registration the config no longer contains the pre-registration token; it
|
||||
@@ -87,19 +87,19 @@ SHA-256 of that token, never the token itself.
|
||||
Open the server's detail page. Within a minute or two you should see:
|
||||
|
||||
- Status `active`, with a recent last-seen timestamp.
|
||||
- Inventory — CPU, memory, swap, partitions, kernel. Metrics refresh every 30
|
||||
- Inventory CPU, memory, swap, partitions, kernel. Metrics refresh every 30
|
||||
seconds; the full static snapshot every 15 minutes.
|
||||
- Pending OS updates, checked hourly.
|
||||
|
||||
## If it does not appear
|
||||
|
||||
| Symptom | Cause |
|
||||
| --- | --- |
|
||||
| Script exits at "Unsupported architecture" | Not amd64 or arm64 |
|
||||
| "Checksum mismatch!" | Interrupted download, or a proxy rewriting the body. Re-run |
|
||||
| "Could not determine latest agent version" | The host cannot reach `gitea.hostxtra.co.uk`, or no `agent/v*` release exists |
|
||||
| Service runs, server stays `pending` | The machine cannot reach `GRPC_HOST`. Test it from that machine |
|
||||
| Registers once then goes `offline` | Reachable for `Register` but not for the poll — usually a firewall that permits the initial connection but drops the long-lived one |
|
||||
| Symptom | Cause |
|
||||
| ------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Script exits at "Unsupported architecture" | Not amd64 or arm64 |
|
||||
| "Checksum mismatch!" | Interrupted download, or a proxy rewriting the body. Re-run |
|
||||
| "Could not determine latest agent version" | The host cannot reach `gitea.hostxtra.co.uk`, or no `agent/v*` release exists |
|
||||
| Service runs, server stays `pending` | The machine cannot reach `GRPC_HOST`. Test it from that machine |
|
||||
| Registers once then goes `offline` | Reachable for `Register` but not for the poll usually a firewall that permits the initial connection but drops the long-lived one |
|
||||
|
||||
A server is marked `offline` when its last-seen time passes the threshold; that
|
||||
sweep runs every two minutes, so allow for it before concluding anything.
|
||||
|
||||
@@ -42,7 +42,7 @@ a way you did not intend.
|
||||
Create `/opt/vantage/.env`:
|
||||
|
||||
```bash
|
||||
# The host:port agents dial. NOT the web URL — this port speaks gRPC.
|
||||
# The host:port agents dial. NOT the web URL this port speaks gRPC.
|
||||
GRPC_HOST=vantage.example.com:9090
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@ openssl rand -hex 32
|
||||
:::danger Keep the encryption key
|
||||
`KEY_ENCRYPTION_KEY` encrypts SSH private keys, vault secrets, OIDC client
|
||||
secrets and console credentials with AES-256-GCM. Lose it and every one of those
|
||||
becomes unreadable — there is no recovery path. Back it up somewhere other than
|
||||
becomes unreadable there is no recovery path. Back it up somewhere other than
|
||||
the server it protects, and never rotate it without a planned re-encryption.
|
||||
:::
|
||||
|
||||
@@ -109,12 +109,12 @@ Continue with [First login](./first-login.md).
|
||||
|
||||
## Verifying the install
|
||||
|
||||
| Check | Expected |
|
||||
| --- | --- |
|
||||
| `docker compose ps` | five services `running` |
|
||||
| Check | Expected |
|
||||
| ---------------------------------------------- | ------------------------------- |
|
||||
| `docker compose ps` | five services `running` |
|
||||
| `curl -s localhost:8080/auth/bootstrap-status` | JSON saying bootstrap is needed |
|
||||
| `nc -z your-host 9090` | open |
|
||||
| `docker compose logs server \| grep -i fatal` | nothing |
|
||||
| `nc -z your-host 9090` | open |
|
||||
| `docker compose logs server \| grep -i fatal` | nothing |
|
||||
|
||||
## Common install problems
|
||||
|
||||
@@ -131,7 +131,6 @@ More in [Troubleshooting](../reference/troubleshooting.md).
|
||||
|
||||
## What this install does not include
|
||||
|
||||
The marketing site, the public form service, the HQ portal and this
|
||||
documentation site are separate services in `deploy/docker-compose.site.yml`.
|
||||
A self-hosted install deliberately runs none of them, and in particular never
|
||||
holds the licence signing key.
|
||||
The website, the HQ portal and this documentation site are hosted by us and are
|
||||
not part of a self-hosted install. It deliberately runs none of them, and in
|
||||
particular never holds the licence signing key.
|
||||
|
||||
@@ -27,8 +27,8 @@ stores everything durable; Redis stores sessions and nothing else.
|
||||
|
||||
**The agent** is a single Go binary running as root on each managed server. It
|
||||
polls the control plane every 30 seconds for desired key state, and holds a
|
||||
bidirectional command stream so the server can push work — run a workflow step,
|
||||
generate a key, apply updates — without waiting for the next poll.
|
||||
bidirectional command stream so the server can push work run a workflow step,
|
||||
generate a key, apply updates without waiting for the next poll.
|
||||
|
||||
**The web UI** is the operator interface. Everything it does goes through the
|
||||
REST API, which is the actual security boundary; the UI only ever makes things
|
||||
@@ -46,18 +46,18 @@ could guess on its behalf.
|
||||
|
||||
## Two request patterns
|
||||
|
||||
| Pattern | Used for | Why |
|
||||
| --- | --- | --- |
|
||||
| Poll (`SyncKeys`, every 30s) | desired SSH key state | Key changes are not urgent, and polling survives a dropped connection with no reconnection logic |
|
||||
| Push (`CommandStream`) | workflow steps, key generation, updates, agent self-update | Clicking Run should not wait up to 30 seconds |
|
||||
| Pattern | Used for | Why |
|
||||
| ----------------------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
|
||||
| Poll, every 30s | desired SSH key state | Key changes are not urgent, and polling survives a dropped connection with no reconnection logic |
|
||||
| Push, over the command stream | workflow steps, key generation, updates, agent self-update | Clicking Run should not wait up to 30 seconds |
|
||||
|
||||
## Multi-tenancy
|
||||
|
||||
Every document in the database carries an instance ID, and every query is scoped
|
||||
by it. One deployment can therefore host many independent tenants. On a
|
||||
self-hosted install that mechanism is still there — you simply have one tenant.
|
||||
self-hosted install that mechanism is still there you simply have one tenant.
|
||||
|
||||
## Next
|
||||
|
||||
- [Cloud or self-hosted](./cloud-vs-self-hosted.md) — which one you want
|
||||
- [Self-hosted install](./self-hosted-install.md) — stand it up
|
||||
- [Cloud or self-hosted](./cloud-vs-self-hosted.md) which one you want
|
||||
- [Self-hosted install](./self-hosted-install.md) stand it up
|
||||
|
||||
@@ -12,17 +12,17 @@ behind your instances: your team, your instances, their licences and billing.
|
||||
One account holds many people and many instances. Everyone in it has an account
|
||||
role:
|
||||
|
||||
| Role | Can |
|
||||
| --- | --- |
|
||||
| `owner` | Everything, including billing |
|
||||
| `admin` | Invite people, create instances, grant instance access |
|
||||
| `member` | Read what the account holds |
|
||||
| Role | Can |
|
||||
| -------- | ------------------------------------------------------ |
|
||||
| `owner` | Everything, including billing |
|
||||
| `admin` | Invite people, create instances, grant instance access |
|
||||
| `member` | Read what the account holds |
|
||||
|
||||
Reading is open to any signed-in member. Every mutation except changing your own
|
||||
password requires `owner` or `admin`. Billing is owner-only.
|
||||
|
||||
The three words are the same as the control plane's roles, on purpose — but they
|
||||
are separate things. Your account role governs the portal; your role *inside* an
|
||||
The three words are the same as the control plane's roles, on purpose but they
|
||||
are separate things. Your account role governs the portal; your role _inside_ an
|
||||
instance governs that instance.
|
||||
|
||||
## Signing up
|
||||
@@ -37,7 +37,7 @@ you later create or link one.
|
||||
|
||||
:::info Verify before you can sign in
|
||||
An unverified account gets a distinct "check your email" message rather than a
|
||||
generic authentication failure — the address is already known to be yours, so
|
||||
generic authentication failure the address is already known to be yours, so
|
||||
there is nothing to protect by being vague.
|
||||
:::
|
||||
|
||||
@@ -45,7 +45,7 @@ Verification links are valid for **24 hours**. The token is 32 random bytes and
|
||||
only its SHA-256 hash is stored, so a leaked database yields no working links.
|
||||
|
||||
If the verification email cannot be sent, the signup is rolled back rather than
|
||||
left stranded — retry rather than assuming a half-created account is in the way.
|
||||
left stranded retry rather than assuming a half-created account is in the way.
|
||||
|
||||
## Signing in
|
||||
|
||||
@@ -54,11 +54,11 @@ signing in to HQ does not sign you in to an instance, and vice versa.
|
||||
|
||||
## What comes next
|
||||
|
||||
| You want | Go to |
|
||||
| --- | --- |
|
||||
| A Vantage instance we run | [Cloud instances](./cloud-instances.md) |
|
||||
| To license an install you run | [Self-hosted instances](./self-hosted-instances.md) |
|
||||
| To add colleagues | [People and roles](./people-and-roles.md) |
|
||||
| You want | Go to |
|
||||
| ------------------------------ | ------------------------------------------------------------- |
|
||||
| A Vantage instance we run | [Cloud instances](./cloud-instances.md) |
|
||||
| To license an install you run | [Self-hosted instances](./self-hosted-instances.md) |
|
||||
| To add colleagues | [People and roles](./people-and-roles.md) |
|
||||
| To understand tiers and limits | [Licensing and entitlements](./licensing-and-entitlements.md) |
|
||||
|
||||
## The portal layout
|
||||
|
||||
@@ -52,8 +52,8 @@ flowchart LR
|
||||
```
|
||||
|
||||
The webhook is the **only** issuing path for paid plans. It is signature
|
||||
verified, processed exactly once, and resolved from the subscription's *current*
|
||||
line items — so a webhook that arrives out of order still produces the right
|
||||
verified, processed exactly once, and resolved from the subscription's _current_
|
||||
line items so a webhook that arrives out of order still produces the right
|
||||
answer rather than replaying a stale state.
|
||||
|
||||
A licence is signed from **granted** only. A checkout you abandon changes
|
||||
@@ -65,7 +65,7 @@ Cancelling, or a payment going past due, takes **no immediate licence action**.
|
||||
Your licence runs to its grace-padded expiry and then lapses normally. There is
|
||||
no mid-term cut-off.
|
||||
|
||||
For a cloud Free instance, lapsing eventually leads to deletion — see
|
||||
For a cloud Free instance, lapsing eventually leads to deletion see
|
||||
[Free tier](./free-tier.md). Paid instances are not reaped.
|
||||
|
||||
## Renewals
|
||||
|
||||
@@ -15,7 +15,7 @@ A cloud instance is a Vantage control plane we run for you, reachable at
|
||||
|
||||
The instance is provisioned with you as its owner, and a Free licence is issued
|
||||
immediately. The owner user inside it gets your HQ password hash **copied**, not
|
||||
shared — see [People and roles](./people-and-roles.md).
|
||||
shared see [People and roles](./people-and-roles.md).
|
||||
|
||||
### Slugs
|
||||
|
||||
@@ -24,7 +24,7 @@ reserved. Pick something you can say on a phone call.
|
||||
|
||||
:::warning One Free instance per account, per deployment
|
||||
Creating a second Free cloud instance is refused. If you want another, it needs
|
||||
a paid plan — or free up the first.
|
||||
a paid plan or free up the first.
|
||||
:::
|
||||
|
||||
## Using it
|
||||
@@ -41,9 +41,9 @@ affect anyone signing in or any agent syncing.
|
||||
|
||||
Each instance on Overview is one record. Closed, it is a row. Open, it shows:
|
||||
|
||||
- **Licence contents** — tier, server allowance, features, expiry.
|
||||
- **Members** — who has access and with what instance role.
|
||||
- **Actions** — grant access, change configuration, renew.
|
||||
- **Licence contents** tier, server allowance, features, expiry.
|
||||
- **Members** who has access and with what instance role.
|
||||
- **Actions** grant access, change configuration, renew.
|
||||
|
||||
## Members
|
||||
|
||||
@@ -53,14 +53,14 @@ Granting access writes a real user into the instance. Covered fully in
|
||||
## Changing what it can do
|
||||
|
||||
Server allowance and per-instance features (browser console, single sign-on) are
|
||||
part of the instance's **entitlement**. Changing it goes through billing — see
|
||||
part of the instance's **entitlement**. Changing it goes through billing see
|
||||
[Licensing and entitlements](./licensing-and-entitlements.md) and
|
||||
[Billing](./billing.md).
|
||||
|
||||
## Renaming
|
||||
|
||||
The display name is free to change. The slug is the hostname and is not
|
||||
casually changed — ask support if you need it.
|
||||
casually changed ask support if you need it.
|
||||
|
||||
## What happens if the licence lapses
|
||||
|
||||
@@ -73,6 +73,6 @@ runs to its grace-padded expiry and then lapses.
|
||||
|
||||
## Deleting
|
||||
|
||||
Ask support. Deletion is performed by the control plane, not by HQ — the control
|
||||
Ask support. Deletion is performed by the control plane, not by HQ the control
|
||||
plane is the only service that knows which collections carry the instance ID,
|
||||
and duplicating that list into HQ would be a list that drifts.
|
||||
|
||||
@@ -4,18 +4,18 @@ title: Free tier
|
||||
sidebar_label: Free tier
|
||||
---
|
||||
|
||||
Free is a real tier in both deployments — not a trial that turns into nothing.
|
||||
Free is a real tier in both deployments not a trial that turns into nothing.
|
||||
|
||||
## What you get
|
||||
|
||||
| | Free |
|
||||
| --- | --- |
|
||||
| Servers | 3 |
|
||||
| Monitors | 3 |
|
||||
| Secret groups | 1 |
|
||||
| Notification channels | 1 |
|
||||
| Audit retention | 30 days |
|
||||
| Support | Community |
|
||||
| | Free |
|
||||
| --------------------- | --------- |
|
||||
| Servers | 3 |
|
||||
| Monitors | 3 |
|
||||
| Secret groups | 1 |
|
||||
| Notification channels | 1 |
|
||||
| Audit retention | 30 days |
|
||||
| Support | Community |
|
||||
|
||||
Browser console and single sign-on are not included; they are per-instance
|
||||
features on a paid plan.
|
||||
@@ -23,7 +23,7 @@ features on a paid plan.
|
||||
## One per account, per deployment
|
||||
|
||||
The limit is enforced per account **and** deployment. A Free cloud instance does
|
||||
not prevent a Free self-hosted one — they are separate slots.
|
||||
not prevent a Free self-hosted one they are separate slots.
|
||||
|
||||
## Free is outside Paddle
|
||||
|
||||
@@ -49,8 +49,7 @@ cloud instance can lapse and eventually be deleted without anyone noticing.
|
||||
## What happens when it lapses
|
||||
|
||||
**Self-hosted:** the instance goes into degraded mode after the grace period and
|
||||
stays that way. Nothing is deleted, ever — the reaper is disabled by default on
|
||||
a self-hosted install and must stay that way.
|
||||
stays that way. Nothing is deleted, ever.
|
||||
|
||||
**Cloud:** the instance goes into degraded mode, and after a further period the
|
||||
instance **and all its data are deleted**. Warning emails are sent first, naming
|
||||
@@ -69,7 +68,7 @@ instance ID.
|
||||
## Moving off Free
|
||||
|
||||
Change the instance's configuration to a paid tier and check out. Your data
|
||||
stays where it is — a tier change reissues a licence, it does not rebuild
|
||||
stays where it is a tier change reissues a licence, it does not rebuild
|
||||
anything.
|
||||
|
||||
## Relinks
|
||||
|
||||
@@ -10,30 +10,30 @@ A **licence** is a signed statement of what one instance may do. An
|
||||
## Tiers
|
||||
|
||||
Three tiers, in both deployments. The allowances are identical across cloud and
|
||||
self-hosted — what differs is the term on offer, not what you get.
|
||||
self-hosted what differs is the term on offer, not what you get.
|
||||
|
||||
| | Free | Professional | Enterprise |
|
||||
| --- | --- | --- | --- |
|
||||
| Servers (base) | 3 | 3 | 10 |
|
||||
| Monitors | 3 | unlimited | unlimited |
|
||||
| Secret groups | 1 | unlimited | unlimited |
|
||||
| Notification channels | 1 | unlimited | unlimited |
|
||||
| Audit retention | 30 days | 365 days | unlimited |
|
||||
| Support | Community | Email, 24×5 | Email and phone, 24×7 |
|
||||
| | Free | Professional | Enterprise |
|
||||
| --------------------- | --------- | ------------ | --------------------- |
|
||||
| Servers (base) | 3 | 3 | 10 |
|
||||
| Monitors | 3 | unlimited | unlimited |
|
||||
| Secret groups | 1 | unlimited | unlimited |
|
||||
| Notification channels | 1 | unlimited | unlimited |
|
||||
| Audit retention | 30 days | 365 days | unlimited |
|
||||
| Support | Community | Email, 24×5 | Email and phone, 24×7 |
|
||||
|
||||
The server count is **metered**: the base allowance comes with the tier, and you
|
||||
buy additional servers on top. That is why Professional shows a real number
|
||||
rather than "unlimited" — the number you actually have is the one in your
|
||||
rather than "unlimited" the number you actually have is the one in your
|
||||
entitlement.
|
||||
|
||||
## Features
|
||||
|
||||
Two are per-instance toggles rather than tier bundles:
|
||||
|
||||
| Feature | What it enables |
|
||||
| --- | --- |
|
||||
| `console` | The [browser console](../vantage/browser-console.md) |
|
||||
| `oidc` | Per-instance [single sign-on](../vantage/settings.md#single-sign-on-oidc) |
|
||||
| Feature | What it enables |
|
||||
| --------- | ------------------------------------------------------------------------- |
|
||||
| `console` | The [browser console](../vantage/browser-console.md) |
|
||||
| `oidc` | Per-instance [single sign-on](../vantage/settings.md#single-sign-on-oidc) |
|
||||
|
||||
No tier includes them by default; you enable them on the instances that need
|
||||
them.
|
||||
@@ -42,9 +42,9 @@ them.
|
||||
|
||||
Each instance has one entitlement row holding two configurations:
|
||||
|
||||
| | Meaning |
|
||||
| --- | --- |
|
||||
| **Desired** | What you last asked for |
|
||||
| | Meaning |
|
||||
| ----------- | ---------------------------- |
|
||||
| **Desired** | What you last asked for |
|
||||
| **Granted** | What a payment has confirmed |
|
||||
|
||||
Checkout is built from **desired**. A licence is only ever signed from
|
||||
@@ -80,7 +80,7 @@ signing happens only in HQ.
|
||||
Expiry is padded with a grace period. Past that, the instance goes into degraded
|
||||
mode: it keeps running and keeps your data, but stops letting you do everything.
|
||||
|
||||
The way out is a current licence — renew or purchase, then paste it (self-hosted)
|
||||
The way out is a current licence renew or purchase, then paste it (self-hosted)
|
||||
or let it be written for you (cloud).
|
||||
|
||||
## Server limits in practice
|
||||
|
||||
@@ -11,11 +11,11 @@ each **instance**.
|
||||
|
||||
**People** lists everyone in the account.
|
||||
|
||||
| Role | Can |
|
||||
| --- | --- |
|
||||
| `owner` | Everything, including billing |
|
||||
| `admin` | Invite, create instances, grant instance access |
|
||||
| `member` | Read |
|
||||
| Role | Can |
|
||||
| -------- | ----------------------------------------------- |
|
||||
| `owner` | Everything, including billing |
|
||||
| `admin` | Invite, create instances, grant instance access |
|
||||
| `member` | Read |
|
||||
|
||||
Owners and admins invite; billing is owner-only.
|
||||
|
||||
@@ -53,7 +53,7 @@ flowchart LR
|
||||
```
|
||||
|
||||
The instance authenticates that user exactly as it authenticates anyone else,
|
||||
with **no runtime dependency on HQ**. Revoking deletes the row — the control
|
||||
with **no runtime dependency on HQ**. Revoking deletes the row the control
|
||||
plane has no disabled state, and a row that exists is a row that can sign in.
|
||||
|
||||
### Granting
|
||||
|
||||
@@ -52,7 +52,7 @@ Rebuilding the host produces a new instance UUID, and the old licence no longer
|
||||
matches. **Relink** moves the licence to the new UUID and reissues.
|
||||
|
||||
The number of relinks per term is capped, and the portal shows how many you have
|
||||
left. This is not meant to obstruct disaster recovery — if you have exhausted
|
||||
left. This is not meant to obstruct disaster recovery if you have exhausted
|
||||
them for a real reason, ask support.
|
||||
|
||||
## Installing the licence
|
||||
@@ -60,13 +60,13 @@ them for a real reason, ask support.
|
||||
Paste it at **Settings → Licence** in your install. The instance verifies the
|
||||
signature and checks that the UUID matches its own.
|
||||
|
||||
Pasting works even while the current licence is expired — that endpoint is
|
||||
Pasting works even while the current licence is expired that endpoint is
|
||||
exempt from the licence check, because it is the route out of degraded mode.
|
||||
|
||||
## Keeping it current
|
||||
|
||||
Your install does not fetch licences. When a licence is reissued — renewal,
|
||||
configuration change, relink — download the new one from HQ and paste it in.
|
||||
Your install does not fetch licences. When a licence is reissued renewal,
|
||||
configuration change, relink download the new one from HQ and paste it in.
|
||||
|
||||
:::warning Nothing reminds your install
|
||||
The control plane knows only what its licence says. Expiry emails come from HQ,
|
||||
|
||||
@@ -17,17 +17,17 @@ firewall holes.
|
||||
|
||||
## Where to start
|
||||
|
||||
| If you want to | Read |
|
||||
| --- | --- |
|
||||
| Understand what the pieces are | [What is Vantage](./getting-started/what-is-vantage.md) |
|
||||
| Run it on your own hardware | [Self-hosted install](./getting-started/self-hosted-install.md) |
|
||||
| Enrol your first machine | [Add your first server](./getting-started/first-server.md) |
|
||||
| Manage your account, licence or billing | [Vantage HQ](./hq/accounts-and-signup.md) |
|
||||
| Look something up | [Reference](./reference/environment-variables.md) |
|
||||
| If you want to | Read |
|
||||
| --------------------------------------- | --------------------------------------------------------------- |
|
||||
| Understand what the pieces are | [What is Vantage](./getting-started/what-is-vantage.md) |
|
||||
| Run it on your own hardware | [Self-hosted install](./getting-started/self-hosted-install.md) |
|
||||
| Enrol your first machine | [Add your first server](./getting-started/first-server.md) |
|
||||
| Manage your account, licence or billing | [Vantage HQ](./hq/accounts-and-signup.md) |
|
||||
| Look something up | [Reference](./reference/environment-variables.md) |
|
||||
|
||||
## The two products
|
||||
|
||||
**Vantage** is the control plane — the thing you sign in to in order to manage
|
||||
**Vantage** is the control plane the thing you sign in to in order to manage
|
||||
servers. It runs either on your own infrastructure or as a cloud instance we
|
||||
run for you.
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ Each server's detail page shows the version it reported at its last sync.
|
||||
|
||||
## Updating from the UI
|
||||
|
||||
**Servers → *a server* → Update agent** pushes `UpdateAgentCmd` with a target
|
||||
**Servers → _a server_ → Update agent** pushes `UpdateAgentCmd` with a target
|
||||
version. The agent then:
|
||||
|
||||
1. Downloads the binary for its platform from the release.
|
||||
@@ -38,7 +38,7 @@ irm https://vantage.example.com/update.ps1 | iex
|
||||
```
|
||||
|
||||
It does the same download, checksum and replace, then restarts the service. Use
|
||||
this when the control plane cannot push — for example, when the machine is
|
||||
this when the control plane cannot push for example, when the machine is
|
||||
reachable but its command stream is not.
|
||||
|
||||
## Rolling out across a fleet
|
||||
@@ -57,20 +57,20 @@ Do one, confirm it returns to `active`, then do the rest.
|
||||
|
||||
## Version compatibility
|
||||
|
||||
The gRPC API is versioned to tolerate an agent older than the control plane. The
|
||||
reverse — an agent newer than the control plane — is not a case anyone tests.
|
||||
The agent API is versioned to tolerate an agent older than the control plane. The
|
||||
reverse an agent newer than the control plane is not a case anyone tests.
|
||||
Upgrade the control plane first.
|
||||
|
||||
Agents report their version on every `SyncKeys`, so a fleet running mixed
|
||||
Agents report their version on every poll, so a fleet running mixed
|
||||
versions is visible in the server list rather than something you have to go
|
||||
looking for.
|
||||
|
||||
## If an update fails
|
||||
|
||||
| Symptom | Cause |
|
||||
| --- | --- |
|
||||
| "Checksum mismatch" | Interrupted download, or a proxy rewriting the body. Retry |
|
||||
| Downloads nothing | The machine cannot reach `gitea.hostxtra.co.uk` |
|
||||
| Symptom | Cause |
|
||||
| --------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| "Checksum mismatch" | Interrupted download, or a proxy rewriting the body. Retry |
|
||||
| Downloads nothing | The machine cannot reach `gitea.hostxtra.co.uk` |
|
||||
| Service will not start afterwards | Wrong architecture binary, or the file was replaced while a different service manager held it. Reinstall with the install one-liner |
|
||||
|
||||
Reinstalling is always safe: the config file is left alone, so the agent comes
|
||||
|
||||
@@ -9,12 +9,12 @@ either one restores to something unusable.
|
||||
|
||||
## What holds what
|
||||
|
||||
| Store | Contents | Back up |
|
||||
| --- | --- | --- |
|
||||
| MongoDB | Everything durable — servers, keys, assignments, workflows, runs, monitors, incidents, secrets, settings, audit | **Yes** |
|
||||
| Redis | Sessions only | No. Losing it signs everyone out and nothing else |
|
||||
| `./data` bind mount | Workflow run logs | Optional |
|
||||
| `KEY_ENCRYPTION_KEY` | Not stored anywhere by the app | **Yes, separately** |
|
||||
| Store | Contents | Back up |
|
||||
| -------------------- | ------------------------------------------------------------------------------------------------------------- | ------------------------------------------------- |
|
||||
| MongoDB | Everything durable servers, keys, assignments, workflows, runs, monitors, incidents, secrets, settings, audit | **Yes** |
|
||||
| Redis | Sessions only | No. Losing it signs everyone out and nothing else |
|
||||
| `./data` bind mount | Workflow run logs | Optional |
|
||||
| `KEY_ENCRYPTION_KEY` | Not stored anywhere by the app | **Yes, separately** |
|
||||
|
||||
:::danger The database alone is not a backup
|
||||
Private keys, vault secrets, OIDC client secrets and console credentials are
|
||||
@@ -50,7 +50,7 @@ nothing writes during the restore.
|
||||
cp /opt/vantage/.env /secure-location/vantage.env
|
||||
```
|
||||
|
||||
Treat it as a credential in its own right — it holds the encryption key.
|
||||
Treat it as a credential in its own right it holds the encryption key.
|
||||
|
||||
## Run logs
|
||||
|
||||
@@ -69,17 +69,17 @@ What it does **not** do is reconcile the world. After a restore:
|
||||
- Agents reconnect with their existing tokens, since the token hashes are in the
|
||||
database.
|
||||
- If the restore is older than an enrolment, that server's token hash is missing
|
||||
and the agent will fail to authenticate — re-enrol it.
|
||||
and the agent will fail to authenticate re-enrol it.
|
||||
- The next agent poll rewrites `authorized_keys` to match the restored desired
|
||||
state, which may remove keys added since the backup.
|
||||
|
||||
## A workable schedule
|
||||
|
||||
| What | When |
|
||||
| --- | --- |
|
||||
| MongoDB dump | Nightly, retained per your policy |
|
||||
| Environment file | On change, held in a password manager or secret store |
|
||||
| Restore rehearsal | Occasionally, into a throwaway host |
|
||||
| What | When |
|
||||
| ----------------- | ----------------------------------------------------- |
|
||||
| MongoDB dump | Nightly, retained per your policy |
|
||||
| Environment file | On change, held in a password manager or secret store |
|
||||
| Restore rehearsal | Occasionally, into a throwaway host |
|
||||
|
||||
The rehearsal is the part that gets skipped and the part that finds the
|
||||
problems.
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
---
|
||||
id: ci-cd
|
||||
title: CI/CD
|
||||
sidebar_label: CI/CD
|
||||
---
|
||||
|
||||
Two Gitea Actions workflows: one releases agents, one builds images.
|
||||
|
||||
## Agent releases
|
||||
|
||||
Triggered by an `agent/v*` tag.
|
||||
|
||||
```bash
|
||||
git tag agent/v1.0.0 && git push origin agent/v1.0.0
|
||||
```
|
||||
|
||||
Builds `linux/amd64`, `linux/arm64` and `windows/amd64`, writes `checksums.txt`
|
||||
and creates a Gitea release. A second job on Windows packages the WiX MSI.
|
||||
|
||||
The install and update scripts read the newest `agent/v*` release from the Gitea
|
||||
API, so tagging is what makes a new agent available to every install.
|
||||
|
||||
## Image builds
|
||||
|
||||
Triggered on every push to `main`. Builds and pushes seven images: `server`,
|
||||
`web`, `site`, `sitesvc`, `admin`, `adminsite` and `docsite`.
|
||||
|
||||
:::warning Despite the name, this workflow does not deploy
|
||||
There is no SSH step. Rolling images out is a manual step on the host:
|
||||
|
||||
```bash
|
||||
cd /opt/vantage && \
|
||||
docker compose -f docker-compose.yml -f docker-compose.site.yml pull && \
|
||||
docker compose -f docker-compose.yml -f docker-compose.site.yml up -d --remove-orphans
|
||||
```
|
||||
:::
|
||||
|
||||
### Each image rebuilds only when its own inputs change
|
||||
|
||||
A `git diff` against the previous head decides. That is why the checkout uses
|
||||
`fetch-depth: 0` — a shallow clone has one commit and nothing to diff against.
|
||||
|
||||
| Image | Rebuilds when |
|
||||
| --- | --- |
|
||||
| `server` | `server/`, `shared/`, `proto/`, `go.work` |
|
||||
| `admin` | `admin/`, `shared/`, `go.work` |
|
||||
| `sitesvc` | `sitesvc/`, `shared/`, `go.work` |
|
||||
| `web` · `site` · `adminsite` · `docsite` | their own directory only |
|
||||
|
||||
`shared/` fans out to all three Go images because each of their Dockerfiles
|
||||
copies it from a root context. **If a fourth service ever imports `shared/`, it
|
||||
must be added to that list or it will ship stale.**
|
||||
|
||||
Everything rebuilds when there is no trustworthy base commit to diff against: a
|
||||
manual `workflow_dispatch`, a new branch, or a force-push whose old head is
|
||||
gone. Changing the workflow file itself also rebuilds everything, since a build
|
||||
argument is baked into each image.
|
||||
|
||||
### The gap: repository variables
|
||||
|
||||
:::danger Editing a repository variable pushes no commit, so nothing rebuilds
|
||||
Values like `ADMIN_API_URL`, `HQ_URL`, `ADMIN_ENV`, `PADDLE_ENV`,
|
||||
`PADDLE_CLIENT_TOKEN`, `DOCS_URL` and `DOCS_BASE_URL` are baked into images at
|
||||
build time. After editing one, run the workflow manually — that is what
|
||||
`workflow_dispatch` is for.
|
||||
|
||||
The symptom is a frontend that keeps using the old value with no error anywhere,
|
||||
which is a long afternoon if you do not know about this.
|
||||
:::
|
||||
|
||||
The same applies to base images: a service nobody touches stops being rebuilt on
|
||||
newer base layers. A periodic manual run covers it.
|
||||
|
||||
## Secrets and variables
|
||||
|
||||
| Name | Type | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `RELEASE_TOKEN` | Secret | Gitea API token, `write:release` |
|
||||
| `REGISTRY_USER` / `REGISTRY_PASSWORD` | Secret | Registry push credentials |
|
||||
| `PADDLE_API_KEY` | Secret | Read by admin at runtime |
|
||||
| `PADDLE_WEBHOOK_SECRET` | Secret | Webhook signature verification |
|
||||
| `GITEA_HOST` / `DOCKER_HOST` | Variable | Hosts used in tags and URLs |
|
||||
| `HQ_URL` | Variable | Baked into `web`; empty on self-hosted |
|
||||
| `SITE_API_URL` / `SITE_CONTACT_EMAIL` | Variable | Baked into `site` |
|
||||
| `ADMIN_API_URL` | Variable | Baked into `adminsite` **and** `site` |
|
||||
| `ADMIN_ENV` | Variable | Environment badge in the portal |
|
||||
| `PADDLE_ENV` | Variable | Baked into `adminsite`, read by `admin`. Must match on both sides |
|
||||
| `PADDLE_CLIENT_TOKEN` | Variable | Browser Paddle token for checkout |
|
||||
| `DOCS_URL` / `DOCS_BASE_URL` | Variable | Baked into `docsite` |
|
||||
|
||||
Anything marked "browser-reachable" must be an origin a browser can actually
|
||||
resolve — not an internal service name. Get it wrong and every request fails at
|
||||
runtime with a not-connected panel rather than at build time.
|
||||
|
||||
## The documentation site
|
||||
|
||||
`docsite/` builds to static files and is served by nginx under `/docs` on the
|
||||
marketing host, routed by its own proxy location.
|
||||
|
||||
`DOCS_BASE_URL` has to agree with three things at once: that proxy location, the
|
||||
directory the runtime image serves from, and the value baked into the build. When
|
||||
they disagree the page loads and every stylesheet and script 404s.
|
||||
@@ -5,7 +5,12 @@ sidebar_label: Upgrading
|
||||
---
|
||||
|
||||
Upgrading the control plane is a pull and a recreate. Agents are versioned and
|
||||
upgraded separately — see [Agent updates](./agent-updates.md).
|
||||
upgraded separately see [Agent updates](./agent-updates.md).
|
||||
|
||||
:::info Cloud instances upgrade themselves
|
||||
This page is for self-hosted installs. If your instance is hosted by us, there
|
||||
is nothing here for you to do.
|
||||
:::
|
||||
|
||||
## Upgrade
|
||||
|
||||
@@ -25,7 +30,7 @@ renamed or removed.
|
||||
2. **Indexes** are ensured. Auth and settings index builders are fatal on
|
||||
failure; secret and workflow ones only warn.
|
||||
3. **Default steps** are reseeded from the image, overwriting the `default`
|
||||
library — which is why those steps are read-only.
|
||||
library which is why those steps are read-only.
|
||||
|
||||
Watch it:
|
||||
|
||||
@@ -52,28 +57,15 @@ is the reason the backup is not optional.
|
||||
The stack is not designed for it. `docker compose up -d` recreates the server
|
||||
container, which is a short interruption:
|
||||
|
||||
- Agents reconnect on their own — they retry, and the poll loop is idempotent.
|
||||
- Agents reconnect on their own they retry, and the poll loop is idempotent.
|
||||
- Workflow runs in progress lose their command stream. Steps already dispatched
|
||||
finish on the agent, but their results have nowhere to go. **Do not upgrade
|
||||
during a run.**
|
||||
- Sessions survive, because they live in Redis rather than in the server.
|
||||
|
||||
## Upgrading the hosted deployment
|
||||
|
||||
Both Compose files, together:
|
||||
|
||||
```bash
|
||||
cd /opt/vantage
|
||||
docker compose -f docker-compose.yml -f docker-compose.site.yml pull
|
||||
docker compose -f docker-compose.yml -f docker-compose.site.yml up -d --remove-orphans
|
||||
```
|
||||
|
||||
CI builds and pushes images but does **not** deploy them; rolling out is this
|
||||
manual step. See [CI/CD](./ci-cd.md).
|
||||
|
||||
## After upgrading
|
||||
|
||||
- Confirm every service is `running`.
|
||||
- Confirm servers return to `active` within a couple of poll intervals.
|
||||
- Open a page that touches encryption — a secret group — to confirm
|
||||
- Open a page that touches encryption a secret group to confirm
|
||||
`KEY_ENCRYPTION_KEY` came through.
|
||||
|
||||
@@ -8,10 +8,10 @@ The agent reads no environment variables. Everything is in one YAML file.
|
||||
|
||||
## Location
|
||||
|
||||
| Platform | Path |
|
||||
| --- | --- |
|
||||
| Linux | `/etc/vantage/config.yaml` |
|
||||
| Windows | `%ProgramData%\vantage\config.yaml` |
|
||||
| Platform | Path |
|
||||
| -------- | ----------------------------------- |
|
||||
| Linux | `/etc/vantage/config.yaml` |
|
||||
| Windows | `%ProgramData%\vantage\config.yaml` |
|
||||
|
||||
Directory `0700`, file `0600`. The install script sets both.
|
||||
|
||||
@@ -26,17 +26,17 @@ poll_interval: 30s
|
||||
tls: true
|
||||
```
|
||||
|
||||
| Field | Meaning |
|
||||
| --- | --- |
|
||||
| `server_url` | `host:port` of the gRPC endpoint. Comes from the server's `GRPC_HOST` |
|
||||
| `server_id` | The identity issued when the enrolment was created |
|
||||
| `pre_reg_token` | Single-use, one hour. Cleared once registration succeeds |
|
||||
| `agent_token` | The permanent credential, written by the agent itself |
|
||||
| `poll_interval` | How often `SyncKeys` runs. Default `30s` |
|
||||
| `tls` | Whether to use TLS. Leave `true` |
|
||||
| Field | Meaning |
|
||||
| --------------- | --------------------------------------------------------------------- |
|
||||
| `server_url` | `host:port` of the gRPC endpoint. Comes from the server's `GRPC_HOST` |
|
||||
| `server_id` | The identity issued when the enrolment was created |
|
||||
| `pre_reg_token` | Single-use, one hour. Cleared once registration succeeds |
|
||||
| `agent_token` | The permanent credential, written by the agent itself |
|
||||
| `poll_interval` | How often the agent polls for key state. Default `30s` |
|
||||
| `tls` | Whether to use TLS. Leave `true` |
|
||||
|
||||
:::danger This file is the credential
|
||||
`agent_token` is plaintext here and nowhere else — the control plane holds only
|
||||
`agent_token` is plaintext here and nowhere else the control plane holds only
|
||||
its SHA-256. Anyone who can read this file can act as this agent. That is why
|
||||
it is `0600` and the directory is `0700`.
|
||||
:::
|
||||
@@ -45,21 +45,21 @@ it is `0600` and the directory is `0700`.
|
||||
|
||||
```
|
||||
1. Load the config
|
||||
2. pre_reg_token present → Register() → save agent_token,
|
||||
2. pre_reg_token present → register → save agent_token,
|
||||
clear pre_reg_token, reconnect
|
||||
3. Start goroutines: command stream · hourly update check ·
|
||||
inventory · monitors
|
||||
4. Enter the SyncKeys poll loop
|
||||
3. Start: command stream · hourly update check · inventory · monitors
|
||||
4. Enter the key poll loop
|
||||
```
|
||||
|
||||
## The poll loop
|
||||
|
||||
```
|
||||
1. SyncKeys(server_id, agent_token, agent_version)
|
||||
2. Non-Linux hosts stop here — Windows agents register and heartbeat only
|
||||
1. Ask the control plane for the desired key state, reporting the
|
||||
agent version
|
||||
2. Non-Linux hosts stop here Windows agents register and heartbeat only
|
||||
3. Diff the desired keys against /root/.ssh/authorized_keys;
|
||||
unchanged → write nothing
|
||||
4. Changed → write a temp file, os.Rename() over the real one, chmod 0600
|
||||
4. Changed → write a temp file, rename it over the real one, chmod 0600
|
||||
```
|
||||
|
||||
## Service management
|
||||
@@ -107,5 +107,5 @@ rm -rf /etc/vantage
|
||||
systemctl daemon-reload
|
||||
```
|
||||
|
||||
Keys already written to `authorized_keys` remain on disk — the agent is no
|
||||
Keys already written to `authorized_keys` remain on disk the agent is no
|
||||
longer running to remove them. Revoke first if that matters.
|
||||
|
||||
@@ -9,20 +9,18 @@ it is absent.
|
||||
|
||||
## Server
|
||||
|
||||
| Name | Required | Default | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| `GRPC_HOST` | **yes** | — | The `host:port` agents dial. Boot fails without it. There is deliberately no fallback to the web host: that would hand every agent a port that does not speak gRPC |
|
||||
| `MONGO_URI` | no | `mongodb://localhost:27017` | The database name is taken from the URI path, falling back to `vantage`. There is no separate `MONGO_DB` |
|
||||
| `REDIS_ADDR` | no | `localhost:6379` | Sessions only |
|
||||
| `KEY_ENCRYPTION_KEY` | yes in practice | — | 64 hex characters (32 bytes) for AES-256-GCM. Required for private keys, vault secrets, OIDC client secrets and console credentials |
|
||||
| `GITEA_HOST` | yes | `gitea.example.com` | Used to build the install scripts and agent download URLs. The default is a placeholder that will not resolve |
|
||||
| `GUACD_ADDR` | no | `guacd:4822` | The [browser console](../vantage/browser-console.md) daemon |
|
||||
| `APP_ROOT_LABEL` | no | `vantage` | The app root label for the host and session organisation guard |
|
||||
| `VANTAGE_WORKFLOW_LOG_DIR` | no | — | Where workflow run logs are written |
|
||||
| `VANTAGE_DEFAULT_STEPS_DIR` | no | baked into the image | Where the seeded step library is read from |
|
||||
| `VANTAGE_DEPLOYMENT` | no | self-hosted | Set to `cloud` on a cloud instance. Governs whether a licence may be pasted |
|
||||
| `VANTAGE_LICENSE` | no | — | A licence blob, used **only** when the instance has no stored one |
|
||||
| `FREE_INSTANCE_REAP_AFTER` | no | empty | How long past a Free licence's expiry before the instance and all its data are deleted |
|
||||
| Name | Required | Default | Notes |
|
||||
| -------------------------- | --------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `GRPC_HOST` | **yes** | | The `host:port` agents dial. Boot fails without it. There is deliberately no fallback to the web host: that would hand every agent a port that does not speak gRPC |
|
||||
| `MONGO_URI` | no | `mongodb://localhost:27017` | The database name is taken from the URI path, falling back to `vantage`. There is no separate `MONGO_DB` |
|
||||
| `REDIS_ADDR` | no | `localhost:6379` | Sessions 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
|
||||
@@ -30,11 +28,6 @@ credentials. Lose it and all of them are unreadable. Back it up separately from
|
||||
the database it protects.
|
||||
:::
|
||||
|
||||
:::warning `FREE_INSTANCE_REAP_AFTER` empty means disabled, and empty is the default
|
||||
That is the correct value for a self-hosted install, which must never reap. It
|
||||
is set only on the hosted deployment.
|
||||
:::
|
||||
|
||||
:::info A wrong `APP_ROOT_LABEL` fails quietly
|
||||
It does not error. It simply stops matching, and the host/session guard stops
|
||||
protecting anything.
|
||||
@@ -50,57 +43,3 @@ remap with Docker's port publishing instead.
|
||||
|
||||
The agent reads no environment variables. Everything is in its
|
||||
[config file](./agent-config.md).
|
||||
|
||||
## Hosted-only services
|
||||
|
||||
These run only on the hosted deployment, from
|
||||
`deploy/docker-compose.site.yml`. A self-hosted install runs none of them.
|
||||
|
||||
### sitesvc — the public contact form
|
||||
|
||||
| Name | Required | Notes |
|
||||
| --- | --- | --- |
|
||||
| `MONGO_URI` | yes | Must point at the control plane's database. Refuses to start against a database that has not run the instances migration. The database name is read from the URI path; a URI without one is refused rather than defaulted |
|
||||
| `SMTP_HOST`, `SMTP_FROM` | yes | Without them the contact form answers `503` rather than silently dropping messages |
|
||||
| `SMTP_TO` | no | Defaults to `support@hostxtra.co.uk` |
|
||||
| `SMTP_PORT` | no | Defaults to `587`; `465` uses implicit TLS |
|
||||
| `SMTP_USERNAME`, `SMTP_PASSWORD` | no | Auth is skipped when the username is empty |
|
||||
| `SITE_ORIGIN` | yes in practice | Comma-separated allowed origins. Unset refuses every cross-origin browser request |
|
||||
| `TRUST_PROXY` | no | Only `true` behind a proxy that overwrites `X-Forwarded-For`, or clients spoof past the rate limiter |
|
||||
|
||||
### admin — the licensing authority
|
||||
|
||||
| Name | Required | Notes |
|
||||
| --- | --- | --- |
|
||||
| `ADMIN_MONGO_URI` | yes | Admin's own database |
|
||||
| `CONTROL_MONGO_URI` | yes | The control plane's database, for licence injection and user projection |
|
||||
| `LICENSE_SIGNING_KEY` | yes | **The only service that ever holds this.** Never add it to the server, and never add admin to the self-hosted Compose file |
|
||||
| `REDIS_ADDR`, `REDIS_USERNAME`, `REDIS_PASSWORD` | yes | Admin uses an external Redis; the base Compose file hardcodes `redis:6379` for the server, so these reach admin only |
|
||||
| `ADMIN_ORIGIN` | yes | Comma-separated browser origins that call admin. See the warning below |
|
||||
| `PADDLE_API_KEY` | yes | Boot-required |
|
||||
| `PADDLE_WEBHOOK_SECRET` | yes | Boot-required. An unverified webhook endpoint is one anyone can issue licences through |
|
||||
| `PADDLE_ENV` | yes | `sandbox` or `production`. Selects which catalogue price IDs are served, and must match the value baked into the portal build |
|
||||
| `SMTP_*` | yes in practice | Account, licence and billing email |
|
||||
| `PUBLIC_URL`, `APP_LOGIN_URL` | yes in practice | Used in links inside emails |
|
||||
| `FREE_INSTANCE_REAP_AFTER` | yes | Must match the control plane's value. Admin only uses it to name the date in warning emails; the control plane performs the delete |
|
||||
|
||||
:::warning A missing `ADMIN_ORIGIN` entry produces no error anywhere
|
||||
The CORS layer simply omits the allow-origin header and still answers the
|
||||
preflight with `204`. The browser blocks the request and **admin logs nothing at
|
||||
all**. The symptom is a preflight failure on an endpoint that works perfectly
|
||||
under `curl`.
|
||||
:::
|
||||
|
||||
## Build-time variables
|
||||
|
||||
These are baked into frontend images at build time, not read at runtime.
|
||||
Changing one requires rebuilding that image — and because editing a CI variable
|
||||
pushes no commit, nothing rebuilds on its own. See [CI/CD](../operations/ci-cd.md).
|
||||
|
||||
| Name | Baked into |
|
||||
| --- | --- |
|
||||
| `HQ_URL` | `web` |
|
||||
| `SITE_API_URL`, `SITE_CONTACT_EMAIL` | `site` |
|
||||
| `ADMIN_API_URL` | `adminsite` **and** `site` |
|
||||
| `ADMIN_ENV`, `PADDLE_CLIENT_TOKEN`, `PADDLE_ENV` | `adminsite` |
|
||||
| `DOCS_URL`, `DOCS_BASE_URL` | `docsite` |
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
---
|
||||
id: grpc-api
|
||||
title: gRPC API
|
||||
sidebar_label: gRPC API
|
||||
---
|
||||
|
||||
The agent-facing API, on port `9090`, over TLS. Agents dial **out** to it;
|
||||
nothing dials an agent.
|
||||
|
||||
## Service
|
||||
|
||||
```protobuf
|
||||
service Vantage {
|
||||
rpc Register(RegisterRequest) returns (RegisterResponse);
|
||||
rpc SyncKeys(SyncRequest) returns (SyncResponse);
|
||||
rpc UploadGeneratedKey(UploadKeyRequest) returns (UploadKeyResponse);
|
||||
rpc ReportUpdates(ReportUpdatesRequest) returns (ReportUpdatesResponse);
|
||||
rpc ReportInventory(InventoryReport) returns (InventoryReportResponse);
|
||||
rpc SyncMonitors(SyncMonitorsRequest) returns (SyncMonitorsResponse);
|
||||
rpc ReportChecks(ReportChecksRequest) returns (ReportChecksResponse);
|
||||
rpc CommandStream(stream AgentMessage) returns (stream ServerCommand);
|
||||
}
|
||||
```
|
||||
|
||||
The full message definitions live in `proto/vantage/v1/vantage.proto`.
|
||||
|
||||
## Authentication
|
||||
|
||||
`Register` presents the single-use, one-hour pre-registration token and receives
|
||||
a permanent agent token. Every other call presents that agent token.
|
||||
|
||||
The control plane stores only the SHA-256 of the agent token. The plaintext
|
||||
exists in the agent's `0600` config file and nowhere else, so a token cannot be
|
||||
read back out of the control plane.
|
||||
|
||||
## Unary calls
|
||||
|
||||
| RPC | Direction | Frequency |
|
||||
| --- | --- | --- |
|
||||
| `Register` | once, at enrolment | once |
|
||||
| `SyncKeys` | agent asks for desired key state | every 30s (`poll_interval`) |
|
||||
| `UploadGeneratedKey` | agent returns a keypair it generated | on demand |
|
||||
| `ReportUpdates` | pending OS package updates | hourly |
|
||||
| `ReportInventory` | CPU, memory, disk, kernel | metrics 30s, static 15 min |
|
||||
| `SyncMonitors` | agent asks which checks it should run | periodically |
|
||||
| `ReportChecks` | agent returns check results | after each check cycle |
|
||||
|
||||
`SyncKeys` doubles as the heartbeat. A server that stops calling it is marked
|
||||
`offline` by a sweep that runs every two minutes.
|
||||
|
||||
## The command stream
|
||||
|
||||
`CommandStream` is the only streaming RPC and the only push path.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant A as Agent
|
||||
participant S as Server
|
||||
A->>S: AgentReady (authenticate)
|
||||
S-->>A: ServerCommand (RunStepCmd)
|
||||
A-->>S: StepOutputChunk (repeated)
|
||||
A-->>S: StepResult
|
||||
S-->>A: ServerCommand (CleanupWorkspaceCmd)
|
||||
A-->>S: CommandResult
|
||||
```
|
||||
|
||||
The agent authenticates once with `AgentReady`, then the server pushes commands
|
||||
and the agent replies with `CommandResult`, `StepResult` or `StepOutputChunk`.
|
||||
|
||||
### Commands
|
||||
|
||||
| Command | Effect |
|
||||
| --- | --- |
|
||||
| `GenerateKeyCmd` | Generate an SSH keypair on the machine |
|
||||
| `DeleteKeyCmd` | Remove a generated key by label |
|
||||
| `UpdateAgentCmd` | Download and replace the agent binary with a target version |
|
||||
| `ApplyUpdatesCmd` | Apply pending OS package updates |
|
||||
| `RunStepCmd` | Execute one workflow step |
|
||||
| `CleanupWorkspaceCmd` | Recursively remove the run's working directory |
|
||||
|
||||
## Why poll for keys and push for commands
|
||||
|
||||
A 30-second delay on a key change is fine, and polling needs no reconnection
|
||||
logic to survive a dropped link. Clicking **Run** on a workflow and waiting up
|
||||
to 30 seconds is not fine. Hence one of each.
|
||||
|
||||
## Network requirements
|
||||
|
||||
Every managed machine needs outbound TCP to `GRPC_HOST`. Nothing needs to reach
|
||||
the machine. Watch for middleboxes that permit the short `Register` call but
|
||||
drop the long-lived command stream — that failure looks like a server that
|
||||
registers and then goes offline.
|
||||
@@ -6,26 +6,14 @@ sidebar_label: Ports and networking
|
||||
|
||||
## Control plane ports
|
||||
|
||||
| Port | Service | Who connects | Expose publicly |
|
||||
| --- | --- | --- | --- |
|
||||
| `3000` | web | Browsers, via your reverse proxy | Yes, behind TLS |
|
||||
| `8080` | server REST | The web app | No |
|
||||
| `9090` | server gRPC | Agents | **Yes** |
|
||||
| `4822` | guacd | The server | No |
|
||||
| `27017` | MongoDB | The server | No |
|
||||
| `6379` | Redis | The server | No |
|
||||
|
||||
## Hosted-only ports
|
||||
|
||||
Only on the hosted deployment, from `deploy/docker-compose.site.yml`.
|
||||
|
||||
| Port | Service |
|
||||
| --- | --- |
|
||||
| `3003` | marketing site |
|
||||
| `3004` | HQ portal |
|
||||
| `3005` | this documentation site |
|
||||
| `8082` | sitesvc |
|
||||
| `8083` | admin |
|
||||
| Port | Service | Who connects | Expose publicly |
|
||||
| ------- | ----------- | -------------------------------- | --------------- |
|
||||
| `3000` | web | Browsers, via your reverse proxy | Yes, behind TLS |
|
||||
| `8080` | server REST | The web app | No |
|
||||
| `9090` | server gRPC | Agents | **Yes** |
|
||||
| `4822` | guacd | The server | No |
|
||||
| `27017` | MongoDB | The server | No |
|
||||
| `6379` | Redis | The server | No |
|
||||
|
||||
## Direction of travel
|
||||
|
||||
@@ -36,7 +24,8 @@ flowchart LR
|
||||
W --> S["server :8080"]
|
||||
A["Agent on a managed server"] -->|"gRPC/TLS :9090, outbound"| S
|
||||
S --> G["guacd :4822"]
|
||||
G -->|"SSH / RDP / VNC"| T["Target machine"]
|
||||
G -->|"relayed over the :9090 stream"| A
|
||||
A -->|"SSH / RDP / VNC, loopback"| T["Target machine (same host as agent)"]
|
||||
```
|
||||
|
||||
Two things are worth reading off that diagram.
|
||||
@@ -45,9 +34,13 @@ Two things are worth reading off that diagram.
|
||||
NAT is not an obstacle. The only requirement is that the machine can reach
|
||||
`GRPC_HOST`.
|
||||
|
||||
**The console does not use the agent.** guacd connects directly to the target on
|
||||
the protocol port. A machine reachable only by its agent — behind NAT, on a
|
||||
private subnet — cannot be consoled, even though every other feature works.
|
||||
**The console rides the agent's connection too.** guacd never dials the target
|
||||
directly; the server pushes a command down the agent's existing outbound gRPC
|
||||
stream on `9090`, and the agent relays the protocol traffic from its own
|
||||
loopback. No route from the control plane to the target's address is needed,
|
||||
and no new inbound port opens on the target — the same connection that carries
|
||||
key sync carries console traffic. This is what makes the console work for a
|
||||
machine behind NAT on a private subnet, as long as its agent is online.
|
||||
|
||||
## What to open
|
||||
|
||||
@@ -61,7 +54,9 @@ private subnet — cannot be consoled, even though every other feature works.
|
||||
- `gitea.hostxtra.co.uk`, for agent releases and version checks.
|
||||
- Anything a server-run [monitor](../vantage/monitors.md) checks.
|
||||
- SMTP, if you use an SMTP notification channel.
|
||||
- Protocol ports on machines you intend to console.
|
||||
|
||||
No route to the machines you intend to console is needed — that traffic rides
|
||||
the agent's existing outbound `9090` connection instead.
|
||||
|
||||
### Outbound from a managed machine
|
||||
|
||||
@@ -75,7 +70,7 @@ Terminate TLS for the web UI at your reverse proxy.
|
||||
|
||||
gRPC on `9090` is reached directly by agents with `tls: true`, so that port needs
|
||||
a valid certificate for the name in `GRPC_HOST`. If you proxy it, the proxy must
|
||||
speak HTTP/2 end to end — many do not by default, and the symptom is agents that
|
||||
speak HTTP/2 end to end many do not by default, and the symptom is agents that
|
||||
register and then fail to hold the command stream.
|
||||
|
||||
## Reverse proxy notes
|
||||
@@ -91,7 +86,7 @@ register and then fail to hold the command stream.
|
||||
|
||||
The control plane needs outbound access to fetch agent releases. Managed
|
||||
machines need it too, unless you distribute the agent binary yourself and write
|
||||
the config by hand — the install script's only job is to do those two things.
|
||||
the config by hand the install script's only job is to do those two things.
|
||||
|
||||
Licence verification is entirely local, so a licensed install works with no
|
||||
outbound access to HQ at all.
|
||||
|
||||
@@ -12,7 +12,7 @@ has no privileges it does not.
|
||||
Most endpoints take a session: an opaque 32-byte token in the `km_session`
|
||||
cookie, with the body in Redis for 24 hours.
|
||||
|
||||
One endpoint takes a bearer token instead — the External Secrets Operator read
|
||||
One endpoint takes a bearer token instead the External Secrets Operator read
|
||||
path.
|
||||
|
||||
## Unauthenticated
|
||||
@@ -119,14 +119,14 @@ GET,PUT /org/oidc (owner|admin)
|
||||
|
||||
## Notable refusals
|
||||
|
||||
| Endpoint | Condition | Status |
|
||||
| --- | --- | --- |
|
||||
| `POST /license` | deployment is `cloud` | `409 cloud_managed` |
|
||||
| `PUT,DELETE /steps/:id` | the step's source is `default` | `409` |
|
||||
| `PUT /org/users/:id/role`, `DELETE /org/users/:id` | the user's auth source is `hq` | `409` |
|
||||
| Endpoint | Condition | Status |
|
||||
| -------------------------------------------------- | ------------------------------ | ------------------- |
|
||||
| `POST /license` | deployment is `cloud` | `409 cloud_managed` |
|
||||
| `PUT,DELETE /steps/:id` | the step's source is `default` | `409` |
|
||||
| `PUT /org/users/:id/role`, `DELETE /org/users/:id` | the user's auth source is `hq` | `409` |
|
||||
|
||||
`POST /license` is exempt from the licence check, so pasting a valid licence
|
||||
works while the current one is expired — that is the way out of degraded mode.
|
||||
works while the current one is expired that is the way out of degraded mode.
|
||||
|
||||
## Multi-tenancy
|
||||
|
||||
@@ -143,7 +143,7 @@ session and does not need that distinction.
|
||||
|
||||
## Admin API
|
||||
|
||||
The HQ service has its own API, its own database and its own session cookie
|
||||
(`admin_session`) on port `8083`. It is documented in the
|
||||
[Vantage HQ](../hq/accounts-and-signup.md) section rather than here; the two
|
||||
services share no session and no authentication.
|
||||
Vantage HQ is a separate hosted service with its own API and its own session.
|
||||
Its behaviour is described in the [Vantage HQ](../hq/accounts-and-signup.md)
|
||||
section rather than here; the two services share no session and no
|
||||
authentication.
|
||||
|
||||
@@ -8,7 +8,7 @@ Symptoms, in the order people hit them.
|
||||
|
||||
## The server will not start
|
||||
|
||||
**Exits immediately on boot.** Almost always a missing `GRPC_HOST` — the server
|
||||
**Exits immediately on boot.** Almost always a missing `GRPC_HOST` the server
|
||||
refuses to start rather than guess a value that would break every agent later.
|
||||
|
||||
**Fails during index creation.** The auth and settings index builders are fatal
|
||||
@@ -47,11 +47,11 @@ Work through it in this order:
|
||||
4. Was the token already used or expired? It is single-use and lives one hour —
|
||||
create a fresh enrolment rather than reusing the old command.
|
||||
|
||||
| Symptom | Cause |
|
||||
| --- | --- |
|
||||
| Symptom | Cause |
|
||||
| --------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
|
||||
| Registers, then goes `offline` within minutes | Something permits the short `Register` call but drops the long-lived stream. Usually a proxy or idle-timeout middlebox |
|
||||
| Stays `pending` forever | Registration never happened. Token spent, or the endpoint unreachable |
|
||||
| Flaps between `active` and `offline` | Intermittent path, or a poll interval longer than the offline threshold |
|
||||
| Stays `pending` forever | Registration never happened. Token spent, or the endpoint unreachable |
|
||||
| Flaps between `active` and `offline` | Intermittent path, or a poll interval longer than the offline threshold |
|
||||
|
||||
Remember the offline sweep runs every two minutes, so status is never
|
||||
instantaneous.
|
||||
@@ -67,7 +67,7 @@ instantaneous.
|
||||
|
||||
## A workflow run fails or hangs
|
||||
|
||||
- **Hangs at dispatch.** The target's command stream is not connected — the
|
||||
- **Hangs at dispatch.** The target's command stream is not connected the
|
||||
server may be `offline`.
|
||||
- **Fails immediately with an interpreter error.** A bash step on a Windows
|
||||
target, or PowerShell on Linux.
|
||||
@@ -81,13 +81,13 @@ instantaneous.
|
||||
|
||||
## The console will not connect
|
||||
|
||||
| Symptom | Cause |
|
||||
| --- | --- |
|
||||
| Connects, then closes at once | guacd unreachable. Check `GUACD_ADDR` and that the container is running |
|
||||
| SSH rejects the key | The stored key has no private half, or is not on the target |
|
||||
| RDP fails on retry | Credentials are single-use and consumed at tunnel open — enter them again |
|
||||
| Hangs at "connecting" | The **control plane** cannot reach the target on the protocol port. The agent's reachability is irrelevant here |
|
||||
| Fails only in production | The reverse proxy is not forwarding WebSocket upgrade headers |
|
||||
| Symptom | Cause |
|
||||
| ----------------------------- | --------------------------------------------------------------------------------------------------------------- |
|
||||
| Connects, then closes at once | guacd unreachable. Check `GUACD_ADDR` and that the container is running |
|
||||
| SSH rejects the key | The stored key has no private half, or is not on the target |
|
||||
| RDP fails on retry | Credentials are single-use and consumed at tunnel open enter them again |
|
||||
| Hangs, then disconnects | The agent never claimed the relay, nothing is listening on the protocol port on the target's own loopback address, or guacd never dialled in time. Check the audit log for `console.proxy_failed` — its reason (`agent_timeout`, `dial_refused`, `guacd_timeout`, `rejected`) names which |
|
||||
| Fails only in production | The reverse proxy is not forwarding WebSocket upgrade headers |
|
||||
|
||||
## Monitors report down when the service is up
|
||||
|
||||
@@ -98,7 +98,7 @@ instantaneous.
|
||||
|
||||
## Notifications are not arriving
|
||||
|
||||
Use the channel **Test** button — it goes through the real delivery path, so a
|
||||
Use the channel **Test** button it goes through the real delivery path, so a
|
||||
test that arrives proves credentials, network path and destination.
|
||||
|
||||
If the test fails: a webhook returning 300 or above counts as a failure, SMTP
|
||||
@@ -107,23 +107,19 @@ needs `host`, `port`, `from` and `to`, and Telegram needs both `token` and
|
||||
|
||||
## Licence problems
|
||||
|
||||
| Symptom | Cause |
|
||||
| --- | --- |
|
||||
| `409 cloud_managed` when pasting | It is a cloud instance. Licences are written by HQ; there is nothing to paste |
|
||||
| Licence rejected as not matching | It is bound to a different instance UUID. Relink in HQ |
|
||||
| Instance degraded despite a valid-looking licence | It has expired past its grace period. Pasting still works — that endpoint stays available specifically so it can |
|
||||
| Cannot enrol another server | The server allowance is reached. Raise it in HQ or remove one |
|
||||
| Symptom | Cause |
|
||||
| ------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
|
||||
| `409 cloud_managed` when pasting | It is a cloud instance. Licences are written by HQ; there is nothing to paste |
|
||||
| Licence rejected as not matching | It is bound to a different instance UUID. Relink in HQ |
|
||||
| Instance degraded despite a valid-looking licence | It has expired past its grace period. Pasting still works that endpoint stays available specifically so it can |
|
||||
| Cannot enrol another server | The server allowance is reached. Raise it in HQ or remove one |
|
||||
|
||||
## HQ portal problems
|
||||
|
||||
**A request fails in the browser but works under `curl`.** The browser origin is
|
||||
missing from `ADMIN_ORIGIN`. This produces no log line at all in admin — the
|
||||
preflight is answered `204` without the allow-origin header, and the browser
|
||||
blocks the real request.
|
||||
|
||||
**A price or plan looks wrong after an edit.** Repository variables are baked
|
||||
into images at build time and editing one pushes no commit, so nothing rebuilds.
|
||||
Trigger the build manually. See [CI/CD](../operations/ci-cd.md).
|
||||
The portal is a hosted service, so problems with it are ours to fix rather than
|
||||
yours to configure. If a page fails to load, an action reports an error, or a
|
||||
plan or price looks wrong after a change, contact support with your instance
|
||||
UUID and roughly when it happened.
|
||||
|
||||
## Gathering information before asking for help
|
||||
|
||||
@@ -133,5 +129,5 @@ docker compose logs --tail=200 server
|
||||
journalctl -u vantage-agent --no-pager -n 200 # on the affected machine
|
||||
```
|
||||
|
||||
Include your instance UUID from **Settings → Licence** — it is the reference
|
||||
Include your instance UUID from **Settings → Licence** it is the reference
|
||||
support works from.
|
||||
|
||||
@@ -8,13 +8,13 @@ Every mutating API path writes an audit event. The log is at **Audit**.
|
||||
|
||||
## What an event carries
|
||||
|
||||
| Field | Meaning |
|
||||
| --- | --- |
|
||||
| Field | Meaning |
|
||||
| ------ | -------------------------------------------------------- |
|
||||
| Action | A dotted name, e.g. `server.created`, `settings.updated` |
|
||||
| Actor | Who did it |
|
||||
| Target | The object acted on |
|
||||
| Detail | A short human-readable note |
|
||||
| Time | When |
|
||||
| Actor | Who did it |
|
||||
| Target | The object acted on |
|
||||
| Detail | A short human-readable note |
|
||||
| Time | When |
|
||||
|
||||
## What is recorded
|
||||
|
||||
@@ -36,7 +36,7 @@ lookup.
|
||||
|
||||
## Retention
|
||||
|
||||
Audit events are not swept by the workflow log retention setting — that setting
|
||||
Audit events are not swept by the workflow log retention setting that setting
|
||||
governs run logs only. Audit history stays until the instance does.
|
||||
|
||||
:::warning It is a log, not a control
|
||||
|
||||
@@ -7,7 +7,7 @@ sidebar_label: Browser console
|
||||
An SSH, RDP or VNC session in a browser tab, with no client software and no
|
||||
inbound port on the target beyond the one the protocol already uses.
|
||||
|
||||
Protocol handling is Apache Guacamole's — the control plane proxies a WebSocket
|
||||
Protocol handling is Apache Guacamole's the control plane proxies a WebSocket
|
||||
to a **guacd** daemon and manages credentials around it.
|
||||
|
||||
## Requirements
|
||||
@@ -15,20 +15,23 @@ to a **guacd** daemon and manages credentials around it.
|
||||
- `guacd` running and reachable from the server. The bundled Compose stack
|
||||
includes it; `GUACD_ADDR` defaults to `guacd:4822`.
|
||||
- `KEY_ENCRYPTION_KEY` set, since every credential involved is stored encrypted.
|
||||
- Network reachability **from the control plane to the target** on the protocol
|
||||
port. This is the one part of Vantage that is not agent-mediated: guacd
|
||||
connects directly, so a machine reachable only by its agent cannot be
|
||||
consoled.
|
||||
- The target's **agent must be online**. Console traffic is relayed over the
|
||||
agent's existing outbound connection, so the control plane never needs a route
|
||||
to the server's address — but it does need the agent.
|
||||
- No inbound port on the target, beyond what the protocol already listens on
|
||||
locally. A service bound only to `127.0.0.1` works, because the agent dials
|
||||
loopback on the target itself.
|
||||
|
||||
## Opening a session
|
||||
|
||||
From a server's page, choose **Console**. Then:
|
||||
|
||||
1. The UI calls `POST /api/console/connect`, which mints a **one-time** session
|
||||
token.
|
||||
token. If the target's agent is not connected, this fails immediately with
|
||||
`409 agent_offline` rather than hanging.
|
||||
2. The browser opens a WebSocket to `GET /api/console/tunnel` with that token.
|
||||
3. The server marks the token consumed — atomically, so a second use cannot
|
||||
race — and proxies the connection to guacd.
|
||||
3. The server marks the token consumed atomically, so a second use cannot
|
||||
race and proxies the connection to guacd.
|
||||
|
||||
## Credentials
|
||||
|
||||
@@ -52,19 +55,19 @@ is worth one connection at most, and only until it is used.
|
||||
## Session behaviour
|
||||
|
||||
Closing the tab ends the session. There is no reconnect and no session
|
||||
persistence — reopening mints a new token and a new connection.
|
||||
persistence reopening mints a new token and a new connection.
|
||||
|
||||
## Auditing
|
||||
|
||||
Opening a console is an audited action, with actor, server and time. What
|
||||
happens *inside* the session is not recorded: there is no session capture or
|
||||
happens _inside_ the session is not recorded: there is no session capture or
|
||||
keystroke log. If you need that, it has to come from the target machine.
|
||||
|
||||
## When it does not work
|
||||
|
||||
| Symptom | Cause |
|
||||
| --- | --- |
|
||||
| Connects then closes immediately | guacd unreachable — check `GUACD_ADDR` and that the container is up |
|
||||
| SSH refuses the key | The stored key has no private half, or is not in the target's `authorized_keys` |
|
||||
| RDP fails on a fresh credential | Credentials are consumed on open; a retry needs them entered again |
|
||||
| Hangs at connecting | The control plane cannot reach the target on the protocol port |
|
||||
| Symptom | Cause |
|
||||
| -------------------------------- | ------------------------------------------------------------------------------- |
|
||||
| Connects then closes immediately | guacd unreachable check `GUACD_ADDR` and that the container is up |
|
||||
| SSH refuses the key | The stored key has no private half, or is not in the target's `authorized_keys` |
|
||||
| RDP fails on a fresh credential | Credentials are consumed on open; a retry needs them entered again |
|
||||
| Hangs, then disconnects | The agent never claimed the relay, nothing is listening on the protocol port on the target's own loopback address, or guacd never dialled in time. Check the audit log for `console.proxy_failed` — its reason (`agent_timeout`, `dial_refused`, `guacd_timeout`, `rejected`) names which |
|
||||
|
||||
@@ -9,12 +9,12 @@ from, and a notification path when they stop being satisfied.
|
||||
|
||||
## Types
|
||||
|
||||
| Type | Checks | Options |
|
||||
| --- | --- | --- |
|
||||
| `http` | An HTTP(S) URL | method, expected status, keyword that must appear in the body, allow insecure TLS |
|
||||
| `tcp` | A host and port accept a connection | — |
|
||||
| `icmp` | A host answers ping | — |
|
||||
| `tls` | A certificate is valid and not expiring | warn N days before expiry |
|
||||
| Type | Checks | Options |
|
||||
| ------ | --------------------------------------- | --------------------------------------------------------------------------------- |
|
||||
| `http` | An HTTP(S) URL | method, expected status, keyword that must appear in the body, allow insecure TLS |
|
||||
| `tcp` | A host and port accept a connection | |
|
||||
| `icmp` | A host answers ping | |
|
||||
| `tls` | A certificate is valid and not expiring | warn N days before expiry |
|
||||
|
||||
An `http` monitor with a keyword is usually the one you want for an application:
|
||||
a 200 that returns an error page still fails the keyword.
|
||||
@@ -23,12 +23,12 @@ a 200 that returns an error page still fails the keyword.
|
||||
|
||||
Every monitor has a **runner**:
|
||||
|
||||
| Runner | Meaning |
|
||||
| --- | --- |
|
||||
| `server` | The control plane's scheduler performs the check |
|
||||
| Runner | Meaning |
|
||||
| ----------- | -------------------------------------------------------------- |
|
||||
| `server` | The control plane's scheduler performs the check |
|
||||
| a server ID | That server's agent performs it locally and reports the result |
|
||||
|
||||
Use `server` for anything reachable from the control plane — public endpoints,
|
||||
Use `server` for anything reachable from the control plane public endpoints,
|
||||
your own front door. Use an agent for anything only reachable from inside the
|
||||
target network: a database on a private subnet, a service bound to localhost, a
|
||||
device on a management VLAN.
|
||||
@@ -41,8 +41,8 @@ different questions, and outages usually live in the gap.
|
||||
|
||||
## Interval, retries and state
|
||||
|
||||
- **Interval** — how often to check.
|
||||
- **Retries** — how many consecutive failures are tolerated before the state
|
||||
- **Interval** how often to check.
|
||||
- **Retries** how many consecutive failures are tolerated before the state
|
||||
flips.
|
||||
|
||||
A monitor sits in `pending` until its first result. Failures accumulate; once
|
||||
@@ -65,7 +65,7 @@ hours does not send a message per interval.
|
||||
|
||||
The monitor detail page shows:
|
||||
|
||||
- **Uptime**, from hourly rollup records — checks performed, how many were up,
|
||||
- **Uptime**, from hourly rollup records checks performed, how many were up,
|
||||
and mean latency per hour. Rollups are what make the graph cheap to draw over
|
||||
long windows.
|
||||
- **Incidents**, each with a start, a resolution and the cause recorded at the
|
||||
@@ -74,5 +74,5 @@ The monitor detail page shows:
|
||||
## Disabling versus deleting
|
||||
|
||||
Disabling stops the checks and keeps the history. Deleting removes the monitor.
|
||||
Prefer disabling for anything seasonal — the uptime record is usually the part
|
||||
Prefer disabling for anything seasonal the uptime record is usually the part
|
||||
you wanted.
|
||||
|
||||
@@ -15,9 +15,9 @@ Manage them at **Settings → Notifications**.
|
||||
|
||||
Posts JSON to a URL you choose.
|
||||
|
||||
| Setting | |
|
||||
| --- | --- |
|
||||
| `url` | Required |
|
||||
| Setting | |
|
||||
| ------- | -------- |
|
||||
| `url` | Required |
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -35,31 +35,31 @@ after 10 seconds.
|
||||
|
||||
### Discord
|
||||
|
||||
| Setting | |
|
||||
| --- | --- |
|
||||
| `url` | Discord webhook URL |
|
||||
| Setting | |
|
||||
| ------- | ------------------- |
|
||||
| `url` | Discord webhook URL |
|
||||
|
||||
Posts the alert as message content.
|
||||
|
||||
### Slack
|
||||
|
||||
| Setting | |
|
||||
| --- | --- |
|
||||
| `url` | Slack incoming webhook URL |
|
||||
| Setting | |
|
||||
| ------- | -------------------------- |
|
||||
| `url` | Slack incoming webhook URL |
|
||||
|
||||
### Telegram
|
||||
|
||||
| Setting | |
|
||||
| --- | --- |
|
||||
| `token` | Bot token |
|
||||
| Setting | |
|
||||
| --------- | ----------- |
|
||||
| `token` | Bot token |
|
||||
| `chat_id` | Target chat |
|
||||
|
||||
### SMTP
|
||||
|
||||
| Setting | |
|
||||
| --- | --- |
|
||||
| `host`, `port` | Required |
|
||||
| `from`, `to` | Required |
|
||||
| Setting | |
|
||||
| ---------------------- | ---------------------------------------------------- |
|
||||
| `host`, `port` | Required |
|
||||
| `from`, `to` | Required |
|
||||
| `username`, `password` | Optional; auth is skipped when the username is empty |
|
||||
|
||||
Port `465` uses implicit TLS; anything else uses STARTTLS.
|
||||
@@ -83,7 +83,7 @@ something that needs to branch on status.
|
||||
## Testing
|
||||
|
||||
Every channel has a **Test** button. It dispatches a fabricated down event for a
|
||||
monitor called "Test monitor", through the real delivery path — so a test that
|
||||
monitor called "Test monitor", through the real delivery path so a test that
|
||||
arrives proves the credentials, the network path and the destination, not just
|
||||
the configuration form.
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ External Secrets Operator.
|
||||
|
||||
## Groups and values
|
||||
|
||||
A **group** is a named bundle — `prod-db`, `registry`, `acme-api`. Inside it are
|
||||
A **group** is a named bundle `prod-db`, `registry`, `acme-api`. Inside it are
|
||||
key/value pairs.
|
||||
|
||||
Group by consumer, not by type. A group is the unit a workflow step references
|
||||
@@ -22,7 +22,7 @@ a group holding everything is over-sharing to every step that needs any of it.
|
||||
**Secrets → New group**, then add keys.
|
||||
|
||||
Values are write-then-hidden. The list shows keys, never values. **Reveal** is a
|
||||
separate action on a separate endpoint, and it writes an audit event — so
|
||||
separate action on a separate endpoint, and it writes an audit event so
|
||||
looking at a secret is a recorded act.
|
||||
|
||||
Deleting a single key and deleting the whole group are separate operations.
|
||||
@@ -42,7 +42,7 @@ library entry.
|
||||
|
||||
:::warning A step can print its own secrets
|
||||
Injection puts values in the environment. If your script echoes them, or runs
|
||||
with `set -x`, they land in the run log — which is stored on disk and readable
|
||||
with `set -x`, they land in the run log which is stored on disk and readable
|
||||
in the UI. Vantage does not scrub step output.
|
||||
:::
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@ sidebar_label: Servers
|
||||
---
|
||||
|
||||
The fleet. Every managed machine runs an agent that connects outbound to the
|
||||
control plane, and everything else in Vantage — keys, workflows, monitors,
|
||||
consoles — targets these records.
|
||||
control plane, and everything else in Vantage keys, workflows, monitors,
|
||||
consoles targets these records.
|
||||
|
||||
## Enrolling a server
|
||||
|
||||
@@ -16,14 +16,14 @@ a one-liner to run as root on the target machine.
|
||||
|
||||
## Lifecycle
|
||||
|
||||
| Status | Meaning |
|
||||
| --- | --- |
|
||||
| Status | Meaning |
|
||||
| --------- | --------------------------------------------------- |
|
||||
| `pending` | Enrolment created; the agent has not registered yet |
|
||||
| `active` | The agent registered and is syncing |
|
||||
| `offline` | Last-seen passed the threshold |
|
||||
| `active` | The agent registered and is syncing |
|
||||
| `offline` | Last-seen passed the threshold |
|
||||
|
||||
The offline sweep runs every two minutes, so a machine that has just gone away
|
||||
takes a little while to be marked as such. That delay is intentional — a single
|
||||
takes a little while to be marked as such. That delay is intentional a single
|
||||
missed poll is not an outage.
|
||||
|
||||
## The server detail page
|
||||
@@ -37,9 +37,9 @@ Which SSH keys are assigned to this machine, and their state. See
|
||||
|
||||
Agents report:
|
||||
|
||||
| Data | Refreshed |
|
||||
| --- | --- |
|
||||
| CPU, memory, swap, load | every 30 seconds |
|
||||
| Data | Refreshed |
|
||||
| ---------------------------------------- | ---------------- |
|
||||
| CPU, memory, swap, load | every 30 seconds |
|
||||
| Partitions, kernel, full static snapshot | every 15 minutes |
|
||||
|
||||
The two carry separate timestamps, so a stale static snapshot beside fresh
|
||||
@@ -50,15 +50,15 @@ metrics is normal rather than a fault.
|
||||
Agents check for pending package updates hourly and report the count. From the
|
||||
server page you can:
|
||||
|
||||
- **Apply updates** — pushes `ApplyUpdatesCmd` down the command stream. The
|
||||
- **Apply updates** pushes `ApplyUpdatesCmd` down the command stream. The
|
||||
agent runs the platform's package manager and reports back.
|
||||
- **Update agent** — pushes `UpdateAgentCmd` with a target version; the agent
|
||||
- **Update agent** pushes `UpdateAgentCmd` with a target version; the agent
|
||||
downloads the release, verifies it and replaces itself. See
|
||||
[Agent updates](../operations/agent-updates.md).
|
||||
|
||||
:::warning Applying updates is not scheduled or staged
|
||||
It runs now, on that machine. If you need ordering, health gates or a canary,
|
||||
build it as a [workflow](./workflows.md) instead — that is what workflows exist
|
||||
build it as a [workflow](./workflows.md) instead that is what workflows exist
|
||||
for.
|
||||
:::
|
||||
|
||||
@@ -69,7 +69,7 @@ Opens a browser SSH, RDP or VNC session. See [Browser console](./browser-console
|
||||
## Windows servers
|
||||
|
||||
Windows agents register, heartbeat, run workflow steps and report inventory.
|
||||
They do not manage `authorized_keys` — the poll loop stops after the heartbeat
|
||||
They do not manage `authorized_keys` the poll loop stops after the heartbeat
|
||||
on any non-Linux host. This is a deliberate scope decision, not a gap being
|
||||
worked on.
|
||||
|
||||
@@ -94,5 +94,5 @@ no longer running to remove them. Revoke and let the agent apply the change
|
||||
|
||||
Each server has its own token. The control plane stores only its SHA-256; the
|
||||
plaintext exists in the agent's `0600` config and nowhere else. There is no way
|
||||
to read a token back out of the control plane — if one is lost, re-enrol the
|
||||
to read a token back out of the control plane if one is lost, re-enrol the
|
||||
machine.
|
||||
|
||||
@@ -11,7 +11,7 @@ Settings require the `owner` or `admin` role.
|
||||
|
||||
:::info Where instance settings went
|
||||
Members and single sign-on used to live at `/settings/instance`. They are now
|
||||
the Access group at the top of this page — splitting "who can sign in" from "how
|
||||
the Access group at the top of this page splitting "who can sign in" from "how
|
||||
this instance behaves" produced two half-pages and a nav entry nobody could
|
||||
distinguish from Settings. The old path still redirects.
|
||||
:::
|
||||
@@ -22,10 +22,10 @@ distinguish from Settings. The old path still redirects.
|
||||
|
||||
Add, remove and re-role the people who can sign in.
|
||||
|
||||
| Role | Can |
|
||||
| --- | --- |
|
||||
| `owner` | Everything |
|
||||
| `admin` | Everything except owner-only settings |
|
||||
| Role | Can |
|
||||
| -------- | ---------------------------------------------------- |
|
||||
| `owner` | Everything |
|
||||
| `admin` | Everything except owner-only settings |
|
||||
| `member` | Servers, keys, workflows, monitors, secrets, console |
|
||||
|
||||
Local members authenticate with email and a bcrypt-hashed password.
|
||||
@@ -37,7 +37,7 @@ read-only rows with a link to the portal.
|
||||
|
||||
:::warning HQ-managed users cannot be edited locally
|
||||
Changing the role of, or deleting, an `hq`-sourced user is refused with `409`.
|
||||
HQ owns their role, their password and whether they exist at all — a local
|
||||
HQ owns their role, their password and whether they exist at all a local
|
||||
change would be overwritten by the next sync and would leave two writers for one
|
||||
password hash. Manage them from [People and roles](../hq/people-and-roles.md).
|
||||
:::
|
||||
@@ -46,10 +46,10 @@ password hash. Manage them from [People and roles](../hq/people-and-roles.md).
|
||||
|
||||
Configured per organisation:
|
||||
|
||||
| Field | |
|
||||
| --- | --- |
|
||||
| Issuer | Your provider's issuer URL |
|
||||
| Client ID | |
|
||||
| Field | |
|
||||
| ------------- | ---------------------------- |
|
||||
| Issuer | Your provider's issuer URL |
|
||||
| Client ID | |
|
||||
| Client secret | Stored AES-256-GCM encrypted |
|
||||
|
||||
Sign-in then goes `/auth/oidc/start` → your provider → `/auth/oidc/callback`.
|
||||
@@ -60,7 +60,7 @@ misconfigured or unreachable, a local account is the way back in.
|
||||
## Monitoring
|
||||
|
||||
- **Alert defaults** for monitors.
|
||||
- **Notification channels** — their own page. See
|
||||
- **Notification channels** their own page. See
|
||||
[Notification channels](./notification-channels.md).
|
||||
|
||||
## Integrations
|
||||
@@ -69,11 +69,11 @@ misconfigured or unreachable, a local account is the way back in.
|
||||
|
||||
How long run logs are kept.
|
||||
|
||||
| Value | Meaning |
|
||||
| --- | --- |
|
||||
| unset | 30 days |
|
||||
| Value | Meaning |
|
||||
| -------- | -------------- |
|
||||
| unset | 30 days |
|
||||
| a number | that many days |
|
||||
| `0` | forever |
|
||||
| `0` | forever |
|
||||
|
||||
### ESO read token
|
||||
|
||||
@@ -87,7 +87,7 @@ once, stored as a SHA-256 hash, rotatable. See
|
||||
features and expiry.
|
||||
|
||||
On **self-hosted**, paste a licence here. This works even while the current
|
||||
licence is expired — that is the way out of degraded mode.
|
||||
licence is expired that is the way out of degraded mode.
|
||||
|
||||
On **cloud**, there is no paste form. The endpoint answers `409 cloud_managed`,
|
||||
because a cloud licence is written by HQ directly. The page links to the portal
|
||||
@@ -108,6 +108,6 @@ organisation from the slug and rejects a session belonging to a different one.
|
||||
The label it looks for comes from `APP_ROOT_LABEL`.
|
||||
|
||||
:::warning A wrong `APP_ROOT_LABEL` disables the guard
|
||||
It does not fail loudly — it simply stops matching, and the host check stops
|
||||
It does not fail loudly it simply stops matching, and the host check stops
|
||||
protecting anything. If you serve the UI on a custom domain, set it to match.
|
||||
:::
|
||||
|
||||
@@ -28,7 +28,7 @@ library. You may optionally upload the private half too, in which case it is
|
||||
stored **AES-256-GCM encrypted** under `KEY_ENCRYPTION_KEY`.
|
||||
|
||||
The JSON representation of a key exposes only `has_private_key` and
|
||||
`has_passphrase` — never the material. Retrieving a stored private key is its
|
||||
`has_passphrase` never the material. Retrieving a stored private key is its
|
||||
own endpoint and its own audit event.
|
||||
|
||||
:::tip Why store a private key at all
|
||||
@@ -38,7 +38,7 @@ you are not using the console, do not upload private halves.
|
||||
|
||||
## Assigning
|
||||
|
||||
Assign a key to one or more servers. Within one poll interval — 30 seconds — the
|
||||
Assign a key to one or more servers. Within one poll interval 30 seconds the
|
||||
agent picks up the change.
|
||||
|
||||
## Revoking
|
||||
@@ -58,7 +58,7 @@ until it ends. Kill sessions on the machine if that matters.
|
||||
|
||||
Each poll:
|
||||
|
||||
1. `SyncKeys` returns the desired set of public keys for that server.
|
||||
1. The control plane returns the desired set of public keys for that server.
|
||||
2. The agent reads `/root/.ssh/authorized_keys` and computes fingerprints.
|
||||
3. **If the sets match, it writes nothing.** No disk churn on unchanged state,
|
||||
which is most polls.
|
||||
@@ -77,5 +77,5 @@ it in Vantage.
|
||||
## Recovering from a lockout
|
||||
|
||||
If you have removed every key from a machine and cannot get in, you still have
|
||||
the console — provided a private key is stored — or out-of-band access from your
|
||||
the console provided a private key is stored or out-of-band access from your
|
||||
hosting provider. Vantage has no backdoor and does not keep a break-glass key.
|
||||
|
||||
@@ -13,14 +13,14 @@ back live.
|
||||
|
||||
A step has:
|
||||
|
||||
| Field | Meaning |
|
||||
| --- | --- |
|
||||
| `name`, `description` | Library identity |
|
||||
| `interpreter` | `bash` or `powershell` |
|
||||
| `script` | The body |
|
||||
| `declared_inputs` | Named parameters with defaults and descriptions |
|
||||
| `declared_outputs` | Names this step promises to export |
|
||||
| `secret_refs` | Vault entries injected as environment variables |
|
||||
| Field | Meaning |
|
||||
| --------------------- | ----------------------------------------------- |
|
||||
| `name`, `description` | Library identity |
|
||||
| `interpreter` | `bash` or `powershell` |
|
||||
| `script` | The body |
|
||||
| `declared_inputs` | Named parameters with defaults and descriptions |
|
||||
| `declared_outputs` | Names this step promises to export |
|
||||
| `secret_refs` | Vault entries injected as environment variables |
|
||||
|
||||
### Passing values between steps
|
||||
|
||||
@@ -59,7 +59,7 @@ new install is not staring at an empty page.
|
||||
:::warning Default steps are read-only
|
||||
Editing or deleting one is refused with `409`. Seeding rewrites them on every
|
||||
boot, so an edit would silently revert and a delete would come back at the next
|
||||
restart — refusing is the honest answer.
|
||||
restart refusing is the honest answer.
|
||||
|
||||
To customise one, use the per-step **script override** in the workflow designer,
|
||||
which belongs to that workflow and is not touched by seeding. To add to the
|
||||
@@ -67,7 +67,7 @@ shared library permanently, a file has to be committed to the repository and the
|
||||
server image rebuilt.
|
||||
:::
|
||||
|
||||
The UI mirrors this — the step modal opens read-only and Delete is hidden — but
|
||||
The UI mirrors this the step modal opens read-only and Delete is hidden but
|
||||
the API is the boundary; the UI is the courtesy.
|
||||
|
||||
## Building a workflow
|
||||
@@ -80,11 +80,11 @@ the API is the boundary; the UI is the courtesy.
|
||||
|
||||
### Failure behaviour
|
||||
|
||||
| `on_failure` | Effect |
|
||||
| --- | --- |
|
||||
| `stop` | Abort this server's run. Other servers continue |
|
||||
| `continue` | Record the failure, run the next step anyway |
|
||||
| `retry` | Re-run the step up to `max_retries`, then treat it as a failure |
|
||||
| `on_failure` | Effect |
|
||||
| ------------ | --------------------------------------------------------------- |
|
||||
| `stop` | Abort this server's run. Other servers continue |
|
||||
| `continue` | Record the failure, run the next step anyway |
|
||||
| `retry` | Re-run the step up to `max_retries`, then treat it as a failure |
|
||||
|
||||
### Per-step overrides
|
||||
|
||||
@@ -94,9 +94,8 @@ scoped to that workflow.
|
||||
|
||||
## Running
|
||||
|
||||
**Run** snapshots the resolved steps into the run record and dispatches
|
||||
`RunStepCmd` to each target's agent over the command stream — no waiting for the
|
||||
next poll.
|
||||
**Run** snapshots the resolved steps into the run record and dispatches each step
|
||||
to the target's agent over the command stream no waiting for the next poll.
|
||||
|
||||
:::info Runs freeze their steps
|
||||
The snapshot is why editing a step tomorrow never rewrites what happened today.
|
||||
@@ -119,11 +118,11 @@ nothing further is dispatched.
|
||||
Run logs are swept on a schedule set by `workflow_log_retention_days` in
|
||||
Settings:
|
||||
|
||||
| Value | Meaning |
|
||||
| --- | --- |
|
||||
| unset | 30 days |
|
||||
| Value | Meaning |
|
||||
| -------- | -------------- |
|
||||
| unset | 30 days |
|
||||
| a number | that many days |
|
||||
| `0` | keep forever |
|
||||
| `0` | keep forever |
|
||||
|
||||
## Import and export
|
||||
|
||||
|
||||
+3
-23
@@ -36,37 +36,17 @@ const sidebars: SidebarsConfig = {
|
||||
{
|
||||
type: "category",
|
||||
label: "Vantage HQ",
|
||||
items: [
|
||||
"hq/accounts-and-signup",
|
||||
"hq/people-and-roles",
|
||||
"hq/cloud-instances",
|
||||
"hq/self-hosted-instances",
|
||||
"hq/licensing-and-entitlements",
|
||||
"hq/billing",
|
||||
"hq/free-tier",
|
||||
],
|
||||
items: ["hq/accounts-and-signup", "hq/people-and-roles", "hq/cloud-instances", "hq/self-hosted-instances", "hq/licensing-and-entitlements", "hq/billing", "hq/free-tier"],
|
||||
},
|
||||
{
|
||||
type: "category",
|
||||
label: "Reference",
|
||||
items: [
|
||||
"reference/environment-variables",
|
||||
"reference/rest-api",
|
||||
"reference/grpc-api",
|
||||
"reference/agent-config",
|
||||
"reference/ports-and-networking",
|
||||
"reference/troubleshooting",
|
||||
],
|
||||
items: ["reference/environment-variables", "reference/rest-api", "reference/agent-config", "reference/ports-and-networking", "reference/troubleshooting"],
|
||||
},
|
||||
{
|
||||
type: "category",
|
||||
label: "Operations",
|
||||
items: [
|
||||
"operations/upgrading",
|
||||
"operations/backups",
|
||||
"operations/agent-updates",
|
||||
"operations/ci-cd",
|
||||
],
|
||||
items: ["operations/upgrading", "operations/backups", "operations/agent-updates"],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -14,6 +14,7 @@ service Vantage {
|
||||
rpc ReportChecks(ReportChecksRequest) returns (ReportChecksResponse);
|
||||
// Bidirectional stream: agent sends auth once, server pushes commands.
|
||||
rpc CommandStream(stream AgentMessage) returns (stream ServerCommand);
|
||||
rpc ProxyStream(stream ProxyClientMsg) returns (stream ProxyServerMsg);
|
||||
}
|
||||
|
||||
message RegisterRequest {
|
||||
@@ -180,6 +181,7 @@ message ServerCommand {
|
||||
ApplyUpdatesCmd apply_updates = 5;
|
||||
RunStepCmd run_step = 6;
|
||||
CleanupWorkspaceCmd cleanup_workspace = 7;
|
||||
OpenProxyCmd open_proxy = 8;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,3 +230,33 @@ message StepOutputChunk {
|
||||
bytes data = 3;
|
||||
bool eof = 4;
|
||||
}
|
||||
|
||||
// OpenProxyCmd tells the agent to dial 127.0.0.1:port locally and relay that
|
||||
// connection back over a fresh ProxyStream identified by proxy_id.
|
||||
message OpenProxyCmd {
|
||||
string proxy_id = 1;
|
||||
uint32 port = 2;
|
||||
}
|
||||
|
||||
message ProxyOpen {
|
||||
string server_id = 1;
|
||||
string agent_token = 2;
|
||||
string proxy_id = 3;
|
||||
}
|
||||
|
||||
message ProxyClose { string reason = 1; }
|
||||
|
||||
message ProxyClientMsg {
|
||||
oneof payload {
|
||||
ProxyOpen open = 1; // first message only
|
||||
bytes data = 2;
|
||||
ProxyClose close = 3;
|
||||
}
|
||||
}
|
||||
|
||||
message ProxyServerMsg {
|
||||
oneof payload {
|
||||
bytes data = 1;
|
||||
ProxyClose close = 2;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -33,6 +35,15 @@ func consoleConnect(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if srv.Status != "active" {
|
||||
c.JSON(http.StatusConflict, gin.H{
|
||||
"error": "agent_offline",
|
||||
"message": "The agent on this server is not connected. " +
|
||||
"Console sessions are relayed by the agent, so it must be online.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
sess, err := services.CreateConsoleSession(auth.InstanceID(c), body.ServerID, body.Protocol, body.KeyID, actorFromCtx(c), c.ClientIP())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
@@ -59,7 +70,7 @@ func consoleConnect(c *gin.Context) {
|
||||
}
|
||||
|
||||
services.LogEvent(auth.InstanceID(c), "console.opened", actorFromCtx(c), srv.ServerID, "",
|
||||
"console session opened ("+body.Protocol+")")
|
||||
"console session opened ("+body.Protocol+", agent-relayed)")
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"session_id": sess.SessionID,
|
||||
@@ -124,7 +135,43 @@ func consoleTunnel(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
}
|
||||
gp, err := services.BuildGuacParams(srv, sess.Protocol, sess.SSHUsername, privKey, passphrase, rdpUser, rdpPass)
|
||||
targetPort, err := services.TargetPort(srv, sess.Protocol)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
relay, err := services.OpenConsoleProxy(instanceID, srv.ServerID, targetPort)
|
||||
if err != nil {
|
||||
if errors.Is(err, services.ErrAgentOffline) {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "agent_offline"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not open relay"})
|
||||
return
|
||||
}
|
||||
// guac.WebsocketServer.ServeHTTP returns before installing its
|
||||
// OnDisconnect handler when the connect callback errors, which is exactly
|
||||
// the path every relay failure this proxy introduces takes (the agent
|
||||
// never claims it, dial_refused, guacd never dials, rejected). Emitting
|
||||
// console.proxy_failed and ending the session here, unconditionally on
|
||||
// teardown, is what makes those failures reach the audit log at all;
|
||||
// OnDisconnect below only sees the rarer case of a session that was fully
|
||||
// established and then failed.
|
||||
defer func() {
|
||||
relay.Close()
|
||||
if reason := relay.Reason(); reason != "" {
|
||||
services.LogEvent(instanceID, "console.proxy_failed", actorFromCtx(c), srv.ServerID, "",
|
||||
fmt.Sprintf("console relay failed: %s (proxy_id=%s, port=%d)", reason, relay.ProxyID, relay.Port))
|
||||
}
|
||||
_ = services.EndConsoleSession(instanceID, sessionID)
|
||||
}()
|
||||
|
||||
services.LogEvent(instanceID, "console.proxy_opened", actorFromCtx(c), srv.ServerID, "",
|
||||
fmt.Sprintf("console relay opened (proxy_id=%s, port=%d)", relay.ProxyID, relay.Port))
|
||||
|
||||
gp, err := services.BuildGuacParams(sess.Protocol, sess.SSHUsername, privKey, passphrase,
|
||||
rdpUser, rdpPass, relay.Host, relay.Port)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -160,9 +207,9 @@ func consoleTunnel(c *gin.Context) {
|
||||
return guac.NewSimpleTunnel(stream), nil
|
||||
}
|
||||
|
||||
// Teardown (proxy_failed audit + EndConsoleSession) lives in the deferred
|
||||
// func above, not here: this only fires once a tunnel was actually
|
||||
// established, and letting both paths log would double the audit event.
|
||||
wsServer := guac.NewWebsocketServer(connect)
|
||||
wsServer.OnDisconnect = func(id string, r *http.Request, t guac.Tunnel) {
|
||||
_ = services.EndConsoleSession(instanceID, sessionID)
|
||||
}
|
||||
wsServer.ServeHTTP(c.Writer, c.Request)
|
||||
}
|
||||
|
||||
@@ -123,6 +123,32 @@ type ReportChecksResponse struct{}
|
||||
|
||||
type ApplyUpdatesCmd struct{}
|
||||
|
||||
type OpenProxyCmd struct {
|
||||
ProxyId string `json:"proxy_id"`
|
||||
Port uint32 `json:"port"`
|
||||
}
|
||||
|
||||
type ProxyOpen struct {
|
||||
ServerId string `json:"server_id"`
|
||||
AgentToken string `json:"agent_token"`
|
||||
ProxyId string `json:"proxy_id"`
|
||||
}
|
||||
|
||||
type ProxyClose struct {
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
type ProxyClientMsg struct {
|
||||
Open *ProxyOpen `json:"open,omitempty"`
|
||||
Data []byte `json:"data,omitempty"`
|
||||
Close *ProxyClose `json:"close,omitempty"`
|
||||
}
|
||||
|
||||
type ProxyServerMsg struct {
|
||||
Data []byte `json:"data,omitempty"`
|
||||
Close *ProxyClose `json:"close,omitempty"`
|
||||
}
|
||||
|
||||
type ServerCommand struct {
|
||||
CommandId string `json:"command_id"`
|
||||
GenerateKey *GenerateKeyCmd `json:"generate_key,omitempty"`
|
||||
@@ -131,6 +157,7 @@ type ServerCommand struct {
|
||||
ApplyUpdates *ApplyUpdatesCmd `json:"apply_updates,omitempty"`
|
||||
RunStep *RunStepCmd `json:"run_step,omitempty"`
|
||||
CleanupWorkspace *CleanupWorkspaceCmd `json:"cleanup_workspace,omitempty"`
|
||||
OpenProxy *OpenProxyCmd `json:"open_proxy,omitempty"`
|
||||
}
|
||||
|
||||
type CleanupWorkspaceCmd struct {
|
||||
@@ -239,6 +266,55 @@ func (c *vantageCommandStreamClient) Recv() (*ServerCommand, error) {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
type Vantage_ProxyStreamServer interface {
|
||||
Send(*ProxyServerMsg) error
|
||||
Recv() (*ProxyClientMsg, error)
|
||||
grpc.ServerStream
|
||||
}
|
||||
|
||||
type vantageProxyStreamServer struct {
|
||||
grpc.ServerStream
|
||||
}
|
||||
|
||||
func (s *vantageProxyStreamServer) Send(m *ProxyServerMsg) error {
|
||||
return s.ServerStream.SendMsg(m)
|
||||
}
|
||||
|
||||
func (s *vantageProxyStreamServer) Recv() (*ProxyClientMsg, error) {
|
||||
m := new(ProxyClientMsg)
|
||||
if err := s.ServerStream.RecvMsg(m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
type Vantage_ProxyStreamClient interface {
|
||||
Send(*ProxyClientMsg) error
|
||||
Recv() (*ProxyServerMsg, error)
|
||||
CloseSend() error
|
||||
grpc.ClientStream
|
||||
}
|
||||
|
||||
type vantageProxyStreamClient struct {
|
||||
grpc.ClientStream
|
||||
}
|
||||
|
||||
func (c *vantageProxyStreamClient) Send(m *ProxyClientMsg) error {
|
||||
return c.ClientStream.SendMsg(m)
|
||||
}
|
||||
|
||||
func (c *vantageProxyStreamClient) Recv() (*ProxyServerMsg, error) {
|
||||
m := new(ProxyServerMsg)
|
||||
if err := c.ClientStream.RecvMsg(m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func _Vantage_ProxyStream_Handler(srv interface{}, stream grpc.ServerStream) error {
|
||||
return srv.(VantageServer).ProxyStream(&vantageProxyStreamServer{stream})
|
||||
}
|
||||
|
||||
type VantageServer interface {
|
||||
Register(context.Context, *RegisterRequest) (*RegisterResponse, error)
|
||||
SyncKeys(context.Context, *SyncRequest) (*SyncResponse, error)
|
||||
@@ -248,6 +324,7 @@ type VantageServer interface {
|
||||
SyncMonitors(context.Context, *SyncMonitorsRequest) (*SyncMonitorsResponse, error)
|
||||
ReportChecks(context.Context, *ReportChecksRequest) (*ReportChecksResponse, error)
|
||||
CommandStream(Vantage_CommandStreamServer) error
|
||||
ProxyStream(Vantage_ProxyStreamServer) error
|
||||
}
|
||||
|
||||
type UnimplementedVantageServer struct{}
|
||||
@@ -284,6 +361,10 @@ func (UnimplementedVantageServer) CommandStream(Vantage_CommandStreamServer) err
|
||||
return status.Errorf(codes.Unimplemented, "method CommandStream not implemented")
|
||||
}
|
||||
|
||||
func (UnimplementedVantageServer) ProxyStream(Vantage_ProxyStreamServer) error {
|
||||
return status.Errorf(codes.Unimplemented, "method ProxyStream not implemented")
|
||||
}
|
||||
|
||||
type VantageClient interface {
|
||||
Register(ctx context.Context, in *RegisterRequest, opts ...grpc.CallOption) (*RegisterResponse, error)
|
||||
SyncKeys(ctx context.Context, in *SyncRequest, opts ...grpc.CallOption) (*SyncResponse, error)
|
||||
@@ -293,6 +374,7 @@ type VantageClient interface {
|
||||
SyncMonitors(ctx context.Context, in *SyncMonitorsRequest, opts ...grpc.CallOption) (*SyncMonitorsResponse, error)
|
||||
ReportChecks(ctx context.Context, in *ReportChecksRequest, opts ...grpc.CallOption) (*ReportChecksResponse, error)
|
||||
CommandStream(ctx context.Context, opts ...grpc.CallOption) (Vantage_CommandStreamClient, error)
|
||||
ProxyStream(ctx context.Context, opts ...grpc.CallOption) (Vantage_ProxyStreamClient, error)
|
||||
}
|
||||
|
||||
type keyManagerClient struct {
|
||||
@@ -367,6 +449,14 @@ func (c *keyManagerClient) CommandStream(ctx context.Context, opts ...grpc.CallO
|
||||
return &vantageCommandStreamClient{stream}, nil
|
||||
}
|
||||
|
||||
func (c *keyManagerClient) ProxyStream(ctx context.Context, opts ...grpc.CallOption) (Vantage_ProxyStreamClient, error) {
|
||||
stream, err := c.cc.NewStream(ctx, &Vantage_ServiceDesc.Streams[1], "/vantage.v1.Vantage/ProxyStream", opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &vantageProxyStreamClient{stream}, nil
|
||||
}
|
||||
|
||||
func RegisterVantageServer(s grpc.ServiceRegistrar, srv VantageServer) {
|
||||
s.RegisterService(&Vantage_ServiceDesc, srv)
|
||||
}
|
||||
@@ -390,6 +480,12 @@ var Vantage_ServiceDesc = grpc.ServiceDesc{
|
||||
ServerStreams: true,
|
||||
ClientStreams: true,
|
||||
},
|
||||
{
|
||||
StreamName: "ProxyStream",
|
||||
Handler: _Vantage_ProxyStream_Handler,
|
||||
ServerStreams: true,
|
||||
ClientStreams: true,
|
||||
},
|
||||
},
|
||||
Metadata: "vantage/v1/vantage.proto",
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package pb
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestProxyClientMsgRoundTrip(t *testing.T) {
|
||||
in := &ProxyClientMsg{Data: []byte{0x00, 0xff, 0x10}}
|
||||
raw, err := json.Marshal(in)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
var out ProxyClientMsg
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if string(out.Data) != string(in.Data) {
|
||||
t.Fatalf("data mismatch: got %v want %v", out.Data, in.Data)
|
||||
}
|
||||
if out.Open != nil || out.Close != nil {
|
||||
t.Fatalf("empty oneof fields should stay nil, got open=%v close=%v", out.Open, out.Close)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenProxyCmdOnServerCommand(t *testing.T) {
|
||||
cmd := &ServerCommand{CommandId: "c1", OpenProxy: &OpenProxyCmd{ProxyId: "p1", Port: 22}}
|
||||
raw, err := json.Marshal(cmd)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
var out ServerCommand
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if out.OpenProxy == nil || out.OpenProxy.ProxyId != "p1" || out.OpenProxy.Port != 22 {
|
||||
t.Fatalf("open_proxy did not round-trip: %+v", out.OpenProxy)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package grpcserver
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/grpc/pb"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/proxy"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
// ProxyStream carries one console TCP connection. The agent opens it after
|
||||
// dialling its own loopback address, and authenticates with the same agent
|
||||
// token as the command stream plus the single-use proxy_id it was handed.
|
||||
func (s *vantageServer) ProxyStream(stream pb.Vantage_ProxyStreamServer) error {
|
||||
msg, err := stream.Recv()
|
||||
if err != nil {
|
||||
return status.Errorf(codes.InvalidArgument, "expected initial open message: %v", err)
|
||||
}
|
||||
if msg.Open == nil {
|
||||
return status.Error(codes.InvalidArgument, "first message must be open")
|
||||
}
|
||||
|
||||
srv, err := services.ValidateAgentToken(msg.Open.ServerId, msg.Open.AgentToken)
|
||||
if err != nil {
|
||||
// Deliberately identical to the claim-failure response below: a bad
|
||||
// token, an unknown proxy_id, and a proxy_id belonging to another
|
||||
// instance/server must be indistinguishable to the caller.
|
||||
log.Printf("proxy %s (server %s): invalid agent token", msg.Open.ProxyId, msg.Open.ServerId)
|
||||
return status.Error(codes.PermissionDenied, "proxy session unavailable")
|
||||
}
|
||||
|
||||
if err := serveProxy(proxy.Default, msg.Open, srv.InstanceID, stream); err != nil {
|
||||
// The reason is deliberately not returned to the agent: an unknown and a
|
||||
// foreign proxy_id must be indistinguishable.
|
||||
log.Printf("proxy %s (server %s): %v", msg.Open.ProxyId, msg.Open.ServerId, err)
|
||||
return status.Error(codes.PermissionDenied, "proxy session unavailable")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// serveProxy claims the pending session and relays it. Split out from the gRPC
|
||||
// method so the authorisation matrix is testable without a real stream.
|
||||
func serveProxy(reg *proxy.Registry, open *pb.ProxyOpen, instanceID string, stream proxy.AgentStream) error {
|
||||
entry, err := reg.Claim(instanceID, open.ServerId, open.ProxyId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return entry.Session.Serve(stream)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// Package proxy relays console TCP traffic between guacd and a managed server's
|
||||
// agent. The agent dials only its own loopback address; the port is the single
|
||||
// value it takes from the control plane.
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"sync"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNotFound = errors.New("proxy session not found")
|
||||
ErrForbidden = errors.New("proxy session belongs to another server")
|
||||
)
|
||||
|
||||
// NewID returns a 32-byte random identifier as hex. It is the only credential
|
||||
// tying an incoming ProxyStream to a pending console session.
|
||||
func NewID() (string, error) {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
type Entry struct {
|
||||
ProxyID string
|
||||
InstanceID string
|
||||
ServerID string
|
||||
Session *Session
|
||||
}
|
||||
|
||||
type Registry struct {
|
||||
mu sync.Mutex
|
||||
entries map[string]*Entry
|
||||
}
|
||||
|
||||
func NewRegistry() *Registry {
|
||||
return &Registry{entries: make(map[string]*Entry)}
|
||||
}
|
||||
|
||||
var Default = NewRegistry()
|
||||
|
||||
func (r *Registry) Add(e *Entry) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.entries[e.ProxyID] = e
|
||||
}
|
||||
|
||||
// Claim removes and returns the entry. It is single-use: a second claim on the
|
||||
// same proxy_id gets ErrNotFound. A claim whose instance or server does not
|
||||
// match leaves the entry in place and gets ErrForbidden.
|
||||
func (r *Registry) Claim(instanceID, serverID, proxyID string) (*Entry, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
e, ok := r.entries[proxyID]
|
||||
if !ok {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if e.InstanceID != instanceID || e.ServerID != serverID {
|
||||
return nil, ErrForbidden
|
||||
}
|
||||
delete(r.entries, proxyID)
|
||||
return e, nil
|
||||
}
|
||||
|
||||
func (r *Registry) Remove(proxyID string) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
delete(r.entries, proxyID)
|
||||
}
|
||||
|
||||
func (r *Registry) Len() int {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return len(r.entries)
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/grpc/pb"
|
||||
)
|
||||
|
||||
const (
|
||||
chunkSize = 32 * 1024
|
||||
rendezvousTimeout = 10 * time.Second
|
||||
)
|
||||
|
||||
// AgentStream is the server's half of a ProxyStream. It is an interface so the
|
||||
// relay can be tested without gRPC.
|
||||
type AgentStream interface {
|
||||
Send(*pb.ProxyServerMsg) error
|
||||
Recv() (*pb.ProxyClientMsg, error)
|
||||
}
|
||||
|
||||
// Session owns one ephemeral listener and relays the single connection that
|
||||
// arrives on it to an agent stream.
|
||||
type Session struct {
|
||||
listener net.Listener
|
||||
allowed []string
|
||||
timeout time.Duration
|
||||
|
||||
once sync.Once
|
||||
mu sync.Mutex
|
||||
reason string
|
||||
conn net.Conn
|
||||
closing bool
|
||||
|
||||
rendezvous *time.Timer
|
||||
}
|
||||
|
||||
// NewSession binds an ephemeral port on listenHost. allowed is the set of IPs
|
||||
// permitted to connect; an empty set allows any, which is the degraded case
|
||||
// when guacd's host could not be resolved.
|
||||
//
|
||||
// A watchdog is armed immediately: if the agent never opens its ProxyStream
|
||||
// and calls Serve, nothing else would ever bound how long the listener (and
|
||||
// the registry entry that references it) stays open. Serve cancels it once
|
||||
// entered and re-arms the same deadline for the accept wait.
|
||||
func NewSession(listenHost string, allowed []string) (*Session, error) {
|
||||
ln, err := net.Listen("tcp", net.JoinHostPort(listenHost, "0"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("bind relay listener: %w", err)
|
||||
}
|
||||
s := &Session{listener: ln, allowed: allowed, timeout: rendezvousTimeout}
|
||||
s.rendezvous = time.AfterFunc(rendezvousTimeout, func() {
|
||||
s.Close("agent_timeout")
|
||||
})
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Session) Port() int {
|
||||
return s.listener.Addr().(*net.TCPAddr).Port
|
||||
}
|
||||
|
||||
// Reason reports why the session ended, empty if it ended cleanly.
|
||||
func (s *Session) Reason() string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.reason
|
||||
}
|
||||
|
||||
// setReason records r as the session's failure reason, first write wins. It is
|
||||
// a no-op once a deliberate teardown (Close) has begun: a local Close closing
|
||||
// the conn out from under the relay goroutines produces exactly the kind of
|
||||
// error (net.ErrClosed, a broken pipe on write, ...) that looks like a remote
|
||||
// failure but is not one, and must not overwrite — or race to set — the real
|
||||
// reason, or invent one where a clean local close has none.
|
||||
func (s *Session) setReason(r string) {
|
||||
s.mu.Lock()
|
||||
if s.reason == "" && !s.closing {
|
||||
s.reason = r
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// Close tears the session down once. A non-empty reason is recorded only if no
|
||||
// reason has been recorded already, and only before teardown begins. It closes
|
||||
// both the listener and, if a connection has already been accepted, that
|
||||
// connection too — an unconditional kill for the whole relay chain regardless
|
||||
// of which stage it is in.
|
||||
func (s *Session) Close(reason string) {
|
||||
if reason != "" {
|
||||
s.setReason(reason)
|
||||
}
|
||||
s.once.Do(func() {
|
||||
// Recorded before anything is actually closed: the reader/writer
|
||||
// goroutines in relay() call setReason from the errors this Close
|
||||
// itself is about to cause (a closed conn, a closed stream), and
|
||||
// those must be recognised as teardown noise, not a genuine failure.
|
||||
s.mu.Lock()
|
||||
s.closing = true
|
||||
conn := s.conn
|
||||
s.mu.Unlock()
|
||||
|
||||
if s.rendezvous != nil {
|
||||
s.rendezvous.Stop()
|
||||
}
|
||||
_ = s.listener.Close()
|
||||
if conn != nil {
|
||||
_ = conn.Close()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Session) setConn(conn net.Conn) {
|
||||
s.mu.Lock()
|
||||
s.conn = conn
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// Serve accepts exactly one connection, verifies its source, and relays until
|
||||
// either side ends. It always closes the listener before returning.
|
||||
func (s *Session) Serve(stream AgentStream) error {
|
||||
defer s.Close("")
|
||||
|
||||
// The agent has claimed the session and opened its stream, so the
|
||||
// unclaimed-rendezvous watchdog no longer applies; the accept deadline set
|
||||
// just below takes over for the claimed case.
|
||||
if s.rendezvous != nil {
|
||||
s.rendezvous.Stop()
|
||||
}
|
||||
|
||||
if l, ok := s.listener.(*net.TCPListener); ok {
|
||||
_ = l.SetDeadline(time.Now().Add(s.timeout))
|
||||
}
|
||||
|
||||
// Accept until a connection from an allowed source arrives or the
|
||||
// rendezvous deadline (set once, above) expires. A rejected connection is
|
||||
// closed and the loop retries within the same deadline; the listener is
|
||||
// only closed once we have a valid connection, the deadline expires, or
|
||||
// Accept fails for a genuine (non-timeout) reason.
|
||||
for {
|
||||
conn, err := s.listener.Accept()
|
||||
if err != nil {
|
||||
var netErr net.Error
|
||||
if errors.Is(err, os.ErrDeadlineExceeded) || (errors.As(err, &netErr) && netErr.Timeout()) {
|
||||
s.setReason("guacd_timeout")
|
||||
} else {
|
||||
s.setReason("accept_failed")
|
||||
}
|
||||
return fmt.Errorf("waiting for guacd: %w", err)
|
||||
}
|
||||
|
||||
if !allowedRemote(conn.RemoteAddr().String(), s.allowed) {
|
||||
_ = conn.Close()
|
||||
continue
|
||||
}
|
||||
|
||||
// One connection only: nothing else may claim this port. Store the
|
||||
// conn first so a concurrent external Close (e.g. the browser tab
|
||||
// closing) can reach it via the sync.Once teardown; then close just
|
||||
// the listener directly (not through Close, which would also close
|
||||
// the conn we are about to relay). The deferred s.Close("") above
|
||||
// performs the real one-shot teardown, including this conn, once
|
||||
// relay returns.
|
||||
s.setConn(conn)
|
||||
_ = s.listener.Close()
|
||||
|
||||
return s.relay(conn, stream)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Session) relay(conn net.Conn, stream AgentStream) error {
|
||||
errCh := make(chan error, 2)
|
||||
|
||||
// guacd -> agent
|
||||
go func() {
|
||||
buf := make([]byte, chunkSize)
|
||||
for {
|
||||
n, err := conn.Read(buf)
|
||||
if n > 0 {
|
||||
chunk := make([]byte, n)
|
||||
copy(chunk, buf[:n])
|
||||
if sendErr := stream.Send(&pb.ProxyServerMsg{Data: chunk}); sendErr != nil {
|
||||
errCh <- sendErr
|
||||
return
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
if !errors.Is(err, io.EOF) {
|
||||
s.setReason("guacd_read_error")
|
||||
}
|
||||
errCh <- err
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// agent -> guacd
|
||||
go func() {
|
||||
for {
|
||||
msg, err := stream.Recv()
|
||||
if err != nil {
|
||||
errCh <- err
|
||||
return
|
||||
}
|
||||
if msg.Close != nil {
|
||||
s.setReason(msg.Close.Reason)
|
||||
errCh <- fmt.Errorf("agent closed relay: %s", msg.Close.Reason)
|
||||
return
|
||||
}
|
||||
if len(msg.Data) > 0 {
|
||||
if _, err := conn.Write(msg.Data); err != nil {
|
||||
errCh <- err
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
err := <-errCh
|
||||
_ = conn.Close()
|
||||
if errors.Is(err, io.EOF) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// allowedRemote reports whether remote (a host:port string) is in allowed. An
|
||||
// empty allowed list permits anything.
|
||||
func allowedRemote(remote string, allowed []string) bool {
|
||||
host, _, err := net.SplitHostPort(remote)
|
||||
if err != nil {
|
||||
log.Printf("proxy: unparseable remote address %q", remote)
|
||||
return false
|
||||
}
|
||||
if len(allowed) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, a := range allowed {
|
||||
if a == host {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -77,20 +77,37 @@ type GuacParams struct {
|
||||
Params map[string]string
|
||||
}
|
||||
|
||||
func portOr(v, def int) string {
|
||||
func portOr(v, def int) int {
|
||||
if v == 0 {
|
||||
v = def
|
||||
return def
|
||||
}
|
||||
return strconv.Itoa(v)
|
||||
return v
|
||||
}
|
||||
|
||||
func BuildGuacParams(srv *models.Server, protocol, sshUser, privateKey, passphrase, rdpUser, rdpPass string) (*GuacParams, error) {
|
||||
host := srv.IPAddress
|
||||
// TargetPort is the port on the managed server that the agent will dial on its
|
||||
// own loopback address.
|
||||
func TargetPort(srv *models.Server, protocol string) (int, error) {
|
||||
switch protocol {
|
||||
case "ssh":
|
||||
return portOr(srv.SSHPort, 22), nil
|
||||
case "rdp":
|
||||
return portOr(srv.RDPPort, 3389), nil
|
||||
case "vnc":
|
||||
return 5900, nil
|
||||
default:
|
||||
return 0, fmt.Errorf("unsupported protocol %q", protocol)
|
||||
}
|
||||
}
|
||||
|
||||
// BuildGuacParams points guacd at the relay listener, never at the managed
|
||||
// server: on a cloud deployment the server's address is not routable from here.
|
||||
func BuildGuacParams(protocol, sshUser, privateKey, passphrase, rdpUser, rdpPass, relayHost string, relayPort int) (*GuacParams, error) {
|
||||
port := strconv.Itoa(relayPort)
|
||||
switch protocol {
|
||||
case "ssh":
|
||||
p := map[string]string{
|
||||
"hostname": host,
|
||||
"port": portOr(srv.SSHPort, 22),
|
||||
"hostname": relayHost,
|
||||
"port": port,
|
||||
}
|
||||
if sshUser == "" {
|
||||
sshUser = "root"
|
||||
@@ -105,8 +122,8 @@ func BuildGuacParams(srv *models.Server, protocol, sshUser, privateKey, passphra
|
||||
return &GuacParams{Protocol: "ssh", Params: p}, nil
|
||||
case "rdp":
|
||||
return &GuacParams{Protocol: "rdp", Params: map[string]string{
|
||||
"hostname": host,
|
||||
"port": portOr(srv.RDPPort, 3389),
|
||||
"hostname": relayHost,
|
||||
"port": port,
|
||||
"username": rdpUser,
|
||||
"password": rdpPass,
|
||||
"security": "any",
|
||||
@@ -114,8 +131,8 @@ func BuildGuacParams(srv *models.Server, protocol, sshUser, privateKey, passphra
|
||||
}}, nil
|
||||
case "vnc":
|
||||
return &GuacParams{Protocol: "vnc", Params: map[string]string{
|
||||
"hostname": host,
|
||||
"port": "5900",
|
||||
"hostname": relayHost,
|
||||
"port": port,
|
||||
"password": rdpPass,
|
||||
}}, nil
|
||||
default:
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/grpc/pb"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/proxy"
|
||||
)
|
||||
|
||||
// ErrAgentOffline means the console cannot be opened because the target's agent
|
||||
// is not on the command stream. Every console session is relayed by the agent,
|
||||
// so this is fatal rather than a degraded mode.
|
||||
var ErrAgentOffline = errors.New("agent is not connected")
|
||||
|
||||
// ConsoleProxy is a pending relay: a bound listener guacd can dial and a
|
||||
// dispatched command telling the agent to meet it.
|
||||
type ConsoleProxy struct {
|
||||
ProxyID string
|
||||
Host string
|
||||
Port int
|
||||
|
||||
session *proxy.Session
|
||||
}
|
||||
|
||||
func (c *ConsoleProxy) Close() {
|
||||
proxy.Default.Remove(c.ProxyID)
|
||||
c.session.Close("")
|
||||
}
|
||||
|
||||
func (c *ConsoleProxy) Reason() string { return c.session.Reason() }
|
||||
|
||||
func proxyListenHost() string {
|
||||
if v := os.Getenv("PROXY_LISTEN_HOST"); v != "" {
|
||||
return v
|
||||
}
|
||||
return "0.0.0.0"
|
||||
}
|
||||
|
||||
func proxyAdvertiseHost() string {
|
||||
if v := os.Getenv("PROXY_ADVERTISE_HOST"); v != "" {
|
||||
return v
|
||||
}
|
||||
return "server"
|
||||
}
|
||||
|
||||
func guacdAddr() string {
|
||||
if v := os.Getenv("GUACD_ADDR"); v != "" {
|
||||
return v
|
||||
}
|
||||
return "guacd:4822"
|
||||
}
|
||||
|
||||
// guacdHosts resolves guacd's address to the IPs allowed to claim a relay
|
||||
// listener. An unresolvable host yields an empty set, which allows any source:
|
||||
// refusing everything would take the console down entirely, so the narrower
|
||||
// protections (ephemeral port, 10s window, single accept) carry it instead.
|
||||
func guacdHosts(addr string) []string {
|
||||
host, _, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
host = addr
|
||||
}
|
||||
if ip := net.ParseIP(host); ip != nil {
|
||||
return []string{ip.String()}
|
||||
}
|
||||
ips, err := net.LookupHost(host)
|
||||
if err != nil {
|
||||
log.Printf("proxy: cannot resolve guacd host %q, allowing any relay source: %v", host, err)
|
||||
return nil
|
||||
}
|
||||
return ips
|
||||
}
|
||||
|
||||
// DispatchOpenProxy tells the agent to dial its own loopback on port and relay
|
||||
// it back under proxyID.
|
||||
func DispatchOpenProxy(serverID, proxyID string, port uint32) error {
|
||||
return Dispatcher.dispatch(serverID, &pb.ServerCommand{
|
||||
CommandId: proxyID,
|
||||
OpenProxy: &pb.OpenProxyCmd{ProxyId: proxyID, Port: port},
|
||||
})
|
||||
}
|
||||
|
||||
// OpenConsoleProxy binds a relay listener, registers it, and asks the agent to
|
||||
// connect. The caller must Close the result.
|
||||
func OpenConsoleProxy(instanceID, serverID string, targetPort int) (*ConsoleProxy, error) {
|
||||
if !Dispatcher.IsConnected(serverID) {
|
||||
return nil, ErrAgentOffline
|
||||
}
|
||||
|
||||
proxyID, err := proxy.NewID()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generate proxy id: %w", err)
|
||||
}
|
||||
|
||||
sess, err := proxy.NewSession(proxyListenHost(), guacdHosts(guacdAddr()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
proxy.Default.Add(&proxy.Entry{
|
||||
ProxyID: proxyID,
|
||||
InstanceID: instanceID,
|
||||
ServerID: serverID,
|
||||
Session: sess,
|
||||
})
|
||||
|
||||
if err := DispatchOpenProxy(serverID, proxyID, uint32(targetPort)); err != nil {
|
||||
proxy.Default.Remove(proxyID)
|
||||
sess.Close("dispatch_failed")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &ConsoleProxy{
|
||||
ProxyID: proxyID,
|
||||
Host: proxyAdvertiseHost(),
|
||||
Port: sess.Port(),
|
||||
session: sess,
|
||||
}, nil
|
||||
}
|
||||
+1
-10
@@ -4,19 +4,10 @@ import type { NextConfig } from "next";
|
||||
// process, never in the browser, so this never needed the NEXT_PUBLIC_ prefix
|
||||
// that pins a value into the image at build time. NEXT_PUBLIC_API_URL is still
|
||||
// honoured so an existing deployment passing it keeps working.
|
||||
const apiUrl =
|
||||
process.env.API_URL ?? process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8080";
|
||||
const apiUrl = process.env.API_URL ?? process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8080";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
async redirects() {
|
||||
return [
|
||||
// Members and SSO moved onto /settings. Permanent, because the old
|
||||
// page is gone rather than temporarily unavailable — but it costs
|
||||
// nothing to keep an old bookmark or a linked support reply working.
|
||||
{ source: "/settings/instance", destination: "/settings", permanent: true },
|
||||
];
|
||||
},
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user