feat: Removed comments
Server Deploy / deploy (push) Failing after 1m59s

This commit is contained in:
2026-07-24 09:51:30 +01:00
parent 3b52bcbeb8
commit 1a6cf03c03
94 changed files with 772 additions and 937 deletions
+12 -16
View File
@@ -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)
+1 -1
View File
@@ -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")
+18 -18
View File
@@ -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)
+4 -4
View File
@@ -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)
}
+10 -10
View File
@@ -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
+3 -3
View File
@@ -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
}
}
+2 -2
View File
@@ -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) {}
+2 -2
View File
@@ -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)
+17 -17
View File
@@ -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)
+8 -8
View File
@@ -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()
+31 -32
View File
@@ -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 {
+10 -11
View File
@@ -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
}
-2
View File
@@ -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 " "))
+17 -17
View File
@@ -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"}}))
+11 -11
View File
@@ -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
+5 -10
View File
@@ -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"
+4 -4
View File
@@ -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 == "" {
+6 -9
View File
@@ -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})
}
+17 -17
View File
@@ -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) {
+7 -9
View File
@@ -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)
+7 -18
View File
@@ -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
-2
View File
@@ -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
+1 -8
View File
@@ -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"})
+8 -8
View File
@@ -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 <slug>.vantage.<tld>. 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 <slug>.<root>.<...>; apex is <root>.<...>
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()
+12 -16
View File
@@ -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)
+2 -2
View File
@@ -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"
}
+15 -15
View File
@@ -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)
+9 -11
View File
@@ -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,
}),
+4 -4
View File
@@ -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"`
+3 -3
View File
@@ -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"`
+1 -1
View File
@@ -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"`
+11 -11
View File
@@ -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"`
+3 -3
View File
@@ -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"`
+3 -3
View File
@@ -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"`
}
+4 -4
View File
@@ -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"`
}
+7 -7
View File
@@ -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"`
+3 -10
View File
@@ -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 {
+7 -7
View File
@@ -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",
+1 -1
View File
@@ -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 == "" {
+8 -8
View File
@@ -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})
}
+6 -7
View File
@@ -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(
`<tr>`+
@@ -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(),
+4 -4
View File
@@ -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 {
+16 -16
View File
@@ -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()
+3 -3
View File
@@ -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 {
+6 -6
View File
@@ -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 {
+22 -22
View File
@@ -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")
+2 -2
View File
@@ -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()
+1 -6
View File
@@ -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,
+3 -45
View File
@@ -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 "+
+16 -53
View File
@@ -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
+2 -2
View File
@@ -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()
+9 -20
View File
@@ -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 {
+3 -21
View File
@@ -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()
+28 -28
View File
@@ -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 "<GOOS> <GOARCH>".
// 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
+15 -15
View File
@@ -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 {
+6 -6
View File
@@ -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()
+31 -31
View File
@@ -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()
+6 -6
View File
@@ -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
+6 -6
View File
@@ -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, "-")
+2 -2
View File
@@ -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
+8 -8
View File
@@ -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 {
+1 -1
View File
@@ -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 != ""
+42 -42
View File
@@ -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()
+12 -12
View File
@@ -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
+3 -3
View File
@@ -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" },
+5 -5
View File
@@ -29,8 +29,8 @@ export default function PlatformPage() {
</p>
<p>
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.
</p>
</div>
</div>
@@ -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.
</p>
<pre className="code">
<i>// agent poll, simplified</i>
<i>
{"\n"}
desired := client.SyncKeys(serverID, token){"\n"}
current := keys.ReadAuthorizedKeys(){"\n\n"}
<b>if</b> !keys.StateChanged(current, desired) {"{"}
{"\n "}
<i>// nothing to do</i>
<i>
{"\n "}
<b>return</b> nil{"\n"}
{"}"}
{"\n\n"}
keys.WriteAuthorizedKeys(desired){"\n"}
<i>// write .tmp, os.Rename(), chmod 0600</i>
<i>
</pre>
</div>
</section>
+4 -4
View File
@@ -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"],
];
+1 -1
View File
@@ -5,7 +5,7 @@ export function Footer() {
return (
<footer className="foot">
<div className="rail foot__in">
<p>Vantage self-hosted fleet control for people who own their servers.</p>
<p>Vantage self-hosted fleet control for people who own their servers.</p>
{NAV_LINKS.map((link) => (
<Link key={link.href} href={link.href}>
{link.label}
+1 -1
View File
@@ -64,7 +64,7 @@ export function InstrumentPanel() {
if (effect === "revoked") setRevoked(true);
};
// Reduced motion gets the finished state immediately rather than no state.
if (reduced) {
setLines([FIRST_LINE, ...BEATS.map((b) => b.line)].slice(-MAX_LINES));
BEATS.forEach((b) => apply(b.effect));
+2 -2
View File
@@ -1,5 +1,5 @@
// Traced from web/public/images/vantage_logo.svg. The fill is currentColor so
// the mark follows the --logo token in both themes.
export function Logo({ className }: { className?: string }) {
return (
<svg viewBox="246 207 533 610" className={className} aria-hidden="true" focusable="false">
+1 -1
View File
@@ -11,7 +11,7 @@ export function Nav() {
const pathname = usePathname();
const [open, setOpen] = useState(false);
// A route change should never leave the drawer hanging open behind the new page.
useEffect(() => {
setOpen(false);
}, [pathname]);
+2 -2
View File
@@ -1,5 +1,5 @@
// Applies the stored theme before first paint. Without this the page renders in
// the OS theme for a frame and then snaps to the stored one.
const script = `
(function(){
try {
+2 -2
View File
@@ -26,8 +26,8 @@ export function ThemeToggle() {
setTheme(next);
}
// Until mounted the rendered label would disagree with the server output, so
// the button carries a neutral label on first paint.
const label = theme === null ? "Theme" : theme === "dark" ? "Light" : "Dark";
return (
+9 -9
View File
@@ -16,11 +16,11 @@ import (
"github.com/mrhid6/vantage/sitesvc/internal/store"
)
// sitesvc backs the public marketing site. It owns two jobs end to end:
// emailing the contact form, and provisioning an organisation once its owner
// has verified their email address. It shares MongoDB with the control plane —
// that is how the new tenant becomes visible to the app — but shares no code
// and no process with it.
func main() {
godotenv.Load()
@@ -33,10 +33,10 @@ func main() {
}
log.Printf("connected to MongoDB (database %q)", store.DatabaseName())
// The unique indexes on users.email and orgs.slug are a security property,
// not an optimisation, so a failure to build them is fatal rather than a
// warning: provisioning tenants without them risks duplicate accounts and
// ambiguous host-based org resolution.
if err := store.EnsureIndexes(); err != nil {
log.Fatalf("failed to ensure indexes: %v", err)
}
+21 -21
View File
@@ -15,14 +15,14 @@ import (
)
const (
maxBodyBytes = 32 << 10 // 32 KiB is far more than this form needs
maxBodyBytes = 32 << 10
perIPLimit = 5
perIPWindow = 10 * time.Minute
)
// Server backs the marketing site's two forms: contact, which is emailed and
// never stored, and signup, which provisions an organisation and its owner
// after the address has been verified.
type Server struct {
mail mail.Config
limiter *limiter
@@ -49,7 +49,7 @@ func (s *Server) Routes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("POST /api/contact", s.handleContact)
mux.HandleFunc("POST /api/signup", s.handleSignup)
// Opened from an email client, so it is a GET that renders a page.
mux.HandleFunc("GET /api/verify", s.handleVerify)
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
@@ -57,7 +57,7 @@ func (s *Server) Routes() http.Handler {
return s.withCORS(mux)
}
// ---------------------------------------------------------------- middleware
func parseOrigins(raw string) map[string]bool {
out := map[string]bool{}
@@ -69,10 +69,10 @@ func parseOrigins(raw string) map[string]bool {
return out
}
// withCORS reflects only origins named in SITE_ORIGIN. It never answers with a
// wildcard: this endpoint sends mail, and an unset SITE_ORIGIN should fail
// closed for cross-origin callers rather than open to every site on the
// internet.
func (s *Server) withCORS(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
@@ -91,9 +91,9 @@ func (s *Server) withCORS(next http.Handler) http.Handler {
})
}
// clientIP prefers the left-most X-Forwarded-For entry, but only when the
// service is explicitly told it sits behind a proxy. Trusting the header
// unconditionally would let any caller spoof its way past the rate limiter.
func (s *Server) clientIP(r *http.Request) string {
if s.trustProxy {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
@@ -110,7 +110,7 @@ func (s *Server) clientIP(r *http.Request) string {
return host
}
// ------------------------------------------------------------------- handler
type contactBody struct {
Name string `json:"name"`
@@ -118,7 +118,7 @@ type contactBody struct {
Servers string `json:"servers"`
Topic string `json:"topic"`
Message string `json:"message"`
Website string `json:"website"` // honeypot: real people leave this empty
Website string `json:"website"`
}
var (
@@ -137,8 +137,8 @@ func (s *Server) handleContact(w http.ResponseWriter, r *http.Request) {
return
}
// A filled honeypot is a bot. Answer exactly as we would on success so it
// learns nothing, and send nothing.
if strings.TrimSpace(body.Website) != "" {
writeJSON(w, http.StatusAccepted, map[string]string{"status": "received"})
return
@@ -201,9 +201,9 @@ func (s *Server) handleContact(w http.ResponseWriter, r *http.Request) {
return
}
// Nothing is stored, so the send has to succeed before we can tell someone
// their message arrived. This is the one place where a mail failure is the
// caller's problem.
if err := s.mail.Send(subject(addr, fields), plainBody(addr, fields), addr); err != nil {
log.Printf("contact send: %v", err)
writeJSON(w, http.StatusBadGateway, map[string]string{
@@ -233,7 +233,7 @@ func plainBody(addr string, fields map[string]string) string {
return b.String()
}
// ------------------------------------------------------------------- helpers
func decode(w http.ResponseWriter, r *http.Request, dst any) bool {
r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes)
+6 -6
View File
@@ -5,10 +5,10 @@ import (
"time"
)
// limiter is a fixed-window counter keyed by client IP. It exists to blunt
// automated submission floods, not to be a precise quota: the window resets
// wholesale, and state is per-process, so it is a speed bump rather than a
// guarantee. The per-email check in the handler backs it up.
type limiter struct {
mu sync.Mutex
hits map[string]*window
@@ -51,8 +51,8 @@ func (l *limiter) allow(key string) bool {
return true
}
// gc drops expired windows so a long-running process does not accumulate an
// entry for every IP that ever hit it. Caller must hold the lock.
func (l *limiter) gc(now time.Time) {
if now.Sub(l.lastGC) < l.window {
return
+15 -15
View File
@@ -25,20 +25,20 @@ type signupBody struct {
OrgName string `json:"org_name"`
Email string `json:"email"`
Password string `json:"password"`
Website string `json:"website"` // honeypot
Website string `json:"website"`
}
// handleSignup records an unverified signup and emails the confirmation link.
// Nothing is created in orgs or users until that link is opened, so an address
// nobody controls can never occupy an email or hold an organisation slug.
func (s *Server) handleSignup(w http.ResponseWriter, r *http.Request) {
var body signupBody
if !decode(w, r, &body) {
return
}
// A filled honeypot is a bot. Answer as we would on success so it learns
// nothing, and record nothing.
if strings.TrimSpace(body.Website) != "" {
writeJSON(w, http.StatusAccepted, map[string]string{"status": "check_email"})
return
@@ -114,9 +114,9 @@ func (s *Server) handleSignup(w http.ResponseWriter, r *http.Request) {
link := s.verifyURL(token)
if err := s.mail.SendVerification(addr, orgName, link, store.PendingTTL); err != nil {
// The pending record is useless without its email, and the address is
// not registered, so the caller must be told rather than left waiting
// for a message that will never arrive.
log.Printf("signup: send verification to %s: %v", addr, err)
writeJSON(w, http.StatusBadGateway, map[string]string{
"error": "We could not send the confirmation email. Check the address, or email support@hostxtra.co.uk.",
@@ -132,9 +132,9 @@ func (s *Server) verifyURL(token string) string {
return fmt.Sprintf("%s/api/verify?token=%s", base, url.QueryEscape(token))
}
// handleVerify consumes the token and provisions the organisation. It is opened
// from an email client, so it answers with a page rather than JSON, and
// redirects to the app's sign-in page on success when one is configured.
func (s *Server) handleVerify(w http.ResponseWriter, r *http.Request) {
token := r.URL.Query().Get("token")
if token == "" {
@@ -178,9 +178,9 @@ func (s *Server) handleVerify(w http.ResponseWriter, r *http.Request) {
fmt.Sprintf("%s is set up and you are its owner. You can sign in now.", org.Name))
}
// verifyPage renders a minimal self-contained page. Everything interpolated is
// escaped: the only dynamic value is an organisation name the visitor supplied
// themselves, but it still reaches a browser as HTML.
func (s *Server) verifyPage(w http.ResponseWriter, status int, heading, detail string) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("X-Content-Type-Options", "nosniff")
+8 -8
View File
@@ -7,8 +7,8 @@ import (
"unicode/utf8"
)
// Deliberately loose: the only thing worth rejecting here is something that
// cannot be an address at all. Anything stricter starts refusing valid mail.
var emailRe = regexp.MustCompile(`^[^@\s]+@[^@\s.]+\.[^@\s]+$`)
const (
@@ -23,9 +23,9 @@ type fieldError struct {
func (e fieldError) Error() string { return e.Field + ": " + e.Message }
// text trims, rejects empties when required, and caps length. The cap is on
// runes rather than bytes so a multi-byte message is not silently truncated
// mid-character.
func text(name, value string, required bool, max int) (string, *fieldError) {
v := strings.TrimSpace(value)
if v == "" {
@@ -55,9 +55,9 @@ func email(name, value string) (string, *fieldError) {
return v, nil
}
// oneOf constrains a value to a known set. Submitted values for dropdowns are
// as attacker-controlled as any other field, so they are checked rather than
// trusted and stored.
func oneOf(name, value string, allowed []string) (string, *fieldError) {
v := strings.TrimSpace(value)
for _, a := range allowed {
+24 -24
View File
@@ -15,8 +15,8 @@ import (
const timeout = 15 * time.Second
// Config is read once at boot. When Host is empty the service still accepts and
// stores submissions; it just does not email them.
type Config struct {
Host string
Port string
@@ -41,19 +41,19 @@ func (c Config) Enabled() bool {
return c.Host != "" && c.From != "" && c.To != ""
}
// Port 465 uses implicit TLS; any other port starts plain and upgrades with
// STARTTLS when the server advertises it. Dial and connection deadlines keep an
// unreachable host from hanging the caller until the OS TCP timeout.
// Send delivers a plain-text message. replyTo, when set, becomes the Reply-To
// header so hitting reply in a mail client answers the person who filled in the
// form rather than the service's own sending address. The envelope sender stays
// as From, so a submitted address can never affect SPF or DMARC alignment.
func (c Config) Send(subject, body, replyTo string) error {
return c.sendTo(c.To, subject, body, replyTo)
}
// sendTo delivers to an explicit recipient. Contact enquiries go to the support
// inbox (c.To); verification links go to the person signing up.
func (c Config) sendTo(to, subject, body, replyTo string) error {
if !c.Enabled() {
return fmt.Errorf("smtp: not configured")
@@ -127,9 +127,9 @@ func recipients(to string) []string {
return out
}
// message builds the MIME body. The subject is encoded rather than interpolated
// raw, and headers are stripped of CR/LF so submitted content cannot inject
// extra headers.
func message(from, to, subject, body, replyTo string) []byte {
var b strings.Builder
b.WriteString("From: " + sanitizeHeader(from) + "\r\n")
@@ -137,10 +137,10 @@ func message(from, to, subject, body, replyTo string) []byte {
if replyTo != "" {
b.WriteString("Reply-To: " + sanitizeHeader(replyTo) + "\r\n")
}
// Date and Message-ID are RFC 5322 essentials. Without them many servers
// accept the message at SMTP time and then silently junk or drop it, and
// SpamAssassin scores MISSING_DATE and MISSING_MID heavily — the message
// "sends" but never lands in the inbox.
b.WriteString("Date: " + time.Now().Format(time.RFC1123Z) + "\r\n")
b.WriteString("Message-ID: " + messageID(from) + "\r\n")
b.WriteString("Subject: " + mime.QEncoding.Encode("utf-8", sanitizeHeader(subject)) + "\r\n")
@@ -151,9 +151,9 @@ func message(from, to, subject, body, replyTo string) []byte {
return []byte(b.String())
}
// messageID builds a unique <id@domain>, taking the domain from the From
// address so the identifier matches the sending domain. Falls back to the host
// name when From has no domain part.
func messageID(from string) string {
domain := "vantage.local"
if at := strings.LastIndex(from, "@"); at >= 0 && at < len(from)-1 {
@@ -170,9 +170,9 @@ func sanitizeHeader(v string) string {
return strings.NewReplacer("\r", " ", "\n", " ").Replace(v)
}
// SendVerification emails the one-time link that completes a signup. The
// address is the person signing up, not the support inbox, so To is overridden
// for this one message.
func (c Config) SendVerification(to, orgName, link string, ttl time.Duration) error {
body := fmt.Sprintf(`Confirm your email to finish creating %s on Vantage.
+7 -7
View File
@@ -35,13 +35,13 @@ type User struct {
LastLogin *time.Time `bson:"last_login,omitempty"`
}
// PendingSignup is sitesvc's own record, in its own collection. It holds a
// signup between the form being submitted and the email link being clicked.
//
// Nothing is written to orgs or users until verification succeeds, so an
// unverified address can never occupy an email, hold a slug, or sign in. The
// password is bcrypt-hashed here exactly as it would be in users, so the
// plaintext never rests anywhere.
type PendingSignup struct {
ID bson.ObjectID `bson:"_id,omitempty"`
PendingID string `bson:"pending_id"`
+6 -6
View File
@@ -23,13 +23,13 @@ does not agree with.
const (
MinSlugLength = 3
MaxSlugLength = 40
BcryptCost = 12 // matches services.CreateUser
BcryptCost = 12
)
var slugStrip = regexp.MustCompile(`[^a-z0-9]+`)
// ReservedSlugs are names that would collide with a route or a host label.
// Mirrored from services.reservedSlugs.
var ReservedSlugs = map[string]bool{
"www": true, "api": true, "app": true, "admin": true, "auth": true,
"install": true, "static": true, "_next": true, "default": true,
@@ -41,8 +41,8 @@ func Slugify(name string) string {
return strings.Trim(s, "-")
}
// BaseSlug derives and validates the slug for an organisation name, returning
// the same errors the control plane's CreateOrg would.
func BaseSlug(name string) (string, error) {
base := Slugify(name)
if len(base) < MinSlugLength {
@@ -57,7 +57,7 @@ func BaseSlug(name string) (string, error) {
return base, nil
}
// NextSlug is the collision suffix scheme: base, base-2, base-3, ...
func NextSlug(base string, attempt int) string {
if attempt < 2 {
return base
+30 -30
View File
@@ -21,7 +21,7 @@ import (
"golang.org/x/crypto/bcrypt"
)
// PendingTTL is how long a verification link stays valid.
const PendingTTL = 24 * time.Hour
var (
@@ -32,15 +32,15 @@ var (
var database *mongo.Database
// Connect dials MongoDB and selects the database named in the connection
// string, e.g. mongodb://host:27017/vantage. The name is parsed with the
// driver's own connection-string parser rather than by hand, so seed lists,
// mongodb+srv, percent-escaping and auth options all behave as the driver
// expects.
//
// A URI with no database is a configuration error worth failing on: defaulting
// would silently provision tenants into the wrong database, where the control
// plane would never see them.
func Connect(uri string) error {
cs, err := connstring.ParseAndValidate(uri)
if err != nil {
@@ -64,7 +64,7 @@ func Connect(uri string) error {
return nil
}
// DatabaseName reports the database in use, for startup logging.
func DatabaseName() string {
if database == nil {
return ""
@@ -98,8 +98,8 @@ func EnsureIndexes() error {
Options: options.Index().SetUnique(true),
},
{Keys: bson.D{{Key: "email", Value: 1}}},
// Mongo removes expired pending signups on its own, so an abandoned
// signup does not keep a password hash around indefinitely.
{
Keys: bson.D{{Key: "expires_at", Value: 1}},
Options: options.Index().SetExpireAfterSeconds(0),
@@ -139,8 +139,8 @@ func CreatePending(ctx context.Context, orgName, email, password string) (string
return "", err
}
// A second attempt for the same address replaces the first, so the newest
// email is the one that works and old links stop functioning.
if _, err := col("site_pending_signups").DeleteMany(ctx, bson.M{"email": email}); err != nil {
return "", err
}
@@ -161,11 +161,11 @@ func CreatePending(ctx context.Context, orgName, email, password string) (string
return raw, nil
}
// Verify consumes a token and provisions the organisation and its owner.
//
// The pending record is deleted first and atomically, so a token can only ever
// be spent once even if the link is clicked twice at the same moment: the
// second delete matches nothing and stops here.
func Verify(ctx context.Context, rawToken string) (*models.Org, error) {
var pending models.PendingSignup
err := col("site_pending_signups").FindOneAndDelete(ctx, bson.M{
@@ -194,10 +194,10 @@ func Verify(ctx context.Context, rawToken string) (*models.Org, error) {
CreatedAt: time.Now().UTC(),
}
if _, err := col("users").InsertOne(ctx, user); err != nil {
// An org with no owner is unreachable and holds a slug nobody can
// reuse, so take it back out. Losing the pending record here is
// acceptable: the address is already registered, which is what the
// duplicate error means.
if rbErr := rollbackOrg(ctx, org.OrgID); rbErr != nil {
log.Printf("verify: failed to roll back org %s: %v", org.OrgID, rbErr)
}
@@ -210,8 +210,8 @@ func Verify(ctx context.Context, rawToken string) (*models.Org, error) {
return org, nil
}
// createOrg mirrors services.CreateOrg: derive the slug, resolve collisions by
// suffixing, and let the unique index settle any race.
func createOrg(ctx context.Context, name string) (*models.Org, error) {
base, err := provision.BaseSlug(name)
if err != nil {
@@ -236,8 +236,8 @@ func createOrg(ctx context.Context, name string) (*models.Org, error) {
CreatedAt: time.Now().UTC(),
}
if _, err := col("orgs").InsertOne(ctx, org); err != nil {
// Another signup took this slug between the count and the insert.
// Try the next suffix rather than failing the whole signup.
if mongo.IsDuplicateKeyError(err) {
continue
}
@@ -248,8 +248,8 @@ func createOrg(ctx context.Context, name string) (*models.Org, error) {
return nil, fmt.Errorf("%w: could not find a free slug for %q", ErrNameRejected, name)
}
// rollbackOrg removes an org that never got an owner. It refuses to touch one
// that has users, so a mistaken call can never delete a live tenant.
func rollbackOrg(ctx context.Context, orgID string) error {
n, err := col("users").CountDocuments(ctx, bson.M{"org_id": orgID})
if err != nil {
+1 -1
View File
@@ -11,7 +11,7 @@ import { Table, Thead, Tbody, Tr, Th, Td } from "@/components/ui";
const inputClass =
"w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder-text-secondary/50 focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent";
// Name of the ClusterSecretStore the generated manifests reference.
const STORE_NAME = "vantage-store";
function CopyBlock({ label, yaml }: { label: string; yaml: string }) {
+13 -13
View File
@@ -34,7 +34,7 @@ export default function ServerConsolePage() {
const [zoom, setZoom] = useState(1);
const dprRef = useRef(1);
// Inject the vendored Guacamole client script once.
useEffect(() => {
const s = document.createElement("script");
s.src = "/lib/guacamole-common.js";
@@ -45,7 +45,7 @@ export default function ServerConsolePage() {
};
}, []);
// Disconnect on unmount.
useEffect(() => {
return () => {
connectionRef.current?.disconnect();
@@ -90,8 +90,8 @@ export default function ServerConsolePage() {
}
const { token, ws_path } = await api.connectConsole(body);
// Defer the actual openConsole until after the form is unmounted so the
// container measures at full height (see effect below).
setPending({ token, wsPath: ws_path });
setConnected(true);
} catch (e) {
@@ -101,8 +101,8 @@ export default function ServerConsolePage() {
}
}
// Runs after `connected` flips and the connection form is gone, so the
// container now occupies its full flex height.
useEffect(() => {
if (!connected || !pending || !containerRef.current) return;
@@ -111,9 +111,9 @@ export default function ServerConsolePage() {
const rect = containerRef.current.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
dprRef.current = dpr;
// Request the remote at device-pixel resolution with a fixed 96 dpi, then
// scale the display back down by dpr. Folding dpr into `dpi` instead makes
// the remote enlarge everything, which reads as a zoomed-in view.
const connectData =
`token=${encodeURIComponent(pending.token)}` +
`&width=${Math.floor(rect.width * dpr)}` +
@@ -125,10 +125,10 @@ export default function ServerConsolePage() {
setPending(null);
}, [connected, pending]);
// Apply zoom live without reconnecting: resize the remote to a resolution
// that, once scaled to fit the container, yields the requested zoom. Higher
// zoom = fewer remote pixels rendered larger. Display always fits the
// container exactly, so no scrollbars appear.
useEffect(() => {
if (!connectionRef.current || !containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
@@ -10,7 +10,7 @@ const inputClass =
"w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder:text-text-tertiary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30";
const labelClass = "mb-1.5 block text-sm font-medium text-text-secondary";
// Config fields required per channel type.
const CONFIG_FIELDS: Record<ChannelType, string[]> = {
webhook: ["url"],
slack: ["url"],
+6 -6
View File
@@ -53,8 +53,8 @@ function MembersCard() {
const { mutate: changeRole, error: roleError } = useMutation({
mutationFn: ({ userId, next }: { userId: string; next: Role }) => api.updateOrgUserRole(userId, next),
onSuccess: invalidate,
// A rejected change (last owner, owner-only grant) leaves the select showing
// the value the server refused — refetch so the row snaps back to the truth.
onError: invalidate,
});
@@ -65,8 +65,8 @@ function MembersCard() {
const actionError = (roleError ?? removeError) as Error | null;
// The server lets only an owner grant or change the owner role. Mirror that
// here so admins aren't offered controls that can only 403.
const isOwner = user?.role === "owner";
const assignableRoles = isOwner ? ROLES : ROLES.filter((r) => r !== "owner");
@@ -112,7 +112,7 @@ function MembersCard() {
<Tbody>
{users.map((u: OrgUser) => {
const isSelf = u.user_id === user?.user_id;
// Own row stays read-only, and only owners may act on owners.
const locked = isSelf || (u.role === "owner" && !isOwner);
return (
<Tr key={u.user_id}>
@@ -242,7 +242,7 @@ function OIDCCard() {
setIssuer(cfg.issuer ?? "");
setClientId(cfg.client_id ?? "");
setEnabled(cfg.enabled);
// The secret is never returned; leave the field blank to mean "unchanged".
setClientSecret("");
}, [cfg]);
+3 -3
View File
@@ -139,7 +139,7 @@ export default function SettingsPage() {
const queryClient = useQueryClient();
const { isAdmin } = useAuth();
// /api/settings requires owner|admin and 403s for members, so don't even ask.
const { data: settings, isLoading } = useQuery({
queryKey: ["settings"],
queryFn: api.getSettings,
@@ -168,8 +168,8 @@ export default function SettingsPage() {
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!settings) return;
// Preserve legacy alert/email values (managed via Notification Channels now);
// only the offline threshold and log retention are edited here.
save({
alerts: { ...settings.alerts, offline_threshold_minutes: thresholdMinutes },
email: settings.email,
+26 -26
View File
@@ -23,9 +23,9 @@ function AdhocBadge() {
return <span className="rounded px-1.5 py-0.5 font-mono text-[10px] uppercase bg-signal/15 text-signal">ad-hoc</span>;
}
// Stable snapshot of only the fields the editor controls. Excludes volatile
// server-echo fields (e.g. updated_at) that would otherwise change on every
// save and cause autosave to loop forever.
function snapshotOf(w: Workflow): string {
return JSON.stringify({
name: w.name,
@@ -81,11 +81,11 @@ export default function WorkflowBuilder() {
setWf(loaded);
savedSnapshotRef.current = snapshotOf(loaded);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [loaded]);
// Lazily fetch the keys for every secret group so the inspector's
// secret-ref checklist can offer "group/KEY" options.
useEffect(() => {
if (!secretGroups) return;
secretGroups.forEach((g: SecretGroupSummary) => {
@@ -101,17 +101,17 @@ export default function WorkflowBuilder() {
setGroupKeys((prev) => ({ ...prev, [g.group]: [] }));
});
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [secretGroups]);
// Keep a ref to the latest workflow so an in-flight save can tell whether
// the user edited again while the request was on the wire.
wfRef.current = wf;
// Autosave: debounce 800ms after any change to the workflow (step added,
// removed, reordered, or edited) and persist. Diffing the serialized state
// against the last saved snapshot skips no-op saves and the initial load.
// Must stay above the early return below so hook order is stable.
useEffect(() => {
if (!wf || savedSnapshotRef.current === null) return;
if (snapshotOf(wf) === savedSnapshotRef.current) return;
@@ -119,10 +119,10 @@ export default function WorkflowBuilder() {
save();
}, 800);
return () => clearTimeout(t);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [wf]);
// Re-render every 15s so the "Saved … ago" label stays current.
useEffect(() => {
if (!lastSaved) return;
const iv = setInterval(() => setTick((n) => n + 1), 15000);
@@ -141,8 +141,8 @@ export default function WorkflowBuilder() {
const selectedIdxInWf = selectedRef ? wf.steps.indexOf(selectedRef) : -1;
const save = async () => {
// Never run two saves concurrently: a request in flight would race the
// next one. The finally block re-triggers if edits landed meanwhile.
if (savingRef.current) return;
const current = wfRef.current;
if (!current) return;
@@ -158,14 +158,14 @@ export default function WorkflowBuilder() {
return;
}
if (wfRef.current && snapshotOf(wfRef.current) === snapshot) {
// Nothing changed while the request was in flight: adopt the
// server echo as the new saved baseline.
savedSnapshotRef.current = snapshotOf(updated);
setWf(updated);
} else {
// The user edited again mid-flight. Keep their newer state and
// mark only the SENT snapshot as saved, so the effect re-fires
// and persists the remaining changes.
savedSnapshotRef.current = snapshot;
}
setLastSaved(new Date());
@@ -174,8 +174,8 @@ export default function WorkflowBuilder() {
} finally {
savingRef.current = false;
setSaving(false);
// If edits arrived during the save (or a concurrent save was
// skipped), persist them on the next tick.
if (wfRef.current && snapshotOf(wfRef.current) !== savedSnapshotRef.current) {
setTimeout(() => save(), 0);
}
@@ -581,8 +581,8 @@ export default function WorkflowBuilder() {
open={editWorkflowOpen}
workflow={wf}
onSaved={(w) => {
// The modal already persisted w; sync the snapshot so
// autosave doesn't fire a redundant follow-up save.
savedSnapshotRef.current = snapshotOf(w);
setWf(w);
}}
@@ -6,7 +6,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import { api, ServerRun, StepRun, WorkflowRun } from "@/lib/api";
import { Button } from "@/components/ui";
// ---- status vocabulary ----------------------------------------------------
type CellKind = "done" | "fail" | "run" | "wait" | "skip" | "warn";
@@ -23,7 +23,7 @@ function cellKind(status: string): CellKind {
case "cancelled":
return "warn";
default:
return "wait"; // queued / pending / missing
return "wait";
}
}
@@ -45,7 +45,7 @@ const cellClass: Record<CellKind, string> = {
warn: "bg-warning/15 text-warning",
};
// ---- run-level status pill ------------------------------------------------
type PillKind = "running" | "success" | "failed" | "neutral";
@@ -84,7 +84,7 @@ function StatusPill({ status, small }: { status: string; small?: boolean }) {
);
}
// ---- time helpers ---------------------------------------------------------
function fmtDuration(ms: number): string {
if (ms < 0) ms = 0;
@@ -104,7 +104,7 @@ function stepDuration(st: StepRun, running: boolean, now: number): string {
return fmtDuration(end - start);
}
// ---- live log terminal ----------------------------------------------------
function LogTerminal({ runId, server }: { runId: string; server: ServerRun }) {
const [text, setText] = useState("");
@@ -155,9 +155,9 @@ function LogTerminal({ runId, server }: { runId: string; server: ServerRun }) {
);
}
// LogLines renders the raw server-run log, parsing each line's leading UTC
// timestamp ([2026-07-20T12:04:02.000Z]) and rendering it in the viewer's local
// timezone. Event markers (===== …) are highlighted so the run's shape scans.
const TS_RE = /^\[(\d{4}-\d{2}-\d{2}T[\d:.]+Z)\]\s?(.*)$/;
function LogLines({ text }: { text: string }) {
@@ -189,7 +189,7 @@ function LogLines({ text }: { text: string }) {
);
}
// ---- step list ------------------------------------------------------------
function StepList({ server, now }: { server: ServerRun; now: number }) {
const running = server.status === "running";
@@ -228,7 +228,7 @@ function StepList({ server, now }: { server: ServerRun; now: number }) {
);
}
// ---- execution matrix (signature) -----------------------------------------
interface Column {
order: number;
@@ -295,7 +295,7 @@ function ExecutionMatrix({ run, columns, selected, onSelect }: { run: WorkflowRu
);
}
// ---- page -----------------------------------------------------------------
function SectionLabel({ children }: { children: React.ReactNode }) {
return (
@@ -320,7 +320,7 @@ export default function RunDetail() {
const running = run?.status === "running";
// tick the elapsed clock while running
useEffect(() => {
if (!running) return;
const t = setInterval(() => setNow(Date.now()), 1000);
@@ -329,7 +329,7 @@ export default function RunDetail() {
const columns = useMemo(() => (run ? buildColumns(run) : []), [run]);
// default selection: first running server, else first server
const selectedServer = useMemo(() => {
if (!run || run.server_runs.length === 0) return null;
if (selected) {
+5 -5
View File
@@ -9,9 +9,9 @@ export default function LoginPage() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
// If the org has no users yet, first-run setup is the only way in. And if the
// visitor already has a valid session on this host, the form is a dead end —
// send them into the app instead.
useEffect(() => {
(async () => {
try {
@@ -21,13 +21,13 @@ export default function LoginPage() {
return;
}
} catch {
// Status unavailable — fall through and let the login form stand.
}
try {
await auth.me();
window.location.href = "/";
} catch {
// Not signed in (or session invalid here) — show the form.
}
})();
}, []);
+4 -4
View File
@@ -30,7 +30,7 @@ function orgLoginUrlForSlug(slug: string): string {
if (rest[0] !== "vantage") return "/login";
const newHost = [slug, ...rest].join(".") + (port ? `:${port}` : "");
return `${protocol}//${newHost}/login`;
return `${protocol}
}
export default function SetupPage() {
@@ -41,7 +41,7 @@ export default function SetupPage() {
const [validationError, setValidationError] = useState<string | null>(null);
const [created, setCreated] = useState<{ slug: string; loginUrl: string } | null>(null);
// Setup is a one-shot route; once an owner exists it must not be reachable.
useEffect(() => {
auth
.bootstrapStatus()
@@ -49,7 +49,7 @@ export default function SetupPage() {
if (!s.needs_setup) window.location.href = "/login";
})
.catch(() => {
// Status unavailable — let the form stand; the backend re-checks on submit.
});
}, []);
@@ -74,7 +74,7 @@ export default function SetupPage() {
bootstrap();
}
// Prefer the backend's message (it owns the real validation rules).
const message = validationError ?? (error ? (error as Error).message : null);
const inputClass =
+4 -4
View File
@@ -51,10 +51,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
window.location.href = "/login";
return;
}
// Anything else (backend unreachable, org host mismatch) leaves us with
// no session. Rendering children here would mount the whole shell with
// user=null — every page would fire its own doomed API calls and the UI
// would read as a member view. Show the failure instead.
setError((err as Error).message || "Unable to load your session.");
setLoading(false);
}
+3 -3
View File
@@ -105,7 +105,7 @@ export function Sidebar() {
const visibleItems = navItems.filter((item) => !item.adminOnly || isAdmin);
// Longest match wins, so /settings/org doesn't also light up /settings.
const activeHref = visibleItems.reduce<string | null>((best, item) => {
const matches = pathname === item.href || pathname.startsWith(item.href + "/");
if (!matches) return best;
@@ -113,11 +113,11 @@ export function Sidebar() {
}, null);
async function handleLogout() {
// /auth/logout is POST-only on the server.
try {
await auth.logout();
} catch {
// Fall through — clearing the client-side session view is what matters.
}
window.location.href = "/login";
}
+2 -2
View File
@@ -17,8 +17,8 @@ export function EditStepModal({ open, step, onClose }: { open: boolean; step: Wo
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
// NOTE: because state is seeded from props, render the modal conditionally
// (parent mounts it only when opening) OR key it by step_id so it re-seeds.
const save = async () => {
setBusy(true); setError(null);