From 1a6cf03c03c7935de0c300e5a8ae06e816a0af58 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Fri, 24 Jul 2026 09:51:30 +0100 Subject: [PATCH] feat: Removed comments --- agent/internal/checker/checker.go | 28 +++---- agent/internal/config/config.go | 2 +- agent/internal/exec/exec.go | 36 ++++---- agent/internal/grpc/client.go | 8 +- agent/internal/grpc/pb/vantage.pb.go | 20 ++--- agent/internal/inventory/collect_linux.go | 6 +- agent/internal/inventory/collect_other.go | 4 +- agent/internal/inventory/inventory.go | 4 +- agent/internal/keys/keys.go | 34 ++++---- agent/internal/monitors/monitors.go | 16 ++-- agent/internal/sync/sync.go | 63 +++++++------- agent/internal/updates/updates.go | 21 +++-- installer/setup.ps1 | 2 - server/cmd/main.go | 34 ++++---- server/internal/api/console.go | 22 ++--- server/internal/api/handlers.go | 15 ++-- server/internal/api/install_ps1.go | 8 +- server/internal/api/org.go | 15 ++-- server/internal/api/secrets.go | 34 ++++---- server/internal/api/workflows.go | 16 ++-- server/internal/auth/local.go | 25 ++---- server/internal/auth/middleware.go | 2 - server/internal/auth/oidc.go | 9 +- server/internal/auth/orghost.go | 16 ++-- server/internal/checker/checker.go | 28 +++---- server/internal/grpc/codec.go | 4 +- server/internal/grpc/pb/vantage.pb.go | 30 +++---- server/internal/grpc/server.go | 20 ++--- server/internal/models/channel.go | 8 +- server/internal/models/console_session.go | 6 +- server/internal/models/key.go | 2 +- server/internal/models/monitor.go | 22 ++--- server/internal/models/secret.go | 6 +- server/internal/models/settings.go | 6 +- server/internal/models/user.go | 8 +- server/internal/models/workflow.go | 14 ++-- server/internal/monitorsched/scheduler.go | 13 +-- server/internal/notify/dispatch.go | 14 ++-- server/internal/notify/http.go | 2 +- server/internal/notify/smtp.go | 16 ++-- server/internal/notify/template.go | 13 ++- server/internal/services/channels.go | 8 +- server/internal/services/console.go | 32 +++---- server/internal/services/crypto.go | 6 +- server/internal/services/defaults.go | 12 +-- server/internal/services/dispatch.go | 44 +++++----- server/internal/services/inventory.go | 4 +- server/internal/services/keys.go | 7 +- server/internal/services/migrate.go | 48 +---------- server/internal/services/monitors.go | 69 ++++----------- server/internal/services/org_oidc.go | 4 +- server/internal/services/orgs.go | 29 ++----- server/internal/services/secrets.go | 24 +----- server/internal/services/servers.go | 56 ++++++------- server/internal/services/settings.go | 30 +++---- server/internal/services/stepio.go | 12 +-- server/internal/services/steplogs.go | 62 +++++++------- server/internal/services/stepresults.go | 12 +-- server/internal/services/stepscan.go | 12 +-- server/internal/services/sync.go | 4 +- server/internal/services/users.go | 16 ++-- server/internal/services/validate.go | 2 +- server/internal/services/workflow_runner.go | 84 +++++++++---------- server/internal/services/workflows.go | 24 +++--- site/app/layout.tsx | 6 +- site/app/platform/page.tsx | 10 +-- site/app/pricing/page.tsx | 8 +- site/components/Footer.tsx | 2 +- site/components/InstrumentPanel.tsx | 2 +- site/components/Logo.tsx | 4 +- site/components/Nav.tsx | 2 +- site/components/ThemeScript.tsx | 4 +- site/components/ThemeToggle.tsx | 4 +- sitesvc/cmd/main.go | 18 ++-- sitesvc/internal/api/api.go | 42 +++++----- sitesvc/internal/api/ratelimit.go | 12 +-- sitesvc/internal/api/signup.go | 30 +++---- sitesvc/internal/api/validate.go | 16 ++-- sitesvc/internal/mail/mail.go | 48 +++++------ sitesvc/internal/models/models.go | 14 ++-- sitesvc/internal/provision/provision.go | 12 +-- sitesvc/internal/store/store.go | 60 ++++++------- web/app/(app)/secrets/[group]/page.tsx | 2 +- web/app/(app)/servers/[id]/console/page.tsx | 26 +++--- web/app/(app)/settings/notifications/page.tsx | 2 +- web/app/(app)/settings/org/page.tsx | 12 +-- web/app/(app)/settings/page.tsx | 6 +- web/app/(app)/workflows/[id]/page.tsx | 52 ++++++------ .../workflows/[id]/runs/[runId]/page.tsx | 26 +++--- web/app/login/page.tsx | 10 +-- web/app/setup/page.tsx | 8 +- web/components/AuthProvider.tsx | 8 +- web/components/Sidebar.tsx | 6 +- web/components/workflows/EditStepModal.tsx | 4 +- 94 files changed, 772 insertions(+), 937 deletions(-) diff --git a/agent/internal/checker/checker.go b/agent/internal/checker/checker.go index 547f165..c2d8eba 100644 --- a/agent/internal/checker/checker.go +++ b/agent/internal/checker/checker.go @@ -1,7 +1,3 @@ -// Package checker runs service checks (http/tcp/icmp/tls) and returns a uniform -// Result. It has no dependency on models or pb so it can be duplicated verbatim -// into the agent module (agent-run monitors) — callers map their own monitor -// representation onto Spec. package checker import ( @@ -16,7 +12,7 @@ import ( "time" ) -// Check types (mirror models.Monitor* constants). + const ( TypeHTTP = "http" TypeTCP = "tcp" @@ -24,7 +20,7 @@ const ( TypeTLS = "tls" ) -// Spec is a self-contained description of a single check. + type Spec struct { Type string URL string @@ -34,11 +30,11 @@ type Spec struct { ExpectedStatus int Keyword string TLSWarnDays int - Insecure bool // skip TLS certificate verification (HTTP checks) + Insecure bool TimeoutSec int } -// Result is the uniform outcome of running a check. + type Result struct { Up bool LatencyMs int @@ -54,7 +50,7 @@ func (s Spec) timeout() time.Duration { return time.Duration(t) * time.Second } -// Run executes the check described by s. + func Run(ctx context.Context, s Spec) Result { switch s.Type { case TypeHTTP: @@ -81,7 +77,7 @@ func runHTTP(ctx context.Context, s Spec) Result { } client := &http.Client{Timeout: s.timeout()} if s.Insecure { - client.Transport = &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}} //nolint:gosec // opt-in per monitor + client.Transport = &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}} } start := time.Now() req, err := http.NewRequestWithContext(ctx, method, s.URL, nil) @@ -160,9 +156,9 @@ func runTLS(ctx context.Context, s Spec) Result { func msSince(t time.Time) int { return int(time.Since(t).Milliseconds()) } -// runICMP sends a single ICMP echo request and waits for the reply. Requires -// raw-socket privileges (the agent and server run as root). Returns down with a -// descriptive message when the socket cannot be opened or no reply arrives. + + + func runICMP(ctx context.Context, s Spec) Result { dst, err := net.ResolveIPAddr("ip4", s.Host) if err != nil { @@ -192,18 +188,18 @@ func runICMP(ctx context.Context, s Spec) Result { if err != nil { return Result{LatencyMs: msSince(start), Message: "no reply"} } - // Skip the IPv4 header (20 bytes) to reach the ICMP message. + if n < 28 || peer.String() != dst.String() { continue } - if reply[20] == 0 { // ICMP echo reply type + if reply[20] == 0 { return Result{Up: true, LatencyMs: msSince(start)} } } } func icmpEcho(id, seq int) []byte { - // Type(8)=echo request, Code=0, Checksum, ID, Seq, no payload. + b := []byte{8, 0, 0, 0, byte(id >> 8), byte(id), byte(seq >> 8), byte(seq)} cs := icmpChecksum(b) b[2] = byte(cs >> 8) diff --git a/agent/internal/config/config.go b/agent/internal/config/config.go index 1718438..c238722 100644 --- a/agent/internal/config/config.go +++ b/agent/internal/config/config.go @@ -9,7 +9,7 @@ import ( "gopkg.in/yaml.v3" ) -// ConfigDir returns the platform-specific config directory. + func ConfigDir() string { if runtime.GOOS == "windows" { base := os.Getenv("ProgramData") diff --git a/agent/internal/exec/exec.go b/agent/internal/exec/exec.go index 44bc506..979ecbd 100644 --- a/agent/internal/exec/exec.go +++ b/agent/internal/exec/exec.go @@ -14,9 +14,9 @@ import ( "github.com/mrhid6/vantage/agent/internal/grpc/pb" ) -// streamWriter forwards every write to emit() as an ordered chunk. Used as both -// Stdout and Stderr so output interleaves in real execution order. The mutex -// ensures a single stdout/stderr write is not interleaved mid-slice with another. + + + type streamWriter struct { mu sync.Mutex seq uint64 @@ -35,21 +35,21 @@ func (w *streamWriter) Write(p []byte) (int, error) { return len(p), nil } -// WorkspacePath returns the per-run working directory for a workspace id. The -// same id always maps to the same path so RunStep and the cleanup command agree. + + func WorkspacePath(workspaceID string) string { return filepath.Join(os.TempDir(), "vantage-run-"+workspaceID) } -// RunStep writes the script to a temp file, provides a WORKFLOW_ENV file for -// the script to append KEY=value output to, executes it under the requested -// interpreter, and streams output via emit, returning the terminal result -// with empty stdout/stderr but populated exit_code/output_env. -// -// When the command carries a WorkspaceId the step runs with that per-run working -// directory as its cwd (created here if missing); the server removes it once the -// run finishes. The script and env files always live in a private temp dir so -// they never leak into the shared workspace. + + + + + + + + + func RunStep(cmd *pb.RunStepCmd, emit func(seq uint64, data []byte)) *pb.StepResult { res := &pb.StepResult{CommandId: "", OutputEnv: map[string]string{}} @@ -102,7 +102,7 @@ func RunStep(cmd *pb.RunStepCmd, emit func(seq uint64, data []byte)) *pb.StepRes } } c = exec.CommandContext(ctx, shell, "-NoProfile", "-NonInteractive", "-File", scriptPath) - default: // "bash" + default: scriptPath = filepath.Join(dir, "step.sh") if err := os.WriteFile(scriptPath, []byte(cmd.Script), 0700); err != nil { res.ExitCode = 1 @@ -126,7 +126,7 @@ func RunStep(cmd *pb.RunStepCmd, emit func(seq uint64, data []byte)) *pb.StepRes c.Stderr = sw runErr := c.Run() - // stdout/stderr are streamed via emit, not returned in the result. + if ctx.Err() == context.DeadlineExceeded { res.ExitCode = 124 res.Stderr = "[vantage] step timed out" @@ -141,8 +141,8 @@ func RunStep(cmd *pb.RunStepCmd, emit func(seq uint64, data []byte)) *pb.StepRes return res } -// parseEnvFile reads KEY=value lines (last write wins). Blank lines and lines -// without '=' are ignored. + + func parseEnvFile(path string) map[string]string { out := map[string]string{} f, err := os.Open(path) diff --git a/agent/internal/grpc/client.go b/agent/internal/grpc/client.go index 9f07404..9f872fb 100644 --- a/agent/internal/grpc/client.go +++ b/agent/internal/grpc/client.go @@ -27,8 +27,8 @@ func New(serverURL string, useTLS bool) (*Client, error) { serverURL = strings.TrimPrefix(serverURL, "https://") serverURL = strings.TrimPrefix(serverURL, "http://") - // Send a ping every 30s so proxies with a 60s idle timeout don't kill the - // long-lived CommandStream when no commands are flowing. + + dialOpts := []grpc.DialOption{ grpc.WithKeepaliveParams(keepalive.ClientParameters{ Time: 30 * time.Second, @@ -150,8 +150,8 @@ func (c *Client) ReportChecks(serverID, agentToken string, results []pb.CheckRes return err } -// CommandStream opens a long-lived bidirectional stream for server-pushed commands. -// The caller controls the stream lifetime via ctx. + + func (c *Client) CommandStream(ctx context.Context) (pb.Vantage_CommandStreamClient, error) { return c.client.CommandStream(ctx) } diff --git a/agent/internal/grpc/pb/vantage.pb.go b/agent/internal/grpc/pb/vantage.pb.go index 2cb692d..11f9cdb 100644 --- a/agent/internal/grpc/pb/vantage.pb.go +++ b/agent/internal/grpc/pb/vantage.pb.go @@ -1,4 +1,4 @@ -// Hand-written gRPC bindings for vantage.proto (agent side, JSON codec). + package pb @@ -44,7 +44,7 @@ type UploadKeyResponse struct { KeyId string `json:"key_id"` } -// CommandStream message types + type PackageUpdate struct { Name string `json:"name"` @@ -60,7 +60,7 @@ type ReportUpdatesRequest struct { type ReportUpdatesResponse struct{} -// Inventory report message types + type CPUReport struct { Model string `json:"model,omitempty"` @@ -92,7 +92,7 @@ type InventoryReport struct { } type InventoryReportResponse struct{} -// Monitor sync / check report message types + type MonitorSpec struct { MonitorId string `json:"monitor_id"` @@ -141,8 +141,8 @@ type ServerCommand struct { CleanupWorkspace *CleanupWorkspaceCmd `json:"cleanup_workspace,omitempty"` } -// CleanupWorkspaceCmd tells the agent to recursively remove the run's working -// directory once all steps on that server have finished. + + type CleanupWorkspaceCmd struct { WorkspaceId string `json:"workspace_id"` } @@ -186,8 +186,8 @@ type RunStepCmd struct { Script string `json:"script"` Env map[string]string `json:"env,omitempty"` TimeoutSeconds int `json:"timeout_seconds,omitempty"` - // WorkspaceId names the per-run working directory the agent creates and uses - // as the step's cwd. Empty means run in the agent's default directory. + + WorkspaceId string `json:"workspace_id,omitempty"` } @@ -206,7 +206,7 @@ type StepOutputChunk struct { Eof bool `json:"eof,omitempty"` } -// CommandStream client-side interface + type Vantage_CommandStreamClient interface { Send(*AgentMessage) error @@ -230,7 +230,7 @@ func (c *vantageCommandStreamClient) Recv() (*ServerCommand, error) { return m, nil } -// CommandStream server-side interface (included for completeness) + type Vantage_CommandStreamServer interface { Send(*ServerCommand) error diff --git a/agent/internal/inventory/collect_linux.go b/agent/internal/inventory/collect_linux.go index 39d0daf..58f9797 100644 --- a/agent/internal/inventory/collect_linux.go +++ b/agent/internal/inventory/collect_linux.go @@ -1,4 +1,4 @@ -//go:build linux + package inventory @@ -42,11 +42,11 @@ func cpuSample() (idle, total uint64) { defer f.Close() sc := bufio.NewScanner(f) if sc.Scan() { - fields := strings.Fields(sc.Text()) // cpu user nice system idle iowait ... + fields := strings.Fields(sc.Text()) for i, v := range fields[1:] { n, _ := strconv.ParseUint(v, 10, 64) total += n - if i == 3 { // idle + if i == 3 { idle = n } } diff --git a/agent/internal/inventory/collect_other.go b/agent/internal/inventory/collect_other.go index a293e32..59c3c1b 100644 --- a/agent/internal/inventory/collect_other.go +++ b/agent/internal/inventory/collect_other.go @@ -1,8 +1,8 @@ -//go:build !linux + package inventory import "github.com/mrhid6/vantage/agent/internal/grpc/pb" -// collect is a no-op best-effort stub on non-Linux platforms. + func collect(r *pb.InventoryReport, includeStatic bool) {} diff --git a/agent/internal/inventory/inventory.go b/agent/internal/inventory/inventory.go index 238c29d..fbb898f 100644 --- a/agent/internal/inventory/inventory.go +++ b/agent/internal/inventory/inventory.go @@ -2,8 +2,8 @@ package inventory import "github.com/mrhid6/vantage/agent/internal/grpc/pb" -// Collect gathers metrics always and static hardware info when includeStatic. -// Platform specifics are provided by collect_linux.go / collect_other.go. + + func Collect(includeStatic bool) *pb.InventoryReport { r := &pb.InventoryReport{IncludeStatic: includeStatic, CPU: &pb.CPUReport{}, Memory: &pb.MemReport{}} collect(r, includeStatic) diff --git a/agent/internal/keys/keys.go b/agent/internal/keys/keys.go index 1e01ae2..674fc3c 100644 --- a/agent/internal/keys/keys.go +++ b/agent/internal/keys/keys.go @@ -96,16 +96,16 @@ func fingerprint(pubKey string) string { return "MD5:" + strings.Join(pairs, ":") } -// KeyGenOptions controls how ssh-keygen is invoked. + type KeyGenOptions struct { - KeyType string // ed25519 (default), rsa, ecdsa - KeySize int // bits; used for rsa and ecdsa - Passphrase string // empty = no passphrase - Comment string // embedded in the public key + KeyType string + KeySize int + Passphrase string + Comment string } -// GenerateKeyPair generates an SSH keypair and returns the public key. -// The private key is written to keyPath; keyPath+".pub" holds the public key. + + func GenerateKeyPair(keyPath string, opts KeyGenOptions) (string, error) { if err := os.MkdirAll(filepath.Dir(keyPath), 0700); err != nil { return "", err @@ -139,8 +139,8 @@ func GenerateKeyPair(keyPath string, opts KeyGenOptions) (string, error) { return strings.TrimSpace(string(pubData)), nil } -// AddSSHIdentity writes an IdentityFile entry for keyPath into the managed -// vantage.conf include file, and ensures ~/.ssh/config includes it. + + func AddSSHIdentity(keyPath string) error { if err := os.MkdirAll(filepath.Dir(sshConfigPath), 0700); err != nil { return fmt.Errorf("mkdir .ssh: %w", err) @@ -150,7 +150,7 @@ func AddSSHIdentity(keyPath string) error { return err } - // Read existing managed config (it may not exist yet). + var existing string data, err := os.ReadFile(managedConfigPath) if err != nil && !os.IsNotExist(err) { @@ -161,7 +161,7 @@ func AddSSHIdentity(keyPath string) error { line := "IdentityFile " + keyPath for _, l := range strings.Split(existing, "\n") { if strings.TrimSpace(l) == line { - return nil // already present + return nil } } @@ -176,7 +176,7 @@ func AddSSHIdentity(keyPath string) error { return nil } -// RemoveSSHIdentity removes the IdentityFile entry for keyPath from the managed config. + func RemoveSSHIdentity(keyPath string) error { data, err := os.ReadFile(managedConfigPath) if os.IsNotExist(err) { @@ -204,9 +204,9 @@ func RemoveSSHIdentity(keyPath string) error { return nil } -// ensureIncludeDirective adds "Include /root/.ssh/vantage.conf" to the top -// of ~/.ssh/config if it is not already present. The Include must appear before -// any Host stanzas to be effective for all connections. + + + func ensureIncludeDirective() error { data, err := os.ReadFile(sshConfigPath) if err != nil && !os.IsNotExist(err) { @@ -215,11 +215,11 @@ func ensureIncludeDirective() error { for _, l := range strings.Split(string(data), "\n") { if strings.TrimSpace(l) == includeDirective { - return nil // already present + return nil } } - // Prepend the Include directive so it takes effect before any Host blocks. + updated := includeDirective + "\n" + string(data) if err := os.WriteFile(sshConfigPath, []byte(updated), 0600); err != nil { return fmt.Errorf("write %s: %w", sshConfigPath, err) diff --git a/agent/internal/monitors/monitors.go b/agent/internal/monitors/monitors.go index 242259e..77ec420 100644 --- a/agent/internal/monitors/monitors.go +++ b/agent/internal/monitors/monitors.go @@ -1,6 +1,6 @@ -// Package monitors runs agent-side service checks. It polls the server for the -// monitors assigned to this agent (SyncMonitors), runs each on its own interval -// using the local checker package, and reports results back (ReportChecks). + + + package monitors import ( @@ -15,7 +15,7 @@ import ( "github.com/mrhid6/vantage/agent/internal/grpc/pb" ) -// syncInterval controls how often the agent re-fetches its assigned monitors. + const syncInterval = 30 * time.Second type runner struct { @@ -23,13 +23,13 @@ type runner struct { cancel context.CancelFunc } -// Run starts the agent monitor loop and blocks until ctx is cancelled. + func Run(ctx context.Context, cfg *config.Config) { active := map[string]*runner{} var mu sync.Mutex - // results is a shared channel every check writes to; a single reporter - // goroutine batches and ships them so we make one ReportChecks call per tick. + + results := make(chan pb.CheckResult, 64) go reporter(ctx, cfg, results) @@ -128,7 +128,7 @@ func runSpec(ctx context.Context, s pb.MonitorSpec, out chan<- pb.CheckResult) { } } -// reporter batches results on a short interval and ships each batch in one call. + func reporter(ctx context.Context, cfg *config.Config, in <-chan pb.CheckResult) { t := time.NewTicker(5 * time.Second) defer t.Stop() diff --git a/agent/internal/sync/sync.go b/agent/internal/sync/sync.go index 9e69169..8a31683 100644 --- a/agent/internal/sync/sync.go +++ b/agent/internal/sync/sync.go @@ -34,7 +34,7 @@ func Run(ctx context.Context, cfg *config.Config, version string) error { } defer client.Close() - // Register if we have a pre-reg token + if cfg.PreRegToken != "" { log.Println("registering with server...") hostname, _ := os.Hostname() @@ -61,25 +61,25 @@ func Run(ctx context.Context, cfg *config.Config, version string) error { } if cfg.AgentToken == "" { - return fmt.Errorf("no agent token available — registration required") + return fmt.Errorf("no agent token available registration required") } - // Start the command stream alongside the poll loop. + go runCommandStream(ctx, cfg) - // Check for OS updates on startup and then hourly. + go runUpdateCheck(ctx, cfg) - // Report host inventory: metrics every 30s, full static snapshot every 15 min. + go runInventory(ctx, cfg) - // Run agent-side service monitors assigned to this server. + go monitors.Run(ctx, cfg) ticker := time.NewTicker(cfg.PollInterval) defer ticker.Stop() - // Run immediately on startup + if err := poll(client, cfg, version); err != nil { log.Printf("poll error: %v", err) } @@ -102,7 +102,6 @@ func poll(client *grpcclient.Client, cfg *config.Config, version string) error { return fmt.Errorf("SyncKeys: %w", err) } - // Windows agents register and heartbeat only — no authorized_keys management. if runtime.GOOS != "linux" { return nil } @@ -124,8 +123,8 @@ func poll(client *grpcclient.Client, cfg *config.Config, version string) error { return nil } -// runCommandStream maintains a persistent bidirectional stream with the server -// for instant command delivery. Reconnects with exponential backoff on failure. + + func runCommandStream(ctx context.Context, cfg *config.Config) { backoff := time.Second const maxBackoff = 2 * time.Minute @@ -178,9 +177,9 @@ func connectAndHandleStream(ctx context.Context, cfg *config.Config) error { log.Println("command stream connected") - // grpc streams are not safe for concurrent Send; RunStep results are sent - // from per-command goroutines, so all sends on this stream must go through - // this mutex-protected helper. + + + var sendMu sync.Mutex send := func(msg *pb.AgentMessage) error { sendMu.Lock() @@ -220,7 +219,7 @@ func connectAndHandleStream(ctx context.Context, cfg *config.Config) error { } res := agentexec.RunStep(rc, emit) res.CommandId = cid - // Final eof marker so the server closes the log file. + _ = send(&pb.AgentMessage{ ServerId: cfg.ServerID, AgentToken: cfg.AgentToken, @@ -280,8 +279,8 @@ func runUpdateCheck(ctx context.Context, cfg *config.Config) { } } -// runInventory reports host metrics every 30s and a full static snapshot every -// 15 min (and once immediately on startup so static fields populate without delay). + + func runInventory(ctx context.Context, cfg *config.Config) { client, err := grpcclient.New(cfg.ServerURL, cfg.TLS) if err != nil { @@ -299,7 +298,7 @@ func runInventory(ctx context.Context, cfg *config.Config) { } } - report(true) // full snapshot on startup + report(true) ticker := time.NewTicker(30 * time.Second) defer ticker.Stop() tick := 0 @@ -309,7 +308,7 @@ func runInventory(ctx context.Context, cfg *config.Config) { return case <-ticker.C: tick++ - report(tick%30 == 0) // every 30th tick = 15 min → include static + report(tick%30 == 0) } } } @@ -322,7 +321,7 @@ func handleApplyUpdates(cfg *config.Config, cmd *pb.ServerCommand) { } log.Printf("OS updates applied successfully (cmd=%s)", cmd.CommandId) - // Re-report the (now empty) update list so the server reflects the new state. + client, err := grpcclient.New(cfg.ServerURL, cfg.TLS) if err != nil { return @@ -364,21 +363,21 @@ func handleUpdateAgent(cmd *pb.ServerCommand) { } u := cmd.UpdateAgent - arch := runtime.GOARCH // "amd64" or "arm64" + arch := runtime.GOARCH tag := "agent%2Fv" + u.Version binaryURL := fmt.Sprintf("%s/mrhid6/vantage/releases/download/%s/vantage-agent-linux-%s", u.GiteaBaseURL, tag, arch) checksumURL := fmt.Sprintf("%s/mrhid6/vantage/releases/download/%s/checksums.txt", u.GiteaBaseURL, tag) log.Printf("updating agent to v%s from %s (cmd=%s)", u.Version, u.GiteaBaseURL, cmd.CommandId) - // Download binary + tmpBin := "/tmp/vantage-agent-update" if err := downloadFile(binaryURL, tmpBin); err != nil { log.Printf("update download failed (cmd=%s): %v", cmd.CommandId, err) return } - // Download and verify checksum + checksumData, err := httpGetBytes(checksumURL) if err != nil { log.Printf("update checksum fetch failed (cmd=%s): %v", cmd.CommandId, err) @@ -403,11 +402,11 @@ func handleUpdateAgent(cmd *pb.ServerCommand) { exec.Command("systemctl", "restart", "vantage-agent").Run() } -// handleUpdateAgentWindows downloads the latest MSI and launches msiexec to -// perform a MajorUpgrade. msiexec is started DETACHED (via "cmd /c start") so -// that when the upgrade stops the VantageAgent service, nssm's process-tree -// kill of this agent does not also kill the installer mid-flight. Config -// (server_id, agent_token) is preserved by setup.ps1 on upgrade. + + + + + func handleUpdateAgentWindows(cmd *pb.ServerCommand) { u := cmd.UpdateAgent tag := "agent%2Fv" + u.Version @@ -435,8 +434,8 @@ func handleUpdateAgentWindows(cmd *pb.ServerCommand) { logPath := filepath.Join(os.TempDir(), "vantage-agent-msi.log") log.Printf("launching msiexec for upgrade to v%s (cmd=%s)", u.Version, cmd.CommandId) - // "start" detaches msiexec from this process tree so the service stop - // during the upgrade does not terminate the installer. + + up := exec.Command("cmd", "/c", "start", "", "/wait", "msiexec", "/i", msiPath, "/qn", "/norestart", "/l*v", logPath) if err := up.Start(); err != nil { log.Printf("failed to launch msiexec (cmd=%s): %v", cmd.CommandId, err) @@ -445,7 +444,7 @@ func handleUpdateAgentWindows(cmd *pb.ServerCommand) { } func downloadFile(url, dest string) error { - resp, err := http.Get(url) //nolint:gosec + resp, err := http.Get(url) if err != nil { return err } @@ -463,7 +462,7 @@ func downloadFile(url, dest string) error { } func httpGetBytes(url string) ([]byte, error) { - resp, err := http.Get(url) //nolint:gosec + resp, err := http.Get(url) if err != nil { return nil, err } @@ -555,7 +554,7 @@ func localIP() string { return "" } -// GenerateAndUpload generates an SSH keypair and uploads the public key to the server. + func GenerateAndUpload(cfg *config.Config, label string) error { client, err := grpcclient.New(cfg.ServerURL, cfg.TLS) if err != nil { diff --git a/agent/internal/updates/updates.go b/agent/internal/updates/updates.go index a2eecf7..8a1ce12 100644 --- a/agent/internal/updates/updates.go +++ b/agent/internal/updates/updates.go @@ -27,8 +27,8 @@ func detectPM() string { return "" } -// CheckAvailable returns the list of packages with available upgrades. -// Returns nil, nil when no supported package manager is found. + + func CheckAvailable() ([]PackageUpdate, error) { switch detectPM() { case "apt": @@ -48,11 +48,11 @@ func CheckAvailable() ([]PackageUpdate, error) { } } -// ApplyAll runs a full non-interactive upgrade using the detected package manager. + func ApplyAll() error { switch detectPM() { case "apt": - // Refresh lists first, then upgrade. + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) defer cancel() if err := exec.CommandContext(ctx, "apt-get", "update", "-qq").Run(); err != nil { @@ -77,8 +77,8 @@ func ApplyAll() error { func checkApt() ([]PackageUpdate, error) { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) defer cancel() - // Best-effort refresh; ignore errors (cached data is fine). - exec.CommandContext(ctx, "apt-get", "update", "-qq").Run() //nolint:errcheck + + exec.CommandContext(ctx, "apt-get", "update", "-qq").Run() out, err := exec.Command("apt", "list", "--upgradable").Output() if err != nil { @@ -88,7 +88,7 @@ func checkApt() ([]PackageUpdate, error) { scanner := bufio.NewScanner(bytes.NewReader(out)) for scanner.Scan() { line := scanner.Text() - // Format: package/suite version arch [upgradable from: old-ver] + if !strings.Contains(line, "[upgradable from:") { continue } @@ -111,7 +111,6 @@ func checkApt() ([]PackageUpdate, error) { func checkDnfYum(pm string) ([]PackageUpdate, error) { cmd := exec.Command(pm, "check-update") out, err := cmd.Output() - // Exit code 100 means updates are available — not an error. if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 100 { err = nil } @@ -133,7 +132,7 @@ func checkDnfYum(pm string) ([]PackageUpdate, error) { if len(parts) < 2 { continue } - // name.arch new-version repo + name := strings.SplitN(parts[0], ".", 2)[0] updates = append(updates, PackageUpdate{Name: name, NewVersion: parts[1]}) } @@ -146,7 +145,7 @@ func checkPacman() ([]PackageUpdate, error) { scanner := bufio.NewScanner(bytes.NewReader(out)) for scanner.Scan() { parts := strings.Fields(scanner.Text()) - // Format: package old-version -> new-version + if len(parts) < 4 { continue } @@ -164,7 +163,7 @@ func checkZypper() ([]PackageUpdate, error) { scanner := bufio.NewScanner(bytes.NewReader(out)) for scanner.Scan() { line := scanner.Text() - // Data rows start with "v |" (available) or "i |" (installed but updatable). + if !strings.HasPrefix(line, "v |") && !strings.HasPrefix(line, "i |") { continue } diff --git a/installer/setup.ps1 b/installer/setup.ps1 index 74c34a0..5ba6c2e 100644 --- a/installer/setup.ps1 +++ b/installer/setup.ps1 @@ -27,8 +27,6 @@ function Invoke-Native { } } -# Like Invoke-Native but never throws — for teardown, where a missing/stopped -# service must not abort the uninstall. function Invoke-NativeSoft { param([string]$File, [string[]]$Arguments) Write-Log ("RUN(soft): {0} {1}" -f $File, ($Arguments -join " ")) diff --git a/server/cmd/main.go b/server/cmd/main.go index 85fc665..a775cb4 100644 --- a/server/cmd/main.go +++ b/server/cmd/main.go @@ -19,9 +19,9 @@ func main() { mongoURI := getEnv("MONGO_URI", "mongodb://localhost:27017") dbName := getEnv("MONGO_DB", "vantage") - // Agents dial gRPC directly, so there is no sane default: falling back to the - // public web host would hand every new agent a config pointing at a port that - // does not speak gRPC. Fail loudly at boot instead of at install time. + + + if os.Getenv("GRPC_HOST") == "" { log.Fatal("GRPC_HOST is required (host:port agents dial for gRPC)") } @@ -31,19 +31,19 @@ func main() { } log.Println("connected to MongoDB") - // The unique indexes are a security property: GetUserByEmail does an - // unscoped FindOne, so duplicate (or blank) emails let the OIDC callback's - // cross-org guard compare against an arbitrary user, and duplicate org slugs - // make host-based org resolution pick one at random. + + + + if err := services.EnsureAuthIndexes(); err != nil { log.Fatalf("failed to ensure auth indexes: %v", err) } if err := services.RunMigrations(); err != nil { log.Fatalf("migration failed: %v", err) } - // Must run before the unique settings indexes are built, and before 0003: - // 0003 can create a "default" org, which would push 0002 into its ambiguous - // multi-org branch and leave the settings doc unstamped. + + + if err := services.MigrateSettingsOrg(); err != nil { log.Fatalf("settings org migration failed: %v", err) } @@ -55,9 +55,9 @@ func main() { log.Printf("warning: failed to ensure secret indexes: %v", err) } - // The unique indexes are a security property: duplicate settings docs make - // GetSettings return an arbitrary one, and duplicate ESO token hashes make - // ResolveSecretsReadToken pick an arbitrary org. + + + if err := services.EnsureSettingsIndexes(); err != nil { log.Fatalf("failed to ensure settings indexes: %v", err) } @@ -86,7 +86,7 @@ func main() { } log.Println("connected to Redis") - // Background goroutine to mark offline servers + go func() { ticker := time.NewTicker(2 * time.Minute) defer ticker.Stop() @@ -97,17 +97,17 @@ func main() { } }() - // Start gRPC server + go func() { if err := grpcserver.StartGRPC(9090); err != nil { log.Fatalf("gRPC server error: %v", err) } }() - // Start the server-side monitor scheduler. + monitorsched.Start(context.Background()) - // Start REST server + r := gin.New() r.Use(gin.Recovery()) r.Use(gin.LoggerWithConfig(gin.LoggerConfig{SkipPaths: []string{"/api/console/tunnel"}})) diff --git a/server/internal/api/console.go b/server/internal/api/console.go index 9948913..735d1e8 100644 --- a/server/internal/api/console.go +++ b/server/internal/api/console.go @@ -13,9 +13,9 @@ import ( "github.com/wwt/guac" ) -// POST /api/console/connect -// Body: { server_id, protocol, key_id?, rdp_username?, rdp_password? } -// Returns: { session_id, token, ws_path } + + + func consoleConnect(c *gin.Context) { var body struct { ServerID string `json:"server_id" binding:"required"` @@ -71,8 +71,8 @@ func consoleConnect(c *gin.Context) { }) } -// queryIntDefault reads a positive integer query param, falling back to def -// when absent, unparseable, or non-positive. + + func queryIntDefault(r *http.Request, key string, def int) int { v, err := strconv.Atoi(r.URL.Query().Get(key)) if err != nil || v <= 0 { @@ -81,7 +81,7 @@ func queryIntDefault(r *http.Request, key string, def int) int { return v } -// GET /api/console/tunnel?token=... (WebSocket upgrade) + func consoleTunnel(c *gin.Context) { token := c.Query("token") sessionID, err := services.VerifySessionToken(token) @@ -96,14 +96,14 @@ func consoleTunnel(c *gin.Context) { return } - // User-bound: the caller (authenticated via session cookie) must be the same - // user who opened the session. Blocks a leaked token being used by someone else. + + if actor := actorFromCtx(c); actor != sess.User { c.JSON(http.StatusForbidden, gin.H{"error": "session belongs to another user"}) return } - // Single-use: atomically spend the token so a replay within its TTL is rejected. + if err := services.ConsumeSessionToken(orgID, sessionID); err != nil { c.JSON(http.StatusUnauthorized, gin.H{"error": "token already used"}) return @@ -115,7 +115,7 @@ func consoleTunnel(c *gin.Context) { return } - // Decrypt private key + passphrase in-memory only (ssh). + var privKey, passphrase string if sess.Protocol == "ssh" && sess.KeyID != "" { privKey, err = services.GetPrivateKey(auth.OrgID(c), sess.KeyID) @@ -145,7 +145,7 @@ func consoleTunnel(c *gin.Context) { guacdAddr = "guacd:4822" } - // Build a guac tunnel config from our params. + connect := func(r *http.Request) (guac.Tunnel, error) { config := guac.NewGuacamoleConfiguration() config.Protocol = gp.Protocol diff --git a/server/internal/api/handlers.go b/server/internal/api/handlers.go index 3dd7164..80b9e61 100644 --- a/server/internal/api/handlers.go +++ b/server/internal/api/handlers.go @@ -25,14 +25,9 @@ func RegisterRoutes(r *gin.Engine) { r.GET("/update", handleUpdateScript) r.GET("/update.ps1", handleUpdateScriptWindows) - // ESO read endpoint — bearer-token auth, not session auth, so Kubernetes - // External Secrets Operator can call it. Lives under /api (so the reverse - // proxy routes it to the backend) but on a distinct subpath to avoid - // colliding with the session-authed GET /api/secrets/:group. Returns a - // group as flat JSON. r.GET("/api/secrets/:group/values", secretsReadAuth(), esoGetGroup) - // Unauthenticated auth endpoints + r.GET("/auth/bootstrap-status", auth.HandleBootstrapStatus) r.POST("/auth/bootstrap", auth.HandleBootstrap) r.POST("/auth/login", auth.HandleLocalLogin) @@ -41,7 +36,7 @@ func RegisterRoutes(r *gin.Engine) { r.GET("/auth/oidc/start", auth.HandleOIDCStart) r.GET("/auth/oidc/callback", auth.HandleOIDCCallback) - // API endpoints protected by session middleware + apiGroup := r.Group("/api") apiGroup.Use(auth.Middleware()) { @@ -168,7 +163,7 @@ func getServer(c *gin.Context) { assignments, _ := services.GetAssignmentsWithKeysForServer(auth.OrgID(c), id) - // Build response matching ServerWithKeys shape expected by frontend + type serverResponse struct { *models.Server Keys interface{} `json:"keys"` @@ -414,7 +409,7 @@ if [ -z "$LATEST" ]; then fi VERSION="${LATEST#agent/}" -LATEST_ENCODED="${LATEST/\//%%2F}" +LATEST_ENCODED="${LATEST/\ BINARY_URL="https://${GITEA_HOST}/mrhid6/vantage/releases/download/${LATEST_ENCODED}/vantage-agent-linux-${ARCH}" CHECKSUM_URL="https://${GITEA_HOST}/mrhid6/vantage/releases/download/${LATEST_ENCODED}/checksums.txt" @@ -521,7 +516,7 @@ if [ -z "$LATEST" ]; then fi VERSION="${LATEST#agent/}" -LATEST_ENCODED="${LATEST/\//%%2F}" +LATEST_ENCODED="${LATEST/\ BINARY_URL="https://${GITEA_HOST}/mrhid6/vantage/releases/download/${LATEST_ENCODED}/vantage-agent-linux-${ARCH}" CHECKSUM_URL="https://${GITEA_HOST}/mrhid6/vantage/releases/download/${LATEST_ENCODED}/checksums.txt" diff --git a/server/internal/api/install_ps1.go b/server/internal/api/install_ps1.go index 154e1b1..81a1fab 100644 --- a/server/internal/api/install_ps1.go +++ b/server/internal/api/install_ps1.go @@ -16,7 +16,7 @@ func handleInstallScriptWindows(c *gin.Context) { if giteaHost == "" { giteaHost = "gitea.example.com" } - // Guaranteed non-empty: main() fatals at boot if GRPC_HOST is unset. + grpcHost := os.Getenv("GRPC_HOST") script := fmt.Sprintf( @@ -50,9 +50,9 @@ func handleInstallScriptWindows(c *gin.Context) { c.String(http.StatusOK, script) } -// handleUpdateScriptWindows serves a PowerShell one-liner that upgrades an -// already-installed Windows agent. No server_id/token needed: the MSI is a -// MajorUpgrade and setup.ps1 preserves the existing config on upgrade. + + + func handleUpdateScriptWindows(c *gin.Context) { giteaHost := os.Getenv("GITEA_HOST") if giteaHost == "" { diff --git a/server/internal/api/org.go b/server/internal/api/org.go index a736ac7..e0d66e3 100644 --- a/server/internal/api/org.go +++ b/server/internal/api/org.go @@ -19,9 +19,9 @@ func listOrgUsers(c *gin.Context) { c.JSON(http.StatusOK, users) } -// Granting or removing the owner role is reserved to owners: an admin must -// never be able to mint an owner (and log in as it) or strip the owners above -// them. Everything below derives the actor from the session, never the body. + + + func actorMayGrantOwner(c *gin.Context) bool { return auth.Role(c) == models.RoleOwner } @@ -113,8 +113,6 @@ func deleteOrgUser(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"deleted": true}) } -// The last-owner guard is a rejected request, not a server fault — surface it -// as 409 so the UI shows the message rather than a generic failure. func orgUserErrStatus(err error) int { if errors.Is(err, services.ErrLastOwner) { return http.StatusConflict @@ -128,8 +126,8 @@ func getOrgOIDC(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"enabled": false, "client_secret_set": false}) return } - // The client secret itself is write-only (never serialized); expose only - // whether one is stored so the UI can say so without leaking it. + + c.JSON(http.StatusOK, gin.H{ "org_id": cfg.OrgID, "issuer": cfg.Issuer, @@ -155,8 +153,7 @@ func putOrgOIDC(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - // Drop the cached provider so a rotated issuer takes effect immediately — - // an admin moving off a compromised IdP must not keep authenticating there. + auth.EvictOIDCProvider(auth.OrgID(c)) c.JSON(http.StatusOK, gin.H{"saved": true}) } diff --git a/server/internal/api/secrets.go b/server/internal/api/secrets.go index 01e17ed..a79378a 100644 --- a/server/internal/api/secrets.go +++ b/server/internal/api/secrets.go @@ -11,23 +11,23 @@ import ( "github.com/mrhid6/vantage/server/internal/services" ) -// groupNamePattern restricts group and key names to characters that are safe -// in URLs and Kubernetes/env contexts. + + var groupNamePattern = regexp.MustCompile(`^[A-Za-z0-9._-]+$`) func validName(s string) bool { return s != "" && len(s) <= 128 && groupNamePattern.MatchString(s) } -// ctxSecretsOrgKey carries the org resolved from the ESO bearer token. + const ctxSecretsOrgKey = "km_secrets_org" -// secretsReadAuth validates the ESO bearer token on the public read endpoint -// and stashes the org the token belongs to. -// -// This is the one endpoint whose org does NOT come from the session or the -// host: External Secrets Operator calls it machine-to-machine with no session, -// so the token itself is the org-bearing credential. + + + + + + func secretsReadAuth() gin.HandlerFunc { return func(c *gin.Context) { const prefix = "Bearer " @@ -46,15 +46,15 @@ func secretsReadAuth() gin.HandlerFunc { } } -// esoGetGroup handles GET /secrets/:group for the External Secrets Operator. -// Returns a flat JSON object { "KEY": "value", ... }; 404 if the group is empty -// (ESO treats 404 as "deleted"). + + + func esoGetGroup(c *gin.Context) { group := c.Param("group") - // Org comes from the bearer token (set by secretsReadAuth), not a session. + orgID := c.GetString(ctxSecretsOrgKey) if orgID == "" { - // Defence in depth: never query the store unscoped. + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"}) return } @@ -79,8 +79,8 @@ func listSecretGroups(c *gin.Context) { c.JSON(http.StatusOK, groups) } -// createSecretGroup handles POST /api/secrets. A group is implicit, so it must -// be created with at least one key/value pair. + + func createSecretGroup(c *gin.Context) { var body struct { Group string `json:"group" binding:"required"` @@ -126,7 +126,7 @@ func getSecretGroup(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"group": group, "secrets": secrets}) } -// putSecretGroup upserts one or more keys into an existing (or new) group. + func putSecretGroup(c *gin.Context) { group := c.Param("group") if !validName(group) { diff --git a/server/internal/api/workflows.go b/server/internal/api/workflows.go index 02b3dee..5f37a57 100644 --- a/server/internal/api/workflows.go +++ b/server/internal/api/workflows.go @@ -81,7 +81,7 @@ func streamServerRunLog(c *gin.Context) { sendNew := func() { f, err := os.Open(path) if err != nil { - return // file may not exist yet; keep waiting + return } defer f.Close() if _, err := f.Seek(offset, 0); err != nil { @@ -94,7 +94,7 @@ func streamServerRunLog(c *gin.Context) { break } offset += int64(n) - // SSE data frame; split on newlines to keep frames well-formed. + for _, line := range splitSSE(buf[:n]) { _, _ = c.Writer.WriteString("data: " + line + "\n") } @@ -110,7 +110,7 @@ func streamServerRunLog(c *gin.Context) { for { sendNew() if serverRunTerminal(orgID, runID, serverID) { - sendNew() // final drain + sendNew() _, _ = c.Writer.WriteString("event: done\ndata: end\n\n") flusher.Flush() return @@ -123,7 +123,7 @@ func streamServerRunLog(c *gin.Context) { } } -// serverRunTerminal reports whether the given server-run has reached a terminal status. + func serverRunTerminal(orgID, runID, serverID string) bool { r, err := services.GetRun(orgID, runID) if err != nil { @@ -141,8 +141,8 @@ func serverRunTerminal(orgID, runID, serverID string) bool { return true } -// splitSSE turns a raw byte slice into SSE-safe payload lines (newlines become -// separate data lines; carriage returns stripped). + + func splitSSE(b []byte) []string { s := strings.ReplaceAll(string(b), "\r", "") return strings.Split(s, "\n") @@ -224,7 +224,7 @@ func seedDefaults(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"created": created, "updated": updated}) } -const maxStepBodyBytes = 1 << 20 // 1 MiB +const maxStepBodyBytes = 1 << 20 func importStep(c *gin.Context) { c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxStepBodyBytes) @@ -242,8 +242,6 @@ func importStep(c *gin.Context) { c.JSON(http.StatusCreated, out) } -// parseStep validates a step doc and returns the normalized step WITHOUT -// persisting — used by the editor to insert an imported ad-hoc (inline) step. func parseStep(c *gin.Context) { c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxStepBodyBytes) body, err := io.ReadAll(c.Request.Body) diff --git a/server/internal/auth/local.go b/server/internal/auth/local.go index 5cc94f0..d135167 100644 --- a/server/internal/auth/local.go +++ b/server/internal/auth/local.go @@ -48,10 +48,6 @@ func HandleLocalLogin(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"ok": true}) } -// HandleBootstrapStatus answers "does the caller need to run setup". It is -// unauthenticated, so it must not report instance-wide state to whoever asks: -// on an org host the answer is scoped to that org, and only the apex — the -// genuine first-run entry point — gets the global "no users anywhere" answer. func HandleBootstrapStatus(c *gin.Context) { var ( n int64 @@ -69,9 +65,9 @@ func HandleBootstrapStatus(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"needs_setup": n == 0}) } -// HandleBootstrap creates the very first org and its owner, so its guard stays -// deliberately global: it may run once on an empty instance and never again, -// regardless of which host it is called on. + + + func HandleBootstrap(c *gin.Context) { n, err := services.CountUsers() if err != nil { @@ -91,14 +87,7 @@ func HandleBootstrap(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "org_name, email, and password (>=8 chars) required"}) return } - // An upgrade from single-tenant arrives here with no users but with the org - // the migrations created and stamped onto every legacy document. Creating a - // second org would put the owner somewhere else entirely, and since every - // org-scoped read filters on org_id, the operator would land in an empty - // Vantage with all their real data still under the migrated org — silent, - // total-looking data loss. So adopt the existing org instead, and only - // create when there genuinely is none. Same `switch orgCount` shape as - // migration 0002. + orgCount, err := services.CountOrgs() if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) @@ -152,9 +141,9 @@ func HandleMe(c *gin.Context) { c.JSON(http.StatusUnauthorized, gin.H{"error": "session expired"}) return } - // /auth/me is registered outside Middleware, so it repeats the middleware's - // host/org match itself. Without this, a session for org A presented on org - // B's host would render the shell while every /api call 403s. + + + if hostOrg, ok := OrgFromHost(c); ok && hostOrg.OrgID != sess.OrgID { c.JSON(http.StatusForbidden, gin.H{"error": "org host mismatch"}) return diff --git a/server/internal/auth/middleware.go b/server/internal/auth/middleware.go index 4ae437d..1ea963b 100644 --- a/server/internal/auth/middleware.go +++ b/server/internal/auth/middleware.go @@ -62,8 +62,6 @@ func Middleware() gin.HandlerFunc { return } - // An org-less session would turn every downstream scope into - // {"org_id": ""} — fail closed rather than query across tenants. if sess.OrgID == "" { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "session has no organization"}) return diff --git a/server/internal/auth/oidc.go b/server/internal/auth/oidc.go index 7779d1e..f4f8481 100644 --- a/server/internal/auth/oidc.go +++ b/server/internal/auth/oidc.go @@ -18,9 +18,6 @@ var ( provCache = map[string]*oidc.Provider{} ) -// EvictOIDCProvider drops an org's cached provider so the next login rediscovers -// it from the (possibly changed) issuer. Called by the API layer after the org's -// OIDC config is saved — services cannot import auth, so the handler wires it. func EvictOIDCProvider(orgID string) { provMu.Lock() delete(provCache, orgID) @@ -35,10 +32,6 @@ func redirectURL(c *gin.Context) string { return fmt.Sprintf("%s://%s/auth/oidc/callback", scheme, c.Request.Host) } -// providerForOrg returns the org's (cached) OIDC provider plus a request-local -// oauth2 config. The config is never stored on the cached entry: RedirectURL is -// derived from this request's Host, so sharing it would let one in-flight login -// overwrite another's redirect URI. func providerForOrg(ctx context.Context, c *gin.Context, orgID string) (*oidc.Provider, *oauth2.Config, error) { cfg, err := services.GetOrgOIDC(orgID) if err != nil || !cfg.Enabled { @@ -130,7 +123,7 @@ func HandleOIDCCallback(c *gin.Context) { email := strings.ToLower(claims.Email) u, err := services.GetUserByEmail(email) if err != nil { - // provision new member in THIS org + u, err = services.CreateUser(orgID, email, "", "member", "oidc") if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "provisioning failed"}) diff --git a/server/internal/auth/orghost.go b/server/internal/auth/orghost.go index 5044752..4281855 100644 --- a/server/internal/auth/orghost.go +++ b/server/internal/auth/orghost.go @@ -23,9 +23,9 @@ var ( const orgCacheTTL = 60 * time.Second -// appRootLabel is the DNS label the app is deployed under, i.e. the "vantage" -// in .vantage.. Deployments on another root must set APP_ROOT_LABEL -// or every host resolves to no org, disabling the host/session mismatch guard. + + + func appRootLabel() string { if v := os.Getenv("APP_ROOT_LABEL"); v != "" { return strings.ToLower(v) @@ -33,15 +33,15 @@ func appRootLabel() string { return "vantage" } -// hostSlug extracts the leftmost DNS label if the host is a subdomain of the -// app root. Returns "" for the apex or an unknown host shape. + + func hostSlug(host string) string { host = strings.ToLower(host) if i := strings.IndexByte(host, ':'); i >= 0 { host = host[:i] } root := appRootLabel() - // Expect ..<...>; apex is .<...> + parts := strings.Split(host, ".") if len(parts) < 3 { return "" @@ -69,8 +69,8 @@ func OrgFromHost(c *gin.Context) (*models.Org, bool) { org, err := services.GetOrgBySlug(slug) if err != nil || org == nil { - // Never cache a miss: a just-bootstrapped org would otherwise 404 on its - // own subdomain for the rest of the TTL. Misses are cheap and rare. + + return nil, false } orgCacheMu.Lock() diff --git a/server/internal/checker/checker.go b/server/internal/checker/checker.go index 547f165..c2d8eba 100644 --- a/server/internal/checker/checker.go +++ b/server/internal/checker/checker.go @@ -1,7 +1,3 @@ -// Package checker runs service checks (http/tcp/icmp/tls) and returns a uniform -// Result. It has no dependency on models or pb so it can be duplicated verbatim -// into the agent module (agent-run monitors) — callers map their own monitor -// representation onto Spec. package checker import ( @@ -16,7 +12,7 @@ import ( "time" ) -// Check types (mirror models.Monitor* constants). + const ( TypeHTTP = "http" TypeTCP = "tcp" @@ -24,7 +20,7 @@ const ( TypeTLS = "tls" ) -// Spec is a self-contained description of a single check. + type Spec struct { Type string URL string @@ -34,11 +30,11 @@ type Spec struct { ExpectedStatus int Keyword string TLSWarnDays int - Insecure bool // skip TLS certificate verification (HTTP checks) + Insecure bool TimeoutSec int } -// Result is the uniform outcome of running a check. + type Result struct { Up bool LatencyMs int @@ -54,7 +50,7 @@ func (s Spec) timeout() time.Duration { return time.Duration(t) * time.Second } -// Run executes the check described by s. + func Run(ctx context.Context, s Spec) Result { switch s.Type { case TypeHTTP: @@ -81,7 +77,7 @@ func runHTTP(ctx context.Context, s Spec) Result { } client := &http.Client{Timeout: s.timeout()} if s.Insecure { - client.Transport = &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}} //nolint:gosec // opt-in per monitor + client.Transport = &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}} } start := time.Now() req, err := http.NewRequestWithContext(ctx, method, s.URL, nil) @@ -160,9 +156,9 @@ func runTLS(ctx context.Context, s Spec) Result { func msSince(t time.Time) int { return int(time.Since(t).Milliseconds()) } -// runICMP sends a single ICMP echo request and waits for the reply. Requires -// raw-socket privileges (the agent and server run as root). Returns down with a -// descriptive message when the socket cannot be opened or no reply arrives. + + + func runICMP(ctx context.Context, s Spec) Result { dst, err := net.ResolveIPAddr("ip4", s.Host) if err != nil { @@ -192,18 +188,18 @@ func runICMP(ctx context.Context, s Spec) Result { if err != nil { return Result{LatencyMs: msSince(start), Message: "no reply"} } - // Skip the IPv4 header (20 bytes) to reach the ICMP message. + if n < 28 || peer.String() != dst.String() { continue } - if reply[20] == 0 { // ICMP echo reply type + if reply[20] == 0 { return Result{Up: true, LatencyMs: msSince(start)} } } } func icmpEcho(id, seq int) []byte { - // Type(8)=echo request, Code=0, Checksum, ID, Seq, no payload. + b := []byte{8, 0, 0, 0, byte(id >> 8), byte(id), byte(seq >> 8), byte(seq)} cs := icmpChecksum(b) b[2] = byte(cs >> 8) diff --git a/server/internal/grpc/codec.go b/server/internal/grpc/codec.go index 7ef74e1..8113132 100644 --- a/server/internal/grpc/codec.go +++ b/server/internal/grpc/codec.go @@ -4,7 +4,7 @@ import ( "encoding/json" ) -// JSONCodec is a gRPC codec that uses JSON encoding. + type JSONCodec struct{} func (JSONCodec) Marshal(v interface{}) ([]byte, error) { @@ -16,5 +16,5 @@ func (JSONCodec) Unmarshal(data []byte, v interface{}) error { } func (JSONCodec) Name() string { - return "proto" // override default proto codec name so gRPC uses it + return "proto" } diff --git a/server/internal/grpc/pb/vantage.pb.go b/server/internal/grpc/pb/vantage.pb.go index 6dcb766..399cc52 100644 --- a/server/internal/grpc/pb/vantage.pb.go +++ b/server/internal/grpc/pb/vantage.pb.go @@ -1,5 +1,5 @@ -// Hand-written gRPC bindings for vantage.proto using JSON codec. -// To use: register the JSON codec before creating gRPC servers/clients. + + package pb @@ -11,7 +11,7 @@ import ( "google.golang.org/grpc/status" ) -// Message types + type RegisterRequest struct { ServerId string `json:"server_id"` @@ -47,7 +47,7 @@ type UploadKeyResponse struct { KeyId string `json:"key_id"` } -// CommandStream message types + type PackageUpdate struct { Name string `json:"name"` @@ -63,7 +63,7 @@ type ReportUpdatesRequest struct { type ReportUpdatesResponse struct{} -// Inventory report message types + type CPUReport struct { Model string `json:"model,omitempty"` @@ -95,7 +95,7 @@ type InventoryReport struct { } type InventoryReportResponse struct{} -// Monitor sync / check report message types + type MonitorSpec struct { MonitorId string `json:"monitor_id"` @@ -144,8 +144,8 @@ type ServerCommand struct { CleanupWorkspace *CleanupWorkspaceCmd `json:"cleanup_workspace,omitempty"` } -// CleanupWorkspaceCmd tells the agent to recursively remove the run's working -// directory once all steps on that server have finished. + + type CleanupWorkspaceCmd struct { WorkspaceId string `json:"workspace_id"` } @@ -189,8 +189,8 @@ type RunStepCmd struct { Script string `json:"script"` Env map[string]string `json:"env,omitempty"` TimeoutSeconds int `json:"timeout_seconds,omitempty"` - // WorkspaceId names the per-run working directory the agent creates and uses - // as the step's cwd. Empty means run in the agent's default directory. + + WorkspaceId string `json:"workspace_id,omitempty"` } @@ -209,7 +209,7 @@ type StepOutputChunk struct { Eof bool `json:"eof,omitempty"` } -// CommandStream server-side interface + type Vantage_CommandStreamServer interface { Send(*ServerCommand) error @@ -233,7 +233,7 @@ func (s *keyManagerCommandStreamServer) Recv() (*AgentMessage, error) { return m, nil } -// CommandStream client-side interface + type Vantage_CommandStreamClient interface { Send(*AgentMessage) error @@ -257,7 +257,7 @@ func (c *vantageCommandStreamClient) Recv() (*ServerCommand, error) { return m, nil } -// Server interface + type VantageServer interface { Register(context.Context, *RegisterRequest) (*RegisterResponse, error) @@ -304,7 +304,7 @@ func (UnimplementedVantageServer) CommandStream(Vantage_CommandStreamServer) err return status.Errorf(codes.Unimplemented, "method CommandStream not implemented") } -// Client interface + type VantageClient interface { Register(ctx context.Context, in *RegisterRequest, opts ...grpc.CallOption) (*RegisterResponse, error) @@ -389,7 +389,7 @@ func (c *keyManagerClient) CommandStream(ctx context.Context, opts ...grpc.CallO return &vantageCommandStreamClient{stream}, nil } -// Server registration + func RegisterVantageServer(s grpc.ServiceRegistrar, srv VantageServer) { s.RegisterService(&Vantage_ServiceDesc, srv) diff --git a/server/internal/grpc/server.go b/server/internal/grpc/server.go index 8e7f9e7..39972a3 100644 --- a/server/internal/grpc/server.go +++ b/server/internal/grpc/server.go @@ -62,13 +62,13 @@ func (s *vantageServer) UploadGeneratedKey(ctx context.Context, req *pb.UploadKe return nil, status.Errorf(codes.Unauthenticated, "invalid agent token") } - // Agent-generated keys carry no passphrase over the wire (proto has no field). + key, err := services.CreateKey(srv.OrgID, req.Label, req.PublicKey, "generated", srv.ServerID, req.PrivateKey, "") if err != nil { return nil, status.Errorf(codes.Internal, "failed to store key: %v", err) } - // Auto-assign to the generating server + if _, err := services.AssignKey(srv.OrgID, key.KeyID, srv.ServerID); err != nil { log.Printf("failed to auto-assign generated key: %v", err) } @@ -147,8 +147,6 @@ func (s *vantageServer) ReportChecks(ctx context.Context, req *pb.ReportChecksRe t := time.Unix(r.CertExpiryUnix, 0) res.CertExpiry = &t } - // A rejected monitor (wrong org, or not run by this agent) is skipped, - // not fatal — the rest of the batch is still legitimate. if err := services.IngestResult(srv.OrgID, srv.ServerID, r.MonitorId, res); err != nil { log.Printf("ingest check %s: %v", r.MonitorId, err) } @@ -157,7 +155,7 @@ func (s *vantageServer) ReportChecks(ctx context.Context, req *pb.ReportChecksRe } func (s *vantageServer) CommandStream(stream pb.Vantage_CommandStreamServer) error { - // First message authenticates the agent and signals readiness. + msg, err := stream.Recv() if err != nil { return status.Errorf(codes.InvalidArgument, "expected initial auth message: %v", err) @@ -178,8 +176,8 @@ func (s *vantageServer) CommandStream(stream pb.Vantage_CommandStreamServer) err log.Printf("agent %s connected command stream", srv.ServerID) defer log.Printf("agent %s disconnected command stream", srv.ServerID) - // Drain inbound results in the background so client Send calls never block. - // UploadGeneratedKey handles the real storage; these are just confirmation logs. + + go func() { for { m, err := stream.Recv() @@ -226,15 +224,15 @@ func StartGRPC(port int) error { } s := grpc.NewServer( - // Accept client keepalive pings as fast as every 20s so the 30s agent - // ping interval is always within the allowed window. + + grpc.KeepaliveEnforcementPolicy(keepalive.EnforcementPolicy{ MinTime: 20 * time.Second, PermitWithoutStream: false, }), grpc.KeepaliveParams(keepalive.ServerParameters{ - // Server also pings the client after 45s of inactivity so both - // sides can detect a dead connection without waiting for a timeout. + + Time: 45 * time.Second, Timeout: 10 * time.Second, }), diff --git a/server/internal/models/channel.go b/server/internal/models/channel.go index b6dcb0b..839a633 100644 --- a/server/internal/models/channel.go +++ b/server/internal/models/channel.go @@ -6,7 +6,7 @@ import ( "go.mongodb.org/mongo-driver/v2/bson" ) -// Notification channel types. + const ( ChannelWebhook = "webhook" ChannelSMTP = "smtp" @@ -15,9 +15,9 @@ const ( ChannelTelegram = "telegram" ) -// NotificationChannel is an outbound alert destination. Config holds -// type-specific settings (e.g. url; or smtp host/port/username/password/from/to; -// or telegram token/chat_id). + + + type NotificationChannel struct { ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"` OrgID string `bson:"org_id" json:"org_id"` diff --git a/server/internal/models/console_session.go b/server/internal/models/console_session.go index 7e2be00..3eda4ee 100644 --- a/server/internal/models/console_session.go +++ b/server/internal/models/console_session.go @@ -11,15 +11,15 @@ type ConsoleSession struct { OrgID string `bson:"org_id" json:"org_id"` SessionID string `bson:"session_id" json:"session_id"` ServerID string `bson:"server_id" json:"server_id"` - Protocol string `bson:"protocol" json:"protocol"` // ssh | rdp | vnc + Protocol string `bson:"protocol" json:"protocol"` KeyID string `bson:"key_id,omitempty" json:"key_id,omitempty"` User string `bson:"user" json:"user"` StartedAt time.Time `bson:"started_at" json:"started_at"` EndedAt *time.Time `bson:"ended_at,omitempty" json:"ended_at,omitempty"` ClientIP string `bson:"client_ip,omitempty" json:"client_ip,omitempty"` - // TokenConsumedAt marks the one-time session token as spent. Set atomically - // when the tunnel opens; a second open with the same token is rejected. + + TokenConsumedAt *time.Time `bson:"token_consumed_at,omitempty" json:"-"` SSHUsername string `bson:"ssh_username,omitempty" json:"ssh_username,omitempty"` diff --git a/server/internal/models/key.go b/server/internal/models/key.go index c2addaa..86ad450 100644 --- a/server/internal/models/key.go +++ b/server/internal/models/key.go @@ -13,7 +13,7 @@ type Key struct { Label string `bson:"label" json:"label"` PublicKey string `bson:"public_key" json:"public_key"` Fingerprint string `bson:"fingerprint" json:"fingerprint"` - Source string `bson:"source" json:"source"` // uploaded | generated + Source string `bson:"source" json:"source"` GeneratedByServerID string `bson:"generated_by_server_id,omitempty" json:"generated_by_server_id,omitempty"` PrivateKeyEncrypted string `bson:"private_key_enc,omitempty" json:"-"` HasPrivateKey bool `bson:"-" json:"has_private_key"` diff --git a/server/internal/models/monitor.go b/server/internal/models/monitor.go index 1875e0f..8f7860b 100644 --- a/server/internal/models/monitor.go +++ b/server/internal/models/monitor.go @@ -6,7 +6,7 @@ import ( "go.mongodb.org/mongo-driver/v2/bson" ) -// Monitor check types. + const ( MonitorHTTP = "http" MonitorTCP = "tcp" @@ -14,15 +14,15 @@ const ( MonitorTLS = "tls" ) -// Monitor status values. + const ( StatusUp = "up" StatusDown = "down" StatusPending = "pending" ) -// RunnerServer is the reserved Runner value for server-run monitors. Any other -// value is treated as a server_id whose agent runs the check locally. + + const RunnerServer = "server" type MonitorTarget struct { @@ -33,16 +33,16 @@ type MonitorTarget struct { ExpectedStatus int `bson:"expected_status,omitempty" json:"expected_status,omitempty"` Keyword string `bson:"keyword,omitempty" json:"keyword,omitempty"` TLSWarnDays int `bson:"tls_warn_days,omitempty" json:"tls_warn_days,omitempty"` - Insecure bool `bson:"insecure,omitempty" json:"insecure,omitempty"` // skip TLS cert verification (HTTP monitors) + Insecure bool `bson:"insecure,omitempty" json:"insecure,omitempty"` } type MonitorState struct { - Status string `bson:"status" json:"status"` // up|down|pending + Status string `bson:"status" json:"status"` LastCheckAt *time.Time `bson:"last_check_at,omitempty" json:"last_check_at,omitempty"` LatencyMs int `bson:"latency_ms" json:"latency_ms"` Message string `bson:"message,omitempty" json:"message,omitempty"` CertExpiryAt *time.Time `bson:"cert_expiry_at,omitempty" json:"cert_expiry_at,omitempty"` - Fails int `bson:"fails" json:"fails"` // consecutive failures + Fails int `bson:"fails" json:"fails"` LastNotifiedAt *time.Time `bson:"last_notified_at,omitempty" json:"last_notified_at,omitempty"` } @@ -51,11 +51,11 @@ type Monitor struct { OrgID string `bson:"org_id" json:"org_id"` MonitorID string `bson:"monitor_id" json:"monitor_id"` Name string `bson:"name" json:"name"` - Type string `bson:"type" json:"type"` // http|tcp|icmp|tls + Type string `bson:"type" json:"type"` Target MonitorTarget `bson:"target" json:"target"` IntervalSec int `bson:"interval_sec" json:"interval_sec"` - Runner string `bson:"runner" json:"runner"` // "server" or a server_id - Retries int `bson:"retries" json:"retries"` // consecutive fails before down + Runner string `bson:"runner" json:"runner"` + Retries int `bson:"retries" json:"retries"` Enabled bool `bson:"enabled" json:"enabled"` ChannelIDs []string `bson:"channel_ids,omitempty" json:"channel_ids,omitempty"` State MonitorState `bson:"state" json:"state"` @@ -74,7 +74,7 @@ type Incident struct { type Rollup struct { OrgID string `bson:"org_id" json:"org_id"` MonitorID string `bson:"monitor_id" json:"monitor_id"` - PeriodStart time.Time `bson:"period_start" json:"period_start"` // hour bucket + PeriodStart time.Time `bson:"period_start" json:"period_start"` Checks int `bson:"checks" json:"checks"` UpCount int `bson:"up_count" json:"up_count"` SumLatency int64 `bson:"sum_latency" json:"sum_latency"` diff --git a/server/internal/models/secret.go b/server/internal/models/secret.go index 6af18c1..bec0903 100644 --- a/server/internal/models/secret.go +++ b/server/internal/models/secret.go @@ -6,8 +6,8 @@ import ( "go.mongodb.org/mongo-driver/v2/bson" ) -// Secret is a single key/value pair within a group. The value is stored -// encrypted (AES-256-GCM) and is never serialized to JSON. + + type Secret struct { ID bson.ObjectID `bson:"_id,omitempty" json:"-"` OrgID string `bson:"org_id" json:"org_id"` @@ -17,7 +17,7 @@ type Secret struct { UpdatedAt time.Time `bson:"updated_at" json:"updated_at"` } -// GroupSummary describes a group in the list view. + type GroupSummary struct { Group string `json:"group"` KeyCount int `json:"key_count"` diff --git a/server/internal/models/settings.go b/server/internal/models/settings.go index 1c4c975..3e4b767 100644 --- a/server/internal/models/settings.go +++ b/server/internal/models/settings.go @@ -23,8 +23,8 @@ type EmailSettings struct { UseTLS bool `bson:"use_tls" json:"use_tls"` } -// SecretsSettings holds configuration for the secrets vault / ESO integration. -// The read token is stored as a SHA-256 hash and never returned to clients. + + type SecretsSettings struct { ReadTokenHash string `bson:"read_token_hash,omitempty" json:"-"` ReadTokenSet bool `bson:"-" json:"read_token_set"` @@ -37,6 +37,6 @@ type Settings struct { Alerts AlertSettings `bson:"alerts" json:"alerts"` Email EmailSettings `bson:"email" json:"email"` Secrets SecretsSettings `bson:"secrets" json:"secrets"` - // WorkflowLogRetentionDays: nil = default 30, 0 = keep forever, N = N days. + WorkflowLogRetentionDays *int `bson:"workflow_log_retention_days,omitempty" json:"workflow_log_retention_days,omitempty"` } diff --git a/server/internal/models/user.go b/server/internal/models/user.go index e69cefc..1a4707c 100644 --- a/server/internal/models/user.go +++ b/server/internal/models/user.go @@ -6,8 +6,8 @@ import ( "go.mongodb.org/mongo-driver/v2/bson" ) -// Org membership roles. These are the only values ever written to User.Role; -// anything arriving from a client must be checked with ValidRole first. + + const ( RoleOwner = "owner" RoleAdmin = "admin" @@ -28,8 +28,8 @@ type User struct { OrgID string `bson:"org_id" json:"org_id"` Email string `bson:"email" json:"email"` PasswordHash string `bson:"password_hash,omitempty" json:"-"` - Role string `bson:"role" json:"role"` // owner|admin|member - AuthSource string `bson:"auth_source" json:"auth_source"` // local|oidc + Role string `bson:"role" json:"role"` + AuthSource string `bson:"auth_source" json:"auth_source"` CreatedAt time.Time `bson:"created_at" json:"created_at"` LastLogin *time.Time `bson:"last_login,omitempty" json:"last_login,omitempty"` } diff --git a/server/internal/models/workflow.go b/server/internal/models/workflow.go index 0de6fd6..759588c 100644 --- a/server/internal/models/workflow.go +++ b/server/internal/models/workflow.go @@ -18,12 +18,12 @@ type WorkflowStep struct { StepID string `bson:"step_id" json:"step_id"` Name string `bson:"name" json:"name"` Description string `bson:"description" json:"description"` - Interpreter string `bson:"interpreter" json:"interpreter"` // "bash" | "powershell" + Interpreter string `bson:"interpreter" json:"interpreter"` Script string `bson:"script" json:"script"` DeclaredOutputs []string `bson:"declared_outputs" json:"declared_outputs"` DeclaredInputs []InputParam `bson:"declared_inputs" json:"declared_inputs"` SecretRefs []string `bson:"secret_refs" json:"secret_refs"` - Source string `bson:"source" json:"source"` // "user" | "default" + Source string `bson:"source" json:"source"` Slug string `bson:"slug,omitempty" json:"slug,omitempty"` CreatedAt time.Time `bson:"created_at" json:"created_at"` UpdatedAt time.Time `bson:"updated_at" json:"updated_at"` @@ -33,7 +33,7 @@ type WorkflowStepRef struct { StepID string `bson:"step_id,omitempty" json:"step_id,omitempty"` Inline *WorkflowStep `bson:"inline,omitempty" json:"inline,omitempty"` Order int `bson:"order" json:"order"` - OnFailure string `bson:"on_failure" json:"on_failure"` // "stop" | "continue" | "retry" + OnFailure string `bson:"on_failure" json:"on_failure"` MaxRetries int `bson:"max_retries" json:"max_retries"` Overrides *StepOverride `bson:"overrides,omitempty" json:"overrides,omitempty"` Inputs map[string]string `bson:"inputs,omitempty" json:"inputs,omitempty"` @@ -55,7 +55,7 @@ type Workflow struct { UpdatedAt time.Time `bson:"updated_at" json:"updated_at"` } -// ResolvedStep is a step frozen into a run snapshot (library step + overrides applied). + type ResolvedStep struct { Order int `bson:"order" json:"order"` Name string `bson:"name" json:"name"` @@ -70,7 +70,7 @@ type ResolvedStep struct { type StepRun struct { Order int `bson:"order" json:"order"` Name string `bson:"name" json:"name"` - Status string `bson:"status" json:"status"` // queued|running|success|failed|skipped + Status string `bson:"status" json:"status"` Attempts int `bson:"attempts" json:"attempts"` ExitCode int `bson:"exit_code" json:"exit_code"` LogOffset int64 `bson:"log_offset" json:"log_offset"` @@ -82,7 +82,7 @@ type StepRun struct { type ServerRun struct { ServerID string `bson:"server_id" json:"server_id"` Hostname string `bson:"hostname" json:"hostname"` - Status string `bson:"status" json:"status"` // queued|running|success|failed|skipped + Status string `bson:"status" json:"status"` StartedAt *time.Time `bson:"started_at,omitempty" json:"started_at,omitempty"` FinishedAt *time.Time `bson:"finished_at,omitempty" json:"finished_at,omitempty"` RunEnv map[string]string `bson:"run_env" json:"run_env"` @@ -96,7 +96,7 @@ type WorkflowRun struct { WorkflowID string `bson:"workflow_id" json:"workflow_id"` Name string `bson:"name" json:"name"` Steps []ResolvedStep `bson:"steps_snapshot" json:"steps_snapshot"` - Status string `bson:"status" json:"status"` // running|success|failed|cancelled + Status string `bson:"status" json:"status"` TriggeredBy string `bson:"triggered_by" json:"triggered_by"` StartedAt time.Time `bson:"started_at" json:"started_at"` FinishedAt *time.Time `bson:"finished_at,omitempty" json:"finished_at,omitempty"` diff --git a/server/internal/monitorsched/scheduler.go b/server/internal/monitorsched/scheduler.go index 884e3ae..5b90837 100644 --- a/server/internal/monitorsched/scheduler.go +++ b/server/internal/monitorsched/scheduler.go @@ -1,6 +1,3 @@ -// Package monitorsched runs server-side monitors on their configured interval -// and funnels results through services.IngestResult. Agent-run monitors -// (runner != "server") are excluded — those execute on the agent. package monitorsched import ( @@ -14,8 +11,6 @@ import ( "github.com/mrhid6/vantage/server/internal/services" ) -// reloadInterval controls how often the scheduler re-reads monitor definitions -// so CRUD changes (new/removed/edited monitors) take effect. const reloadInterval = 30 * time.Second type runner struct { @@ -24,8 +19,6 @@ type runner struct { cancel context.CancelFunc } -// Start launches the scheduler loop. It returns immediately; the loop runs until -// ctx is cancelled. func Start(ctx context.Context) { go loop(ctx) } @@ -47,7 +40,7 @@ func loop(ctx context.Context) { mu.Lock() defer mu.Unlock() - // Stop runners for monitors that vanished or changed interval. + for id, r := range active { m, ok := want[id] if !ok || m.IntervalSec != r.intervalSec { @@ -55,7 +48,7 @@ func loop(ctx context.Context) { delete(active, id) } } - // Start runners for new/changed monitors. + for id, m := range want { if _, ok := active[id]; ok { continue @@ -93,7 +86,7 @@ func runMonitor(ctx context.Context, m models.Monitor) { } } - run() // check immediately on (re)start + run() t := time.NewTicker(interval) defer t.Stop() for { diff --git a/server/internal/notify/dispatch.go b/server/internal/notify/dispatch.go index 00a846b..00e9545 100644 --- a/server/internal/notify/dispatch.go +++ b/server/internal/notify/dispatch.go @@ -1,6 +1,6 @@ -// Package notify formats and delivers monitor state-change alerts to -// notification channels. It depends only on models so services can call it -// without an import cycle. + + + package notify import ( @@ -10,7 +10,7 @@ import ( "github.com/mrhid6/vantage/server/internal/models" ) -// Event describes a monitor state transition worth alerting on. + type Event struct { MonitorName string Type string @@ -20,7 +20,7 @@ type Event struct { Time time.Time } -// title is a short one-line summary used by the text-based channels. + func (e Event) title() string { verb := "recovered" if e.NewStatus == models.StatusDown { @@ -33,7 +33,7 @@ func (e Event) title() string { return s } -// Dispatch delivers ev to a single channel, formatting per channel type. + func Dispatch(ch models.NotificationChannel, ev Event) error { switch ch.Type { case models.ChannelWebhook: @@ -51,7 +51,7 @@ func Dispatch(ch models.NotificationChannel, ev Event) error { } } -// Test delivers a synthetic event so users can verify a channel's configuration. + func Test(ch models.NotificationChannel) error { return Dispatch(ch, Event{ MonitorName: "Test monitor", diff --git a/server/internal/notify/http.go b/server/internal/notify/http.go index 5880fec..703223b 100644 --- a/server/internal/notify/http.go +++ b/server/internal/notify/http.go @@ -28,7 +28,7 @@ func postJSON(target string, payload any) error { return nil } -// dispatchWebhook posts the full event as JSON to a user-supplied URL. + func dispatchWebhook(ch models.NotificationChannel, ev Event) error { target := ch.Config["url"] if target == "" { diff --git a/server/internal/notify/smtp.go b/server/internal/notify/smtp.go index a7830e2..3537716 100644 --- a/server/internal/notify/smtp.go +++ b/server/internal/notify/smtp.go @@ -13,13 +13,13 @@ import ( const smtpTimeout = 15 * time.Second -// dispatchSMTP sends the alert as a plain-text email. Config keys: host, port, -// username, password, from, to. Auth is skipped when username is empty. Port 465 -// uses implicit TLS; other ports use STARTTLS when the server advertises it. -// -// It dials with a timeout and sets a connection deadline so an unreachable or -// misconfigured SMTP host fails fast instead of hanging the request until the OS -// TCP timeout (which resets the upstream proxy connection). + + + + + + + func dispatchSMTP(ch models.NotificationChannel, ev Event) error { host := ch.Config["host"] port := ch.Config["port"] @@ -36,7 +36,7 @@ func dispatchSMTP(ch models.NotificationChannel, ev Event) error { } _ = conn.SetDeadline(time.Now().Add(smtpTimeout)) - // Implicit TLS on 465; otherwise start plain and upgrade via STARTTLS. + if port == "465" { conn = tls.Client(conn, &tls.Config{ServerName: host}) } diff --git a/server/internal/notify/template.go b/server/internal/notify/template.go index 028a61d..49031cf 100644 --- a/server/internal/notify/template.go +++ b/server/internal/notify/template.go @@ -10,7 +10,7 @@ import ( "github.com/mrhid6/vantage/server/internal/models" ) -// App theme colors (mirrors web/tailwind.config.ts). + const ( colBg = "#0f1117" colSurface = "#1a1d27" @@ -23,7 +23,7 @@ const ( colDanger = "#ef4444" ) -// statusColor returns the accent color for a monitor status. + func statusColor(status string) string { switch status { case models.StatusUp: @@ -35,8 +35,8 @@ func statusColor(status string) string { } } -// buildMIME assembles a multipart/alternative message (plain + HTML) with the -// standard email headers, ready to hand to the SMTP DATA command. + + func buildMIME(from, to, subject, text, htmlBody string) ([]byte, error) { var buf strings.Builder w := multipart.NewWriter(&buf) @@ -66,7 +66,6 @@ func buildMIME(from, to, subject, text, htmlBody string) ([]byte, error) { return []byte(head.String() + buf.String()), nil } -// htmlEmail renders the alert as a dark-themed HTML email matching the app. func htmlEmail(ev Event) string { accent := statusColor(ev.NewStatus) label := "Recovered" @@ -77,7 +76,7 @@ func htmlEmail(ev Event) string { esc := html.EscapeString row := func(k, v string) string { if v == "" { - v = "—" + v = "" } return fmt.Sprintf( ``+ @@ -151,7 +150,7 @@ func htmlEmail(ev Event) string { ) } -// textEmail renders the plain-text fallback. + func textEmail(ev Event) string { return strings.Join([]string{ ev.title(), diff --git a/server/internal/services/channels.go b/server/internal/services/channels.go index beaaff7..70d5efc 100644 --- a/server/internal/services/channels.go +++ b/server/internal/services/channels.go @@ -41,7 +41,7 @@ func GetChannel(orgID, channelID string) (*models.NotificationChannel, error) { return &ch, nil } -// GetChannels loads multiple channels by ID within an org, skipping any not found. + func GetChannels(orgID string, channelIDs []string) ([]models.NotificationChannel, error) { if len(channelIDs) == 0 { return nil, nil @@ -59,8 +59,8 @@ func GetChannels(orgID string, channelIDs []string) ([]models.NotificationChanne return out, nil } -// validateChannelIDs rejects any channel that does not belong to the org. -// Channel IDs arrive from the client as data on monitor writes. + + func validateChannelIDs(orgID string, channelIDs []string) error { for _, id := range channelIDs { ch, err := GetChannel(orgID, id) @@ -103,7 +103,7 @@ func DeleteChannel(orgID, channelID string) error { return err } -// TestChannel sends a synthetic alert to verify configuration. + func TestChannel(orgID, channelID string) error { ch, err := GetChannel(orgID, channelID) if err != nil { diff --git a/server/internal/services/console.go b/server/internal/services/console.go index 606c149..6c6a0b3 100644 --- a/server/internal/services/console.go +++ b/server/internal/services/console.go @@ -17,7 +17,7 @@ import ( ) func sessionHMACKey() ([]byte, error) { - // Reuse the AES key material as the HMAC secret. Distinct domain via prefix. + k, err := encryptionKey() if err != nil { return nil, err @@ -29,7 +29,7 @@ func sessionHMACKey() ([]byte, error) { func b64(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) } -// SignSessionToken returns a signed, expiring token binding a session id. + func SignSessionToken(sessionID string, ttl time.Duration) (string, error) { key, err := sessionHMACKey() if err != nil { @@ -42,7 +42,7 @@ func SignSessionToken(sessionID string, ttl time.Duration) (string, error) { return payload + "." + b64(mac.Sum(nil)), nil } -// VerifySessionToken checks signature + expiry and returns the session id. + func VerifySessionToken(token string) (string, error) { parts := strings.Split(token, ".") if len(parts) != 3 { @@ -86,10 +86,10 @@ func portOr(v, def int) string { return strconv.Itoa(v) } -// BuildGuacParams assembles the guacd connection parameter map for a protocol. -// privateKey/passphrase are the decrypted SSH private key and its optional -// passphrase (ssh only); rdpUser/rdpPass are used for rdp, and rdpPass carries -// the password for vnc. None of these values are persisted or logged by the caller. + + + + func BuildGuacParams(srv *models.Server, protocol, sshUser, privateKey, passphrase, rdpUser, rdpPass string) (*GuacParams, error) { host := srv.IPAddress switch protocol { @@ -159,8 +159,8 @@ func GetConsoleSession(orgID, sessionID string) (*models.ConsoleSession, error) return &s, nil } -// StashConsoleRDPCreds encrypts and stores single-use RDP credentials on the -// session document. They are consumed (and cleared) when the tunnel opens. + + func StashConsoleRDPCreds(orgID, sessionID, username, password string) error { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() @@ -179,9 +179,9 @@ func StashConsoleRDPCreds(orgID, sessionID, username, password string) error { return err } -// ConsumeConsoleRDPCreds decrypts and returns the stored RDP credentials, then -// clears them from the session document (single-use). Returns empty strings if -// none were stored. + + + func ConsumeConsoleRDPCreds(orgID, sessionID string) (username, password string, err error) { s, err := GetConsoleSession(orgID, sessionID) if err != nil { @@ -209,7 +209,7 @@ func ConsumeConsoleRDPCreds(orgID, sessionID string) (username, password string, return username, password, nil } -// SetConsoleSSHUser persists the SSH username to use on the session doc. + func SetConsoleSSHUser(orgID, sessionID, username string) error { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() @@ -219,9 +219,9 @@ func SetConsoleSSHUser(orgID, sessionID, username string) error { return err } -// ConsumeSessionToken atomically marks a session's one-time token as spent. -// It returns an error if the token was already consumed (replay) or the session -// does not exist, so the tunnel can be opened at most once per issued token. + + + func ConsumeSessionToken(orgID, sessionID string) error { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() diff --git a/server/internal/services/crypto.go b/server/internal/services/crypto.go index 1a0a287..12c25ef 100644 --- a/server/internal/services/crypto.go +++ b/server/internal/services/crypto.go @@ -22,8 +22,8 @@ func encryptionKey() ([]byte, error) { return key, nil } -// encryptString encrypts a plaintext value with AES-256-GCM using the -// shared KEY_ENCRYPTION_KEY, returning hex(nonce + ciphertext). + + func encryptString(plaintext string) (string, error) { key, err := encryptionKey() if err != nil { @@ -45,7 +45,7 @@ func encryptString(plaintext string) (string, error) { return hex.EncodeToString(sealed), nil } -// decryptString reverses encryptString. + func decryptString(ciphertextHex string) (string, error) { key, err := encryptionKey() if err != nil { diff --git a/server/internal/services/defaults.go b/server/internal/services/defaults.go index 65856f7..8ccb9c6 100644 --- a/server/internal/services/defaults.go +++ b/server/internal/services/defaults.go @@ -12,7 +12,7 @@ import ( "go.mongodb.org/mongo-driver/v2/mongo/options" ) -// DefaultStepsDir returns the directory holding default step JSON files. + func DefaultStepsDir() string { dir := os.Getenv("VANTAGE_DEFAULT_STEPS_DIR") if dir == "" { @@ -22,9 +22,9 @@ func DefaultStepsDir() string { return dir } -// readDefaultStepFiles parses every *.json in the defaults dir into -// source=default library steps (with slug set). Non-json and invalid files are -// skipped silently; a slug is derived from the step name. + + + func readDefaultStepFiles() ([]models.WorkflowStep, error) { matches, err := filepath.Glob(filepath.Join(DefaultStepsDir(), "*.json")) if err != nil { @@ -50,8 +50,8 @@ func readDefaultStepFiles() ([]models.WorkflowStep, error) { return out, nil } -// SeedDefaultSteps upserts default steps from disk keyed on {slug, source}. -// Re-sync overwrites default-step content; user steps are never touched. + + func SeedDefaultSteps(orgID string) (created, updated int, err error) { steps, err := readDefaultStepFiles() if err != nil { diff --git a/server/internal/services/dispatch.go b/server/internal/services/dispatch.go index b7b008f..43258e2 100644 --- a/server/internal/services/dispatch.go +++ b/server/internal/services/dispatch.go @@ -17,13 +17,13 @@ type commandDispatcher struct { channels map[string]chan *pb.ServerCommand } -// Dispatcher is the singleton command dispatcher used by both the gRPC server -// and the REST API to push commands to connected agents. + + var Dispatcher = &commandDispatcher{ channels: make(map[string]chan *pb.ServerCommand), } -// Connect registers an agent's command channel. Returns the channel to drain. + func (d *commandDispatcher) Connect(serverID string) chan *pb.ServerCommand { ch := make(chan *pb.ServerCommand, 16) d.mu.Lock() @@ -32,14 +32,14 @@ func (d *commandDispatcher) Connect(serverID string) chan *pb.ServerCommand { return ch } -// Disconnect removes the agent's channel on stream close. + func (d *commandDispatcher) Disconnect(serverID string) { d.mu.Lock() delete(d.channels, serverID) d.mu.Unlock() } -// IsConnected reports whether an agent is currently holding a CommandStream. + func (d *commandDispatcher) IsConnected(serverID string) bool { d.mu.RLock() _, ok := d.channels[serverID] @@ -62,15 +62,15 @@ func (d *commandDispatcher) dispatch(serverID string, cmd *pb.ServerCommand) err } } -// DispatchRunStep pushes a RunStepCmd to a server's agent. Caller must have -// registered StepResults.Await(commandID) first. + + func DispatchRunStep(serverID, commandID string, cmd *pb.RunStepCmd) error { return Dispatcher.dispatch(serverID, &pb.ServerCommand{CommandId: commandID, RunStep: cmd}) } -// DispatchCleanupWorkspace tells a server's agent to remove a run's working -// directory. Best-effort and fire-and-forget: if the agent is gone the temp dir -// is reclaimed by the OS on reboot anyway. + + + func DispatchCleanupWorkspace(serverID, workspaceID string) { if !Dispatcher.IsConnected(serverID) { return @@ -81,7 +81,7 @@ func DispatchCleanupWorkspace(serverID, workspaceID string) { }) } -// KeyGenParams carries all options for a generate-key command. + type KeyGenParams struct { Label string KeyType string @@ -90,15 +90,15 @@ type KeyGenParams struct { Comment string } -// GetLatestAgentVersion queries the Gitea API for the latest agent/v* release tag -// and returns just the version number (e.g. "1.2.3"). + + func GetLatestAgentVersion() (string, error) { giteaHost := os.Getenv("GITEA_HOST") if giteaHost == "" { giteaHost = "gitea.example.com" } url := fmt.Sprintf("https://%s/api/v1/repos/mrhid6/vantage/releases?limit=20", giteaHost) - resp, err := http.Get(url) //nolint:gosec + resp, err := http.Get(url) if err != nil { return "", fmt.Errorf("fetch releases: %w", err) } @@ -122,8 +122,8 @@ func GetLatestAgentVersion() (string, error) { return "", fmt.Errorf("no agent release found") } -// DispatchUpdateAgent sends an update command to the named server's agent. -// It fetches the latest version from Gitea and includes the download base URL. + + func DispatchUpdateAgent(serverID string) (string, error) { if !Dispatcher.IsConnected(serverID) { return "", fmt.Errorf("agent is not connected to the command stream") @@ -153,7 +153,7 @@ func DispatchUpdateAgent(serverID string) (string, error) { return version, nil } -// DispatchApplyUpdates sends an apply-updates command to the named server's agent. + func DispatchApplyUpdates(serverID string) error { if !Dispatcher.IsConnected(serverID) { return fmt.Errorf("agent is not connected to the command stream") @@ -165,8 +165,8 @@ func DispatchApplyUpdates(serverID string) error { return Dispatcher.dispatch(serverID, cmd) } -// DispatchDeleteKey sends a delete-key command to the named server's agent. -// It is best-effort: if the agent is offline the local files will remain until next connection. + + func DispatchDeleteKey(serverID, label string) { if !Dispatcher.IsConnected(serverID) { return @@ -176,13 +176,13 @@ func DispatchDeleteKey(serverID, label string) { DeleteKey: &pb.DeleteKeyCmd{Label: label}, } if err := Dispatcher.dispatch(serverID, cmd); err != nil { - // Non-fatal: agent will clean up files on next manual intervention or reinstall. + _ = err } } -// DispatchGenerateKey sends a generate-key command to the named server's agent. -// Returns the command ID that can be used to correlate the agent's result. + + func DispatchGenerateKey(serverID string, p KeyGenParams) (string, error) { if !Dispatcher.IsConnected(serverID) { return "", fmt.Errorf("agent is not connected to the command stream") diff --git a/server/internal/services/inventory.go b/server/internal/services/inventory.go index adf96ad..29c9ba9 100644 --- a/server/internal/services/inventory.go +++ b/server/internal/services/inventory.go @@ -9,8 +9,8 @@ import ( "go.mongodb.org/mongo-driver/v2/bson" ) -// StoreInventory upserts the latest inventory snapshot onto the server document. -// Metrics fields update every call; static fields only when r.IncludeStatic. + + func StoreInventory(serverID string, r *pb.InventoryReport) error { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() diff --git a/server/internal/services/keys.go b/server/internal/services/keys.go index 9f3900f..d0caeac 100644 --- a/server/internal/services/keys.go +++ b/server/internal/services/keys.go @@ -99,9 +99,6 @@ func GetPrivateKey(orgID, keyID string) (string, error) { return decryptPrivateKey(key.PrivateKeyEncrypted) } -// GetPassphrase returns the decrypted passphrase for a key, or an empty string -// if the key has none stored. Agent-path (keyed by unique key_id from an -// assignment lookup) — no org filter. func GetPassphrase(keyID string) (string, error) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() @@ -175,8 +172,6 @@ func AssignKey(orgID, keyID, serverID string) (*models.Assignment, error) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - // Both sides must belong to the caller's org — the IDs arrive from the - // client as data and are consumed by unscoped agent-path queries later. if _, err := GetKey(orgID, keyID); err != nil { return nil, fmt.Errorf("key not found") } @@ -184,7 +179,7 @@ func AssignKey(orgID, keyID, serverID string) (*models.Assignment, error) { return nil, fmt.Errorf("server not found") } - // Check if already assigned and active + var existing models.Assignment err := db.Col("assignments").FindOne(ctx, bson.M{ "org_id": orgID, diff --git a/server/internal/services/migrate.go b/server/internal/services/migrate.go index a17916f..061a8e9 100644 --- a/server/internal/services/migrate.go +++ b/server/internal/services/migrate.go @@ -21,7 +21,6 @@ var scopedCollections = []string{ "console_sessions", "incidents", "monitor_rollups", } -// EnsureAuthIndexes creates unique indexes for the new auth collections. func EnsureAuthIndexes() error { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() @@ -47,18 +46,12 @@ func EnsureAuthIndexes() error { return nil } -// defaultBackfillOrg resolves the org that org-less legacy documents belong to: -// the "default" org, created if absent. Shared by 0001 and 0003 so an instance -// that ran either one converges on the same org. func defaultBackfillOrg(ctx context.Context) (*models.Org, error) { var org models.Org err := db.Col("orgs").FindOne(ctx, bson.M{"slug": "default"}).Decode(&org) switch { case err == nil: case errors.Is(err, mongo.ErrNoDocuments): - // Only a genuine absence justifies an insert. Treating a timeout or a - // decode failure as "absent" would race the fatal unique orgs.slug index - // and turn a transient blip into a boot crash. org = models.Org{OrgID: uuid.NewString(), Name: "Default", Slug: "default", CreatedAt: time.Now()} if _, err := db.Col("orgs").InsertOne(ctx, org); err != nil { return nil, err @@ -69,8 +62,6 @@ func defaultBackfillOrg(ctx context.Context) (*models.Org, error) { return &org, nil } -// RunMigrations backfills a default org onto pre-existing documents. Idempotent -// via a marker in the migrations collection. func RunMigrations() error { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() @@ -80,7 +71,7 @@ func RunMigrations() error { return nil } - // Only backfill if there is legacy data lacking org_id. + needs := false for _, col := range scopedCollections { n, _ := db.Col(col).CountDocuments(ctx, bson.M{"org_id": bson.M{"$exists": false}}) @@ -109,18 +100,6 @@ func RunMigrations() error { return err } -// MigrateMissedOrgScopes repairs collections that migration 0001 could not -// reach. 0001 originally listed "audit" and "channels", but the real collections -// are audit_logs and notification_channels, so on any instance that ran that -// version those documents were left without org_id — invisible to org-filtered -// reads, and in the channels' case silently non-firing. The 0001 marker is -// already written there, so renaming alone does not repair them; this migration -// converges both the never-migrated and the incorrectly-migrated case. -// -// It also stamps console_sessions, incidents and monitor_rollups, which gained -// an org_id only after 0001 shipped. Those carry an owning monitor/server whose -// org is authoritative, so they are derived rather than defaulted. Idempotent -// via a marker in the migrations collection. func MigrateMissedOrgScopes() error { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() @@ -130,7 +109,7 @@ func MigrateMissedOrgScopes() error { return nil } - // Same org resolution 0001 uses, for the collections it meant to cover. + missed := []string{"audit_logs", "notification_channels"} needs := false for _, col := range missed { @@ -155,8 +134,6 @@ func MigrateMissedOrgScopes() error { } } - // Derived from the owning record — defaulting these would hand one org - // another org's console history and incident timeline. if err := backfillOrgFromOwner(ctx, "console_sessions", "server_id", "servers", "server_id"); err != nil { return err } @@ -171,13 +148,8 @@ func MigrateMissedOrgScopes() error { return err } -// backfillOrgFromOwner stamps org_id on every doc in col that lacks one, taking -// the org from the record in ownerCol it points at. Orphans (owner already -// deleted) are left alone; they are unreachable either way. func backfillOrgFromOwner(ctx context.Context, col, localField, ownerCol, ownerField string) error { - // Decoded loosely: a single null or non-string value in the collection would - // fail a []string decode and abort the migration — and therefore boot — over - // one unusable document. Skip what we cannot use instead. + var raw []bson.RawValue if err := db.Col(col).Distinct(ctx, localField, bson.M{"org_id": bson.M{"$exists": false}}).Decode(&raw); err != nil { @@ -207,10 +179,6 @@ func backfillOrgFromOwner(ctx context.Context, col, localField, ownerCol, ownerF return nil } -// MigrateSettingsOrg stamps the legacy global settings singleton with the -// default org's ID. Without it an upgrade would orphan the existing SMTP -// config, alert config, retention setting, and ESO read token. Idempotent via -// a marker in the migrations collection. func MigrateSettingsOrg() error { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() @@ -222,10 +190,6 @@ func MigrateSettingsOrg() error { n, _ := db.Col("settings").CountDocuments(ctx, bson.M{"org_id": bson.M{"$exists": false}}) if n > 0 { - // The org-less settings doc belongs to whichever org already exists — - // migration 0001 only creates a "default" org when there was legacy - // data to backfill, so keying off that slug would invent a phantom org - // and move the real org's config onto it. var org models.Org orgCount, err := db.Col("orgs").CountDocuments(ctx, bson.M{}) if err != nil { @@ -242,12 +206,6 @@ func MigrateSettingsOrg() error { return err } default: - // Ambiguous: several orgs but an unstamped settings doc. Guessing - // would hand one org another's SMTP config and ESO token. Continuing - // is not an option either: the unique settings.org_id index built - // straight after this indexes every unstamped doc as null, so two or - // more of them collide and boot fails there instead — with a far less - // useful message. Stop here, where we can name the remedy. return fmt.Errorf( "settings org migration: %d settings document(s) have no org_id but %d orgs exist; "+ "cannot infer the owner. Set org_id manually on each settings document "+ diff --git a/server/internal/services/monitors.go b/server/internal/services/monitors.go index be27143..4cbfc5b 100644 --- a/server/internal/services/monitors.go +++ b/server/internal/services/monitors.go @@ -21,7 +21,7 @@ func monCtx() (context.Context, context.CancelFunc) { return context.WithTimeout(context.Background(), 5*time.Second) } -// SpecFor maps a monitor onto a checker.Spec. + func SpecFor(m *models.Monitor) checker.Spec { return checker.Spec{ Type: m.Type, @@ -51,12 +51,6 @@ func ListMonitors(orgID string) ([]models.Monitor, error) { return out, nil } -// ListMonitorsForRunner returns enabled monitors whose Runner matches runner, -// scoped to orgID. Runner is client-supplied at write time, so an agent fetching -// its own work must scope by the org of its authenticated server record — -// otherwise another org could point a monitor at that server_id and have it run -// their checks. An empty orgID is rejected: it would silently widen the query to -// every org. func ListMonitorsForRunner(orgID, runner string) ([]models.Monitor, error) { if orgID == "" { return nil, errors.New("org id required") @@ -64,16 +58,10 @@ func ListMonitorsForRunner(orgID, runner string) ([]models.Monitor, error) { return listMonitorsForRunner(orgID, runner) } -// ListServerScheduledMonitors returns every enabled server-run monitor across -// all orgs. This is the in-process scheduler's entry point (mirrors the cross-org -// MarkOfflineServers sweep) and must never be called from a request-driven path — -// it performs no org scoping at all. func ListServerScheduledMonitors() ([]models.Monitor, error) { return listMonitorsForRunner("", models.RunnerServer) } -// listMonitorsForRunner is the shared query. An empty orgID means no org filter -// and is only reachable via ListServerScheduledMonitors. func listMonitorsForRunner(orgID, runner string) ([]models.Monitor, error) { ctx, cancel := monCtx() defer cancel() @@ -92,7 +80,7 @@ func listMonitorsForRunner(orgID, runner string) ([]models.Monitor, error) { return out, nil } -// GetMonitor looks up a monitor scoped to an org (handler/session use). + func GetMonitor(orgID, monitorID string) (*models.Monitor, error) { ctx, cancel := monCtx() defer cancel() @@ -107,8 +95,6 @@ func GetMonitor(orgID, monitorID string) (*models.Monitor, error) { return &m, nil } -// getMonitorByID looks up a monitor by its unique monitor_id with no org -// filter. For agent/scheduler use only (IngestResult), which has no session. func getMonitorByID(monitorID string) (*models.Monitor, error) { ctx, cancel := monCtx() defer cancel() @@ -123,10 +109,6 @@ func getMonitorByID(monitorID string) (*models.Monitor, error) { return &m, nil } -// validateRunner rejects a runner that is neither the reserved server-scheduler -// value nor a server in the org. The value is client-supplied and is later -// consumed by an agent's own monitor fetch, so ownership has to be proven at -// the write boundary. func validateRunner(orgID, runner string) error { if runner == "" || runner == models.RunnerServer { return nil @@ -168,8 +150,8 @@ func CreateMonitor(orgID string, m *models.Monitor) (*models.Monitor, error) { func UpdateMonitor(orgID, monitorID string, upd bson.M) error { ctx, cancel := monCtx() defer cancel() - // A present-but-wrong-type value is a hard error: silently skipping the - // check would still let the unvalidated value through to the $set. + + if raw, present := upd["channel_ids"]; present { ids, ok := raw.([]string) if !ok { @@ -187,9 +169,9 @@ func UpdateMonitor(orgID, monitorID string, upd bson.M) error { if err := validateRunner(orgID, runner); err != nil { return err } - // Match CreateMonitor: an empty runner means the server scheduler. - // Storing "" would match no runner at all and silently stop the - // monitor being checked. + + + if runner == "" { upd["runner"] = models.RunnerServer } @@ -205,7 +187,7 @@ func DeleteMonitor(orgID, monitorID string) error { if err != nil { return err } - // Only cascade when the org-scoped delete actually removed a monitor. + if res.DeletedCount == 0 { return nil } @@ -232,7 +214,7 @@ func ListIncidents(orgID, monitorID string, limit int64) ([]models.Incident, err return out, nil } -// UptimeRollups returns hourly rollups for a monitor since the cutoff, oldest first. + func UptimeRollups(orgID, monitorID string, since time.Time) ([]models.Rollup, error) { ctx, cancel := monCtx() defer cancel() @@ -249,16 +231,6 @@ func UptimeRollups(orgID, monitorID string, since time.Time) ([]models.Rollup, e return out, nil } -// IngestResult applies a check result to a monitor: updates state, opens/resolves -// incidents on up<->down transitions, rolls up the hourly bucket, and fires -// notifications on transition. Both the server scheduler and agent-reported -// results funnel through here. -// -// monitorID is client-supplied on the agent path, so the caller passes the org -// and runner it is authenticated as: orgID is the reporting agent's server org -// and runner is its server_id. A result is only applied to a monitor owned by -// that org and assigned to that runner. An empty orgID is rejected — it would -// skip the ownership check entirely. func IngestResult(orgID, runner, monitorID string, res checker.Result) error { if orgID == "" { return errors.New("org id required") @@ -266,16 +238,10 @@ func IngestResult(orgID, runner, monitorID string, res checker.Result) error { return ingestResult(orgID, runner, monitorID, res) } -// IngestServerScheduledResult applies a result produced by the in-process server -// scheduler, which has no org context of its own. This is the scheduler's entry -// point and must never be called from a request-driven path — it skips the org -// ownership check. func IngestServerScheduledResult(monitorID string, res checker.Result) error { return ingestResult("", models.RunnerServer, monitorID, res) } -// ingestResult is the shared implementation. An empty orgID skips the org -// ownership check and is only reachable via IngestServerScheduledResult. func ingestResult(orgID, runner, monitorID string, res checker.Result) error { ctx, cancel := monCtx() defer cancel() @@ -284,9 +250,6 @@ func ingestResult(orgID, runner, monitorID string, res checker.Result) error { if err != nil { return err } - // Report not-found the same way as a cross-org hit, so probing an unknown - // monitor_id is no quieter than probing a foreign one and stale monitors - // stay visible to operators. if m == nil { return fmt.Errorf("monitor %s not found", monitorID) } @@ -332,14 +295,14 @@ func ingestResult(orgID, runner, monitorID string, res checker.Result) error { return err } - // Hourly rollup. + bucket := now.Truncate(time.Hour) up := 0 if res.Up { up = 1 } - // org_id via $setOnInsert rather than the filter: a legacy bucket written - // before rollups were tenanted must keep accumulating, not fork in two. + + db.Col("monitor_rollups").UpdateOne(ctx, bson.M{"monitor_id": monitorID, "period_start": bucket}, bson.M{ @@ -348,7 +311,7 @@ func ingestResult(orgID, runner, monitorID string, res checker.Result) error { }, options.UpdateOne().SetUpsert(true)) - // Transition handling. + if newStatus != prev { switch newStatus { case models.StatusDown: @@ -373,9 +336,9 @@ func ingestResult(orgID, runner, monitorID string, res checker.Result) error { return nil } -// notifyTransition dispatches notifications on an up<->down transition to each -// enabled channel bound to the monitor. Deliveries run in the background; -// failures are logged, not fatal. + + + func notifyTransition(m *models.Monitor, newStatus, message string) { if len(m.ChannelIDs) == 0 { return diff --git a/server/internal/services/org_oidc.go b/server/internal/services/org_oidc.go index 1b37e03..d5843ec 100644 --- a/server/internal/services/org_oidc.go +++ b/server/internal/services/org_oidc.go @@ -29,8 +29,8 @@ func GetOrgOIDCSecret(orgID string) (string, error) { return decryptString(o.ClientSecretEnc) } -// SaveOrgOIDC upserts the org's provider config. An empty clientSecret keeps the -// stored secret (so the UI need not resend it). + + func SaveOrgOIDC(orgID, issuer, clientID, clientSecret string, enabled bool) error { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() diff --git a/server/internal/services/orgs.go b/server/internal/services/orgs.go index 27f345a..49edfc2 100644 --- a/server/internal/services/orgs.go +++ b/server/internal/services/orgs.go @@ -40,8 +40,8 @@ func GetOrgBySlug(slug string) (*models.Org, error) { return &o, nil } -// ListOrgIDs returns the org_id of every organization. Used by startup tasks -// (e.g. seeding default workflow steps) that must run once per org. + + func ListOrgIDs() ([]string, error) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() @@ -61,17 +61,15 @@ func ListOrgIDs() ([]string, error) { return ids, nil } -// CountOrgs returns the number of organizations on the instance. Used by -// first-run bootstrap to tell "empty instance" from "upgraded single-tenant -// instance whose data already sits under a migration-created org". + + + func CountOrgs() (int64, error) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() return db.Col("orgs").CountDocuments(ctx, bson.M{}) } -// FirstOrg returns the sole/earliest org. Callers must have established that -// exactly one exists before treating it as authoritative. func FirstOrg() (*models.Org, error) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() @@ -82,15 +80,6 @@ func FirstOrg() (*models.Org, error) { return &o, nil } -// AdoptOrg renames an existing org to name, re-slugging it when the new slug is -// clean to take. It exists for the upgrade path: migration 0001 stamps every -// legacy document with the "default" org's ID, so bootstrap must claim that org -// rather than mint a second one — otherwise the operator signs in to an empty -// instance while all their servers and keys stay behind under "default". -// -// The slug is only changed when the derived one is usable and free; anything -// else keeps the current slug, including the reserved "default", which stays -// valid because it is pre-existing rather than newly chosen. func AdoptOrg(orgID, name string) (*models.Org, error) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() @@ -135,7 +124,7 @@ func CreateOrg(name string) (*models.Org, error) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - // Resolve slug collision by suffixing -2, -3, ... + slug := base for i := 2; ; i++ { n, err := db.Col("orgs").CountDocuments(ctx, bson.M{"slug": slug}) @@ -156,9 +145,9 @@ func CreateOrg(name string) (*models.Org, error) { return nil, err } - // Boot-time seeding only covers orgs that already existed, so an org created - // at runtime would have an empty step library until the next restart. Not - // fatal: the org is usable without it and seeding is retried on boot. + + + if created, updated, err := SeedDefaultSteps(o.OrgID); err != nil { log.Printf("warning: failed to seed default steps for new org %s: %v", o.OrgID, err) } else { diff --git a/server/internal/services/secrets.go b/server/internal/services/secrets.go index 35a44b4..47fbd6d 100644 --- a/server/internal/services/secrets.go +++ b/server/internal/services/secrets.go @@ -14,9 +14,6 @@ import ( "go.mongodb.org/mongo-driver/v2/mongo/options" ) -// EnsureSecretIndexes creates the unique compound index on (org_id, group, key). -// The pre-multi-tenant index was on (group, key) alone, which made a second org -// collide on the same group/key — drop it if a live DB still carries it. func EnsureSecretIndexes() error { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() @@ -32,12 +29,6 @@ func EnsureSecretIndexes() error { return err } -// isIndexNotFound reports whether err is Mongo's IndexNotFound (27), returned -// when dropping an index that was never created, or NamespaceNotFound (26), -// returned when the collection itself does not exist yet. Both mean "there is -// no legacy index to drop" — on a fresh install nothing has written to these -// collections, so the drop must be tolerated or the index creation that follows -// it never runs and a brand-new deployment crash-loops at startup. func isIndexNotFound(err error) bool { var ce mongo.CommandError if errors.As(err, &ce) { @@ -47,8 +38,6 @@ func isIndexNotFound(err error) bool { return false } -// ListSecretGroups returns a summary of every group with its key count and -// most recent update time. func ListSecretGroups(orgID string) ([]models.GroupSummary, error) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() @@ -89,8 +78,6 @@ func ListSecretGroups(orgID string) ([]models.GroupSummary, error) { return groups, nil } -// GetSecretGroup returns the keys within a group, sorted by key name, without -// decrypted values. func GetSecretGroup(orgID, group string) ([]models.Secret, error) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() @@ -109,9 +96,6 @@ func GetSecretGroup(orgID, group string) ([]models.Secret, error) { return docs, nil } -// GetSecretGroupDecrypted returns a flat map of key → plaintext value for a -// group. Also used by the ESO read endpoint, which resolves its org from the -// per-org bearer token rather than from a session. func GetSecretGroupDecrypted(orgID, group string) (map[string]string, error) { docs, err := GetSecretGroup(orgID, group) if err != nil { @@ -128,7 +112,6 @@ func GetSecretGroupDecrypted(orgID, group string) (map[string]string, error) { return result, nil } -// RevealSecret returns the decrypted value of a single key. func RevealSecret(orgID, group, key string) (string, error) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() @@ -144,7 +127,6 @@ func RevealSecret(orgID, group, key string) (string, error) { return decryptString(doc.EncryptedValue) } -// UpsertSecrets encrypts and writes each key/value pair into the group. func UpsertSecrets(orgID, group string, values map[string]string) error { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() @@ -170,7 +152,7 @@ func UpsertSecrets(orgID, group string, values map[string]string) error { return nil } -// SortedKeys returns the map keys sorted — handy for stable audit messages. + func SortedKeys(m map[string]string) []string { keys := make([]string, 0, len(m)) for k := range m { @@ -180,7 +162,7 @@ func SortedKeys(m map[string]string) []string { return keys } -// DeleteSecret removes a single key from a group. + func DeleteSecret(orgID, group, key string) error { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() @@ -189,7 +171,7 @@ func DeleteSecret(orgID, group, key string) error { return err } -// DeleteSecretGroup removes an entire group and all its keys. + func DeleteSecretGroup(orgID, group string) error { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() diff --git a/server/internal/services/servers.go b/server/internal/services/servers.go index 39d0098..53f1094 100644 --- a/server/internal/services/servers.go +++ b/server/internal/services/servers.go @@ -54,7 +54,7 @@ func CreateServer(orgID string) (*models.Server, string, error) { return s, token, nil } -// GetServer looks up a server scoped to an org (handler/session use). + func GetServer(orgID, serverID string) (*models.Server, error) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() @@ -67,8 +67,8 @@ func GetServer(orgID, serverID string) (*models.Server, error) { return &s, nil } -// getServerByID looks up a server by its unique server_id with no org filter. -// For agent/internal use only (e.g. workflow runner resolving org from a run). + + func getServerByID(serverID string) (*models.Server, error) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() @@ -96,9 +96,9 @@ func GetServerByPreRegToken(token string) (*models.Server, error) { return &s, nil } -// OSTypeFromInfo derives a coarse os_type ("windows" or "linux") from the -// agent-reported os_info string, which is formatted " ". -// Anything that is not explicitly windows defaults to linux. + + + func OSTypeFromInfo(osInfo string) string { if strings.HasPrefix(strings.ToLower(osInfo), "windows") { return "windows" @@ -106,8 +106,8 @@ func OSTypeFromInfo(osInfo string) string { return "linux" } -// defaultConsoleFields returns the initial console configuration for a newly -// registered server based on its os_type. + + func defaultConsoleFields(osType string) (protocols []string, sshPort, rdpPort int) { if osType == "windows" { return []string{"rdp"}, 22, 3389 @@ -181,19 +181,19 @@ func ValidateAgentToken(serverID, agentToken string) (*models.Server, error) { if err != nil { return nil, fmt.Errorf("invalid agent token") } - // Defence in depth: every agent-path caller scopes its work by this OrgID, - // so a blank one would widen those queries instead of narrowing them. + + if s.OrgID == "" { return nil, fmt.Errorf("server %s has no org", serverID) } return &s, nil } -// BackfillConsoleConfig sets default console_protocols/ports for a server that -// predates the console feature (or was updated without re-registering). Servers -// register only once via a single-use pre_reg_token, so Register() never runs -// again to populate these fields — this runs on every sync as a cheap no-op -// once the fields are present. + + + + + func BackfillConsoleConfig(srv *models.Server) error { if srv == nil || len(srv.ConsoleProtocols) > 0 { return nil @@ -262,7 +262,7 @@ func DeleteServer(orgID, serverID string) error { if err != nil { return err } - // Also remove assignments + _, err = db.Col("assignments").DeleteMany(ctx, bson.M{"server_id": serverID, "org_id": orgID}) return err } @@ -288,29 +288,29 @@ func MarkOfflineServers() error { return err } - // No session here, so the sweep runs per-org and each org's threshold and - // alert config come from that org's own settings doc. Each org gets its own - // deadline so a slow org can't starve the ones after it, and a failure on - // one org is logged rather than aborting the whole sweep. + + + + for _, orgID := range orgIDs { if err := markOfflineForFilter(bson.M{"org_id": orgID}, orgID); err != nil { log.Printf("offline sweep failed for org %s: %v", orgID, err) } } - // Servers whose org_id matches no existing org (org deleted, or the doc - // predates the backfill) would otherwise never be swept, where the old - // global query caught them. Sweep them with the default threshold; there is - // no org settings doc to read, and no org to alert. + + + + if err := markOfflineForFilter(bson.M{"org_id": bson.M{"$nin": orgIDs}}, ""); err != nil { log.Printf("offline sweep failed for orphaned servers: %v", err) } return nil } -// markOfflineForFilter transitions active-but-stale servers matching scope to -// offline. orgID selects whose settings supply the threshold and alert config; -// empty means defaults with no alerting (orphaned servers). + + + func markOfflineForFilter(scope bson.M, orgID string) error { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() @@ -333,7 +333,7 @@ func markOfflineForFilter(scope bson.M, orgID string) error { filter[k] = v } - // Find servers about to transition to offline so we can alert on them. + cursor, err := db.Col("servers").Find(ctx, filter) if err != nil { return err diff --git a/server/internal/services/settings.go b/server/internal/services/settings.go index addd39f..fcaae37 100644 --- a/server/internal/services/settings.go +++ b/server/internal/services/settings.go @@ -34,9 +34,9 @@ var defaultSettings = models.Settings{ }, } -// EnsureSettingsIndexes creates the per-org uniqueness constraints on settings. -// Pre-multi-tenant deployments had a single global settings doc and no indexes; -// drop any legacy index if a live DB still carries one. + + + func EnsureSettingsIndexes() error { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() @@ -52,10 +52,10 @@ func EnsureSettingsIndexes() error { return err } - // Partial so the many settings docs with no ESO token set don't collide on - // a missing (or empty) field. Explicitly named so it does not share Mongo's - // default name with the legacy index dropped above, which would make every - // restart drop and rebuild the enforcing index. + + + + _, err := db.Col("settings").Indexes().CreateOne(ctx, mongo.IndexModel{ Keys: bson.D{{Key: "secrets.read_token_hash", Value: 1}}, Options: options.Index().SetUnique(true).SetName("settings_read_token_hash_unique"). @@ -89,8 +89,8 @@ func hashToken(token string) string { return hex.EncodeToString(sum[:]) } -// RotateSecretsReadToken generates a new ESO read token, stores its SHA-256 -// hash, and returns the plaintext token exactly once. + + func RotateSecretsReadToken(orgID string) (string, error) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() @@ -118,9 +118,9 @@ func RotateSecretsReadToken(orgID string) (string, error) { return token, nil } -// ResolveSecretsReadToken looks the presented token's hash up directly and -// returns the owning org. This is the ESO machine-to-machine path: the org is -// carried by the token itself, since there is no session to scope it. + + + func ResolveSecretsReadToken(token string) (string, bool) { if token == "" { return "", false @@ -167,8 +167,8 @@ func SaveSettings(orgID string, alerts models.AlertSettings, email models.EmailS return err } -// GetWorkflowLogRetentionDays returns the log retention in days: 30 when unset, -// 0 for keep-forever, or the configured value. + + func GetWorkflowLogRetentionDays(orgID string) (int, error) { s, err := GetSettings(orgID) if err != nil { @@ -241,7 +241,7 @@ func SendOfflineEmail(cfg models.EmailSettings, hostname, serverID, ipAddress st } } -// sendMailTLS dials with implicit TLS (port 465) instead of STARTTLS. + func sendMailTLS(addr, host string, auth smtp.Auth, from string, to []string, msg []byte) error { conn, err := tls.Dial("tcp", addr, &tls.Config{ServerName: host}) if err != nil { diff --git a/server/internal/services/stepio.go b/server/internal/services/stepio.go index e8f9ea9..6f2e50d 100644 --- a/server/internal/services/stepio.go +++ b/server/internal/services/stepio.go @@ -9,7 +9,7 @@ import ( const StepDocKind = "vantage.step/v1" -// StepDoc is the portable, id-free representation of a step. + type StepDoc struct { Kind string `json:"kind"` Name string `json:"name"` @@ -21,7 +21,7 @@ type StepDoc struct { SecretRefs []string `json:"secret_refs"` } -// ExportStepDoc builds a portable doc from a library step (ids/source stripped). + func ExportStepDoc(s models.WorkflowStep) StepDoc { return StepDoc{ Kind: StepDocKind, @@ -35,8 +35,8 @@ func ExportStepDoc(s models.WorkflowStep) StepDoc { } } -// ParseStepDoc validates a v1 doc and returns a normalized (id-free) step with -// declared_outputs recomputed from the script. + + func ParseStepDoc(b []byte) (models.WorkflowStep, error) { var d StepDoc if err := json.Unmarshal(b, &d); err != nil { @@ -65,7 +65,7 @@ func ParseStepDoc(b []byte) (models.WorkflowStep, error) { }, nil } -// ImportStepToLibrary parses a doc and persists it as a new user library step. + func ImportStepToLibrary(orgID string, b []byte) (*models.WorkflowStep, error) { s, err := ParseStepDoc(b) if err != nil { @@ -74,7 +74,7 @@ func ImportStepToLibrary(orgID string, b []byte) (*models.WorkflowStep, error) { return CreateStep(orgID, s) } -// ExportStep loads a library step and marshals it to a portable doc. + func ExportStep(orgID, stepID string) ([]byte, error) { ctx, cancel := wfCtx() defer cancel() diff --git a/server/internal/services/steplogs.go b/server/internal/services/steplogs.go index d6c4e6e..84b6714 100644 --- a/server/internal/services/steplogs.go +++ b/server/internal/services/steplogs.go @@ -14,7 +14,7 @@ import ( "go.mongodb.org/mongo-driver/v2/mongo" ) -// WorkflowLogDir returns the base directory for workflow step logs, creating it. + func WorkflowLogDir() string { dir := os.Getenv("VANTAGE_WORKFLOW_LOG_DIR") if dir == "" { @@ -24,19 +24,19 @@ func WorkflowLogDir() string { return dir } -// ServerRunLogPath is the per-server-run log file path. + func ServerRunLogPath(runID, serverID string) string { return filepath.Join(WorkflowLogDir(), runID, serverID+".log") } -// logTS is the UTC timestamp prefix stamped on every log line. Stored in UTC -// (RFC3339, millisecond precision); the UI renders it in the viewer's timezone. + + func logTS() string { return time.Now().UTC().Format("2006-01-02T15:04:05.000") + "Z" } -// AppendMarker writes a timestamped event line to the server-run log and returns -// the byte offset at which the write began (used as a step's log_offset). + + func AppendMarker(runID, serverID, text string) (int64, error) { path := ServerRunLogPath(runID, serverID) if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { @@ -47,19 +47,19 @@ func AppendMarker(runID, serverID, text string) (int64, error) { return 0, err } defer f.Close() - off, _ := f.Seek(0, 2) // current end = offset before write + off, _ := f.Seek(0, 2) if _, err := f.WriteString("[" + logTS() + "] " + text + "\n"); err != nil { return off, err } return off, nil } -// ---- streamed chunk writer, boundary-safe secret masking ---- + type stepLogWriter struct { mu sync.Mutex f *os.File - carry []byte // bytes of an as-yet-unterminated line + carry []byte secrets []string } @@ -70,7 +70,7 @@ type stepLogRegistry struct { var StepLogs = &stepLogRegistry{writers: make(map[string]*stepLogWriter)} -// Open opens (append) the server-run file for a step's streamed chunks. + func (r *stepLogRegistry) Open(commandID, path string, secrets []string) error { if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { return err @@ -92,10 +92,10 @@ func (r *stepLogRegistry) get(commandID string) *stepLogWriter { return r.writers[commandID] } -// Append buffers chunks into whole lines, then writes each complete line with a -// UTC timestamp prefix and secret masking applied. Buffering by line means a -// secret split across a chunk boundary is always masked (the whole line is -// assembled first) and every line carries its own timestamp. + + + + func (r *stepLogRegistry) Append(commandID string, data []byte) { w := r.get(commandID) if w == nil { @@ -115,7 +115,7 @@ func (r *stepLogRegistry) Append(commandID string, data []byte) { w.carry = append([]byte{}, buf...) } -// writeLine emits one masked, timestamped log line. Caller holds w.mu. + func (w *stepLogWriter) writeLine(line []byte) { masked := maskBytes(line, w.secrets) _, _ = w.f.WriteString("[" + logTS() + "] ") @@ -123,7 +123,7 @@ func (w *stepLogWriter) writeLine(line []byte) { _, _ = w.f.WriteString("\n") } -// Close flushes any trailing partial line and closes the file. + func (r *stepLogRegistry) Close(commandID string) { r.mu.Lock() w := r.writers[commandID] @@ -152,9 +152,9 @@ func maskBytes(b []byte, secrets []string) []byte { return []byte(s) } -// ---- retention sweeper ---- -// StartLogSweeper sweeps expired run-log dirs hourly (and once now). + + func StartLogSweeper() { go func() { sweepLogs() @@ -166,10 +166,10 @@ func StartLogSweeper() { }() } -// sweepLogs walks the run-log dirs on disk. Log dirs are keyed by run ID, not -// by org, and this runs with no session — so retention is resolved per run from -// the owning org of that run's doc, with the per-org values cached for the -// sweep. Runs whose doc is gone fall back to the default retention. + + + + func sweepLogs() { base := WorkflowLogDir() entries, err := os.ReadDir(base) @@ -188,14 +188,14 @@ func sweepLogs() { orgID, finishedAt, found, err := runRetentionInfo(runID) if err != nil { - // A transient lookup failure is not evidence the run is gone — - // purging at the default retention here would delete logs an org - // had set to keep longer, or forever. + + + log.Printf("log sweep: retention lookup failed for run %s: %v", runID, err) continue } if found && finishedAt == nil { - continue // still running / never finished — keep + continue } days, ok := cache[orgID] @@ -209,7 +209,7 @@ func sweepLogs() { cache[orgID] = days } if days <= 0 { - continue // keep forever + continue } cutoff := now.AddDate(0, 0, -days) @@ -219,7 +219,7 @@ func sweepLogs() { } continue } - // run doc gone: use dir mtime + if fi, e := os.Stat(dir); e == nil && fi.ModTime().Before(cutoff) { _ = os.RemoveAll(dir) } @@ -228,9 +228,9 @@ func sweepLogs() { const defaultRetentionDays = 30 -// runRetentionInfo returns the owning org and finish time of a run, and whether -// the run doc still exists. A non-nil error means the lookup itself failed and -// says nothing about whether the run doc exists. + + + func runRetentionInfo(runID string) (string, *time.Time, bool, error) { ctx, cancel := wfCtx() defer cancel() diff --git a/server/internal/services/stepresults.go b/server/internal/services/stepresults.go index f89b329..4b9dd11 100644 --- a/server/internal/services/stepresults.go +++ b/server/internal/services/stepresults.go @@ -11,12 +11,12 @@ type stepResultRegistry struct { pending map[string]chan *pb.StepResult } -// StepResults correlates agent StepResult replies back to the workflow runner -// goroutine that dispatched the matching RunStepCmd, keyed by command_id. + + var StepResults = &stepResultRegistry{pending: make(map[string]chan *pb.StepResult)} -// Await registers interest in a command's result BEFORE the command is -// dispatched, and returns a buffered channel that receives the single result. + + func (r *stepResultRegistry) Await(commandID string) <-chan *pb.StepResult { ch := make(chan *pb.StepResult, 1) r.mu.Lock() @@ -25,14 +25,14 @@ func (r *stepResultRegistry) Await(commandID string) <-chan *pb.StepResult { return ch } -// Cancel removes a pending waiter (call on timeout to avoid leaks). + func (r *stepResultRegistry) Cancel(commandID string) { r.mu.Lock() delete(r.pending, commandID) r.mu.Unlock() } -// Deliver routes an incoming StepResult to its waiter, if any. + func (r *stepResultRegistry) Deliver(res *pb.StepResult) { if res == nil { return diff --git a/server/internal/services/stepscan.go b/server/internal/services/stepscan.go index df0d281..9fb3828 100644 --- a/server/internal/services/stepscan.go +++ b/server/internal/services/stepscan.go @@ -5,12 +5,12 @@ import ( "strings" ) -// keyAssign matches an env-var assignment target: KEY= (captures KEY). + var keyAssign = regexp.MustCompile(`([A-Za-z_][A-Za-z0-9_]*)=`) -// DeriveOutputs scans a step script and returns the output keys it writes to -// $WORKFLOW_ENV. Best-effort: only lines that reference WORKFLOW_ENV are -// considered. Deduplicated, first-seen order preserved. + + + func DeriveOutputs(script string) []string { out := []string{} seen := map[string]bool{} @@ -20,7 +20,7 @@ func DeriveOutputs(script string) []string { } for _, m := range keyAssign.FindAllStringSubmatch(line, -1) { key := m[1] - // Skip the sentinel itself (e.g. "WORKFLOW_ENV=..." assignments). + if key == "WORKFLOW_ENV" || key == "env" { continue } @@ -36,7 +36,7 @@ func DeriveOutputs(script string) []string { var slugStrip = regexp.MustCompile(`[^a-z0-9]+`) -// Slugify converts a step name into a stable kebab-case slug. + func Slugify(name string) string { s := strings.ToLower(name) s = slugStrip.ReplaceAllString(s, "-") diff --git a/server/internal/services/sync.go b/server/internal/services/sync.go index d4bd523..9cc4dd3 100644 --- a/server/internal/services/sync.go +++ b/server/internal/services/sync.go @@ -13,8 +13,8 @@ func BuildAuthorizedKeys(serverID string) ([]string, error) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - // Agent path — no session, so the org comes from the server record itself - // and both follow-up queries are scoped to it. + + srv, err := getServerByID(serverID) if err != nil { return nil, err diff --git a/server/internal/services/users.go b/server/internal/services/users.go index e6728b4..f2f1b36 100644 --- a/server/internal/services/users.go +++ b/server/internal/services/users.go @@ -15,13 +15,13 @@ import ( "golang.org/x/crypto/bcrypt" ) -// ErrLastOwner is returned when an operation would leave an org with no owner, -// which would lock every remaining member out of org administration. + + var ErrLastOwner = errors.New("this is the organization's last owner — promote another member to owner first") -// CountUsers counts users across the whole instance. It answers "is this a -// brand new deployment", so it is deliberately unscoped; anything that asks -// about a single tenant must use CountOrgUsers. + + + func CountUsers() (int64, error) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() @@ -34,8 +34,8 @@ func CountOrgUsers(orgID string) (int64, error) { return db.Col("users").CountDocuments(ctx, bson.M{"org_id": orgID}) } -// countOtherOwners counts owner-role users in the org excluding exceptUserID, -// i.e. how many owners would remain if that user were removed or demoted. + + func countOtherOwners(orgID, exceptUserID string) (int64, error) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() @@ -142,7 +142,7 @@ func UpdateUserRole(orgID, userID, role string) error { if err != nil { return fmt.Errorf("user not found") } - // Demoting the final owner would leave nobody able to administer the org. + if target.Role == models.RoleOwner && role != models.RoleOwner { others, err := countOtherOwners(orgID, userID) if err != nil { diff --git a/server/internal/services/validate.go b/server/internal/services/validate.go index c2bf6c5..a3c83e3 100644 --- a/server/internal/services/validate.go +++ b/server/internal/services/validate.go @@ -6,7 +6,7 @@ import ( "github.com/mrhid6/vantage/server/internal/models" ) -// ValidateWorkflow checks each step ref sets exactly one of step_id / inline. + func ValidateWorkflow(w models.Workflow) error { for i, ref := range w.Steps { hasLib := ref.StepID != "" diff --git a/server/internal/services/workflow_runner.go b/server/internal/services/workflow_runner.go index 7ab91b0..0f9230d 100644 --- a/server/internal/services/workflow_runner.go +++ b/server/internal/services/workflow_runner.go @@ -17,8 +17,8 @@ import ( const stepDispatchGrace = 15 * time.Second -// TriggerWorkflow snapshots the workflow, creates a run doc, and starts a -// background goroutine per target server (parallel fan-out). Returns run_id. + + func TriggerWorkflow(orgID, workflowID, actor string) (string, error) { wf, err := GetWorkflow(orgID, workflowID) if err != nil { @@ -30,13 +30,13 @@ func TriggerWorkflow(orgID, workflowID, actor string) (string, error) { if len(wf.Steps) == 0 { return "", fmt.Errorf("workflow has no steps") } - // Re-check ownership at trigger time — targets may predate validation or a - // server may have been removed since the workflow was saved. + + if err := validateTargetServers(orgID, wf.TargetServerIDs); err != nil { return "", err } - // Reject a concurrent run of the same workflow. + ctx, cancel := wfCtx() running := db.Col("workflow_runs").FindOne(ctx, bson.M{"org_id": orgID, "workflow_id": workflowID, "status": "running"}) cancel() @@ -82,8 +82,8 @@ func TriggerWorkflow(orgID, workflowID, actor string) (string, error) { return run.RunID, nil } -// resolveSteps freezes each workflow step ref into a ResolvedStep by loading the -// library step and applying overrides. + + func resolveSteps(orgID string, wf *models.Workflow) ([]models.ResolvedStep, error) { ctx, cancel := wfCtx() defer cancel() @@ -133,7 +133,7 @@ func resolveSteps(orgID string, wf *models.Workflow) ([]models.ResolvedStep, err return out, nil } -// resolveInlineStep freezes an ad-hoc (inline) step ref into a ResolvedStep. + func resolveInlineStep(ref models.WorkflowStepRef) models.ResolvedStep { in := ref.Inline inputs := map[string]string{} @@ -162,7 +162,7 @@ func resolveInlineStep(ref models.WorkflowStepRef) models.ResolvedStep { } } -// executeRun fans out one goroutine per server run and waits for all to finish. + func executeRun(runID string) { run, err := getRunByID(runID) if err != nil { @@ -179,7 +179,7 @@ func executeRun(runID string) { <-done } - // Aggregate status. + final, _ := getRunByID(runID) status := "success" for _, sr := range final.ServerRuns { @@ -194,8 +194,8 @@ func executeRun(runID string) { bson.M{"$set": bson.M{"status": status, "finished_at": now}}) } -// runServer executes the resolved steps sequentially on one server, threading -// output env forward and applying per-step failure policy. + + func runServer(orgID, runID string, srvIdx int, steps []models.ResolvedStep, serverID string) { now := time.Now() setServerRun(runID, srvIdx, bson.M{"server_runs.$.status": "running", "server_runs.$.started_at": now}) @@ -223,14 +223,14 @@ func runServer(orgID, runID string, srvIdx int, steps []models.ResolvedStep, ser maxAttempts = step.MaxRetries + 1 } - // Merge secrets into command env (kept out of persisted logs). + secretVals := resolveSecrets(orgID, step.SecretRefs) for k, v := range secretVals { allSecrets[k] = v } - // Input values may template earlier step outputs and secrets, e.g. - // URL="http://example.com/$VersionNumber". Expand against runEnv (outputs - // threaded from prior steps) and this step's secrets before dispatch. + + + subst := map[string]string{} for k, v := range runEnv { subst[k] = v @@ -249,8 +249,8 @@ func runServer(orgID, runID string, srvIdx int, steps []models.ResolvedStep, ser cmdEnv[k] = v } - // Write the step marker to the server-run log and remember the offset so - // the UI can slice this step's output later. + + marker := fmt.Sprintf("===== step %d/%d: %s (%s) =====", step.Order+1, len(steps), step.Name, step.Interpreter) offset, _ := AppendMarker(runID, serverID, marker) logPath := ServerRunLogPath(runID, serverID) @@ -262,8 +262,8 @@ func runServer(orgID, runID string, srvIdx int, steps []models.ResolvedStep, ser if attempts > 1 { _, _ = AppendMarker(runID, serverID, fmt.Sprintf("retry %d/%d after failure", attempts-1, maxAttempts-1)) } - // Open a fresh writer per attempt; the agent's eof closes it, and the - // defensive Close below covers a missing result. + + _ = StepLogs.Open(commandID, logPath, secretsSlice) res = dispatchAndWait(serverID, commandID, &pb.RunStepCmd{ Interpreter: step.Interpreter, @@ -272,18 +272,18 @@ func runServer(orgID, runID string, srvIdx int, steps []models.ResolvedStep, ser TimeoutSeconds: 0, WorkspaceId: runID, }) - StepLogs.Close(commandID) // idempotent; no-op if eof already closed it + StepLogs.Close(commandID) if res != nil && res.ExitCode == 0 { break } } exit := 1 - outEnv := map[string]string{} // masked copy, safe to persist + outEnv := map[string]string{} if res != nil { exit = res.ExitCode for k, v := range res.OutputEnv { - runEnv[k] = v // real, unmasked value threads forward to later steps + runEnv[k] = v outEnv[k] = maskSecrets(v, allSecrets) } } else { @@ -304,7 +304,7 @@ func runServer(orgID, runID string, srvIdx int, steps []models.ResolvedStep, ser switch step.OnFailure { case "continue": _, _ = AppendMarker(runID, serverID, "on_failure=continue — proceeding to next step") - default: // "stop" or exhausted "retry" + default: serverFailed = true } if serverFailed { @@ -315,8 +315,8 @@ func runServer(orgID, runID string, srvIdx int, steps []models.ResolvedStep, ser } } - // Tell the agent to remove the run's working directory now that its steps are - // done (success or failure). Best-effort; the OS reclaims temp dirs anyway. + + DispatchCleanupWorkspace(serverID, runID) fin := time.Now() @@ -326,8 +326,8 @@ func runServer(orgID, runID string, srvIdx int, steps []models.ResolvedStep, ser } _, _ = AppendMarker(runID, serverID, fmt.Sprintf("run %s in %s — workspace removed", status, fin.Sub(now).Round(time.Millisecond))) - // Persist only a masked copy of runEnv; the real (unmasked) runEnv was already - // used above to build cmdEnv for each step and must never be written to the DB. + + maskedRunEnv := make(map[string]string, len(runEnv)) for k, v := range runEnv { maskedRunEnv[k] = maskSecrets(v, allSecrets) @@ -339,8 +339,8 @@ func runServer(orgID, runID string, srvIdx int, steps []models.ResolvedStep, ser }) } -// dispatchAndWait registers a waiter, dispatches the step, and blocks for the -// result or a timeout. + + func dispatchAndWait(serverID, commandID string, cmd *pb.RunStepCmd) *pb.StepResult { ch := StepResults.Await(commandID) if err := DispatchRunStep(serverID, commandID, cmd); err != nil { @@ -360,9 +360,9 @@ func dispatchAndWait(serverID, commandID string, cmd *pb.RunStepCmd) *pb.StepRes } } -// expandVars substitutes $VAR and ${VAR} references in an input value from the -// given lookup (prior step outputs and secrets). Unknown references expand to -// empty, matching shell behaviour; a literal "$" is written as "$$". + + + func expandVars(v string, lookup map[string]string) string { return os.Expand(v, func(name string) string { if name == "$" { @@ -375,7 +375,7 @@ func expandVars(v string, lookup map[string]string) string { func resolveSecrets(orgID string, refs []string) map[string]string { out := map[string]string{} for _, ref := range refs { - // ref format "group/KEY"; resolve via RevealSecret. + parts := strings.SplitN(ref, "/", 2) if len(parts) != 2 { continue @@ -397,7 +397,7 @@ func maskSecrets(s string, secrets map[string]string) string { return s } -// ---- run doc mutation helpers ---- + func setServerRun(runID string, srvIdx int, set bson.M) { ctx, cancel := wfCtx() @@ -407,7 +407,7 @@ func setServerRun(runID string, srvIdx int, set bson.M) { bson.M{"$set": set}) } -// serverIDAt returns the server_id at an index (positional operator needs a match). + func serverIDAt(runID string, srvIdx int) string { r, err := getRunByID(runID) if err != nil || srvIdx >= len(r.ServerRuns) { @@ -436,7 +436,7 @@ func finishStep(runID, serverID string, order int, status string, attempts, exit }) } -// secretValues returns just the values of a secret map, for masking log output. + func secretValues(m map[string]string) []string { out := make([]string, 0, len(m)) for _, v := range m { @@ -471,11 +471,11 @@ func updateStep(runID, serverID string, order int, set bson.M) { ) } -// ---- reads ---- -// getRunByID looks up a run by its unique run_id with no org filter. For -// agent/internal run-execution use only (executeRun/runServer, etc.), which -// don't have a session and instead resolve org from the run doc itself. + + + + func getRunByID(runID string) (*models.WorkflowRun, error) { ctx, cancel := wfCtx() defer cancel() @@ -487,7 +487,7 @@ func getRunByID(runID string) (*models.WorkflowRun, error) { return &r, err } -// GetRun looks up a run scoped to an org (handler/session use). + func GetRun(orgID, runID string) (*models.WorkflowRun, error) { ctx, cancel := wfCtx() defer cancel() diff --git a/server/internal/services/workflows.go b/server/internal/services/workflows.go index 01f7c8a..c6d7891 100644 --- a/server/internal/services/workflows.go +++ b/server/internal/services/workflows.go @@ -25,8 +25,8 @@ func EnsureWorkflowIndexes() error { }); err != nil { return err } - // The pre-multi-tenant index was on slug alone, so seeding defaults for a - // second org collided — drop it if a live DB still carries it. + + if err := db.Col("workflow_steps").Indexes().DropOne(ctx, "slug_1"); err != nil && !isIndexNotFound(err) { return err } @@ -48,7 +48,7 @@ func EnsureWorkflowIndexes() error { return err } -// ---- Steps ---- + func ListSteps(orgID string) ([]models.WorkflowStep, error) { ctx, cancel := wfCtx() @@ -66,8 +66,8 @@ func ListSteps(orgID string) ([]models.WorkflowStep, error) { return steps, nil } -// StepUsageCounts returns, per library step_id, the number of distinct -// workflows that reference it. Inline steps have no step_id and are ignored. + + func StepUsageCounts(orgID string) (map[string]int, error) { ctx, cancel := wfCtx() defer cancel() @@ -139,7 +139,7 @@ func DeleteStep(orgID, stepID string) error { if _, err := db.Col("workflow_steps").DeleteOne(ctx, bson.M{"step_id": stepID, "org_id": orgID}); err != nil { return err } - // Cascade: remove this step from every workflow that references it, re-sequencing orders. + cur, err := db.Col("workflows").Find(ctx, bson.M{"steps.step_id": stepID, "org_id": orgID}) if err != nil { return err @@ -179,7 +179,7 @@ func getStep(ctx context.Context, orgID, stepID string) (*models.WorkflowStep, e return &s, err } -// ---- Workflows ---- + func ListWorkflows(orgID string) ([]models.Workflow, error) { ctx, cancel := wfCtx() @@ -253,9 +253,9 @@ func UpdateWorkflow(orgID, id string, w models.Workflow) error { return err } -// validateTargetServers rejects any target server that does not belong to the -// org. The IDs are client-supplied and are later consumed by the runner's -// unscoped lookups, so ownership has to be proven at the write boundary. + + + func validateTargetServers(orgID string, serverIDs []string) error { for _, sid := range serverIDs { if _, err := GetServer(orgID, sid); err != nil { @@ -265,8 +265,8 @@ func validateTargetServers(orgID string, serverIDs []string) error { return nil } -// normalizeInlineSteps derives outputs for inline steps and strips fields that -// only belong to library steps. + + func normalizeInlineSteps(w *models.Workflow) { for i := range w.Steps { in := w.Steps[i].Inline diff --git a/site/app/layout.tsx b/site/app/layout.tsx index 3094dd5..cd86409 100644 --- a/site/app/layout.tsx +++ b/site/app/layout.tsx @@ -7,14 +7,14 @@ import { ThemeScript } from "@/components/ThemeScript"; export const metadata: Metadata = { metadataBase: new URL("https://vantage.hostxtra.co.uk"), title: { - default: "Vantage — one control plane for the whole fleet", - template: "%s — Vantage", + default: "Vantage one control plane for the whole fleet", + template: "%s Vantage", }, description: "Self-hosted fleet control: SSH key assignment, workflow execution, service monitoring, a secrets vault and a browser console, across every server you manage.", openGraph: { type: "website", siteName: "Vantage", - title: "Vantage — one control plane for the whole fleet", + title: "Vantage one control plane for the whole fleet", description: "Self-hosted fleet control: SSH keys, workflows, monitors, secrets and consoles, over one outbound agent connection.", }, icons: { icon: "/images/vantage_logo.svg" }, diff --git a/site/app/platform/page.tsx b/site/app/platform/page.tsx index 97d502b..298a9ba 100644 --- a/site/app/platform/page.tsx +++ b/site/app/platform/page.tsx @@ -29,8 +29,8 @@ export default function PlatformPage() {

Key state is polled on a 30-second interval, because 30 seconds is fine for access control and polling - is simple to reason about. Everything that should not wait — running a step, opening a console, applying - updates — is pushed down a bidirectional command stream the agent holds open. + is simple to reason about. Everything that should not wait running a step, opening a console, applying + updates is pushed down a bidirectional command stream the agent holds open.

@@ -76,19 +76,19 @@ export default function PlatformPage() { and renames it over the real one. A machine that loses power mid-write keeps the file it had.

-            // agent poll, simplified
+            
             {"\n"}
             desired := client.SyncKeys(serverID, token){"\n"}
             current := keys.ReadAuthorizedKeys(){"\n\n"}
             if !keys.StateChanged(current, desired) {"{"}
             {"\n    "}
-            // nothing to do
+            
             {"\n    "}
             return nil{"\n"}
             {"}"}
             {"\n\n"}
             keys.WriteAuthorizedKeys(desired){"\n"}
-            // write .tmp, os.Rename(), chmod 0600
+            
           
diff --git a/site/app/pricing/page.tsx b/site/app/pricing/page.tsx index a820d57..ebd4516 100644 --- a/site/app/pricing/page.tsx +++ b/site/app/pricing/page.tsx @@ -13,11 +13,11 @@ const COMPARISON: [string, string, string, string][] = [ ["SSH key assignment", "Yes", "Yes", "Yes"], ["Workflows and step library", "Yes", "Yes", "Yes"], ["Monitors", "3", "Unlimited", "Unlimited"], - ["Secrets vault", "—", "Yes", "Yes"], - ["Browser console", "—", "Yes", "Yes"], - ["OIDC single sign-on", "—", "Yes", "Yes"], + ["Secrets vault", "No", "Yes", "Yes"], + ["Browser console", "No", "Yes", "Yes"], + ["OIDC single sign-on", "No", "Yes", "Yes"], ["Audit history", "30 days", "Forever", "Forever"], - ["Runs on your hardware", "—", "—", "Yes"], + ["Runs on your hardware", "No", "No", "Yes"], ["Support", "Community", "Next business day", "Priority"], ]; diff --git a/site/components/Footer.tsx b/site/components/Footer.tsx index c4a6189..5c4bc45 100644 --- a/site/components/Footer.tsx +++ b/site/components/Footer.tsx @@ -5,7 +5,7 @@ export function Footer() { return (