This commit is contained in:
+17
-17
@@ -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"}}))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 "+
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, "-")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 != ""
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user