From c27faa02fa25c91c9e7fd84e02724b6bec040705 Mon Sep 17 00:00:00 2001 From: anonpenguin23 Date: Sat, 28 Mar 2026 08:59:11 +0200 Subject: [PATCH] feat(auth): integrate rootwallet agent and update service hardening - Replace CLI-based rootwallet calls with agent-based communication - Update production provisioner to support sudo-based service management - Add API key-to-wallet resolution for gateway operator handlers --- core/pkg/auth/rootwallet.go | 52 ++++++++-------- core/pkg/cli/build/builder.go | 4 +- core/pkg/cli/noderesolver/resolver.go | 7 ++- .../environments/production/provisioner.go | 61 +++++++++++-------- core/pkg/environments/production/services.go | 14 ++++- core/pkg/gateway/handlers/operator/handler.go | 61 +++++++++++++++++-- .../gateway/handlers/operator/handler_test.go | 42 ++++++++++++- core/pkg/gateway/handlers/operator/invite.go | 2 +- core/pkg/gateway/handlers/operator/nodes.go | 2 +- .../pkg/gateway/handlers/operator/register.go | 2 +- core/pkg/namespace/cluster_manager.go | 16 ++++- core/pkg/rqlite/client.go | 10 ++- core/pkg/rqlite/transaction.go | 17 +++++- core/pkg/rwagent/client.go | 15 +++++ core/pkg/rwagent/types.go | 5 ++ core/pkg/systemd/manager.go | 27 +++++--- 16 files changed, 260 insertions(+), 77 deletions(-) diff --git a/core/pkg/auth/rootwallet.go b/core/pkg/auth/rootwallet.go index a141816..78ebb9b 100644 --- a/core/pkg/auth/rootwallet.go +++ b/core/pkg/auth/rootwallet.go @@ -3,54 +3,58 @@ package auth import ( "bufio" "bytes" + "context" "encoding/json" "fmt" "io" "net/http" "os" - "os/exec" "strings" "time" + "github.com/DeBrosOfficial/network/pkg/rwagent" "github.com/DeBrosOfficial/network/pkg/tlsutil" ) -// IsRootWalletInstalled checks if the `rw` CLI is available in PATH +// IsRootWalletInstalled checks if the rootwallet agent is reachable. func IsRootWalletInstalled() bool { - _, err := exec.LookPath("rw") - return err == nil + client := rwagent.New(os.Getenv("RW_AGENT_SOCK")) + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + return client.IsRunning(ctx) } -// getRootWalletAddress gets the EVM address from the RootWallet keystore +// getRootWalletAddress gets the EVM address from the rootwallet agent. func getRootWalletAddress() (string, error) { - cmd := exec.Command("rw", "address", "--chain", "evm") - cmd.Stderr = os.Stderr - out, err := cmd.Output() + client := rwagent.New(os.Getenv("RW_AGENT_SOCK")) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + data, err := client.GetAddress(ctx, "evm") if err != nil { - return "", fmt.Errorf("failed to get address from rw: %w", err) + return "", fmt.Errorf("failed to get address from rootwallet agent: %w", err) } - addr := strings.TrimSpace(string(out)) - if addr == "" { - return "", fmt.Errorf("rw returned empty address — run 'rw init' first") + if data.Address == "" { + return "", fmt.Errorf("rootwallet agent returned empty address") } - return addr, nil + return data.Address, nil } -// signWithRootWallet signs a message using RootWallet's EVM key. -// Stdin is passed through so the user can enter their password if the session is expired. +// signWithRootWallet signs a message using the rootwallet agent's EVM key. +// The desktop app may prompt the user for approval. func signWithRootWallet(message string) (string, error) { - cmd := exec.Command("rw", "sign", message, "--chain", "evm") - cmd.Stdin = os.Stdin - cmd.Stderr = os.Stderr - out, err := cmd.Output() + client := rwagent.New(os.Getenv("RW_AGENT_SOCK")) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + data, err := client.Sign(ctx, message, "evm") if err != nil { - return "", fmt.Errorf("failed to sign with rw: %w", err) + return "", fmt.Errorf("failed to sign with rootwallet agent: %w", err) } - sig := strings.TrimSpace(string(out)) - if sig == "" { - return "", fmt.Errorf("rw returned empty signature") + if data.Signature == "" { + return "", fmt.Errorf("rootwallet agent returned empty signature") } - return sig, nil + return data.Signature, nil } // PerformRootWalletAuthentication performs a challenge-response authentication flow diff --git a/core/pkg/cli/build/builder.go b/core/pkg/cli/build/builder.go index 2c306d4..2e61dcf 100644 --- a/core/pkg/cli/build/builder.go +++ b/core/pkg/cli/build/builder.go @@ -197,8 +197,8 @@ func (b *Builder) buildVaultGuardian() error { return fmt.Errorf("zig not found in PATH — install from https://ziglang.org/download/") } - // Vault source is sibling to orama project - vaultDir := filepath.Join(b.projectDir, "..", "orama-vault") + // Vault source is sibling to core/ within the orama monorepo + vaultDir := filepath.Join(b.projectDir, "..", "vault") if _, err := os.Stat(filepath.Join(vaultDir, "build.zig")); err != nil { return fmt.Errorf("vault source not found at %s — expected orama-vault as sibling directory: %w", vaultDir, err) } diff --git a/core/pkg/cli/noderesolver/resolver.go b/core/pkg/cli/noderesolver/resolver.go index d88903e..0843c53 100644 --- a/core/pkg/cli/noderesolver/resolver.go +++ b/core/pkg/cli/noderesolver/resolver.go @@ -110,12 +110,17 @@ func resolveFromNetworkWithURL(gatewayURL, apiKey, env string) ([]inspector.Node if user == "" { user = "root" } + // Sandbox nodes share a single SSH key; production nodes use per-host keys. + vaultTarget := fmt.Sprintf("%s/%s", n.IPAddress, user) + if n.Environment == "sandbox" { + vaultTarget = "sandbox/root" + } nodes = append(nodes, inspector.Node{ Environment: n.Environment, User: user, Host: n.IPAddress, Role: n.Role, - VaultTarget: fmt.Sprintf("%s/%s", n.IPAddress, user), + VaultTarget: vaultTarget, }) } diff --git a/core/pkg/environments/production/provisioner.go b/core/pkg/environments/production/provisioner.go index 15d8741..0a88537 100644 --- a/core/pkg/environments/production/provisioner.go +++ b/core/pkg/environments/production/provisioner.go @@ -86,31 +86,44 @@ func (fp *FilesystemProvisioner) EnsureDirectoryStructure() error { // EnsureOramaUser creates the 'orama' system user and group for running services. // Sets ownership of the orama data directory to the new user. func (fp *FilesystemProvisioner) EnsureOramaUser() error { - // Check if user already exists - if err := exec.Command("id", "orama").Run(); err == nil { - return nil // user already exists - } - - // Create system user with no login shell and home at /opt/orama - cmd := exec.Command("useradd", "--system", "--no-create-home", - "--home-dir", fp.oramaHome, "--shell", "/usr/sbin/nologin", "orama") - if output, err := cmd.CombinedOutput(); err != nil { - return fmt.Errorf("failed to create orama user: %w\n%s", err, string(output)) - } - - // Set ownership of orama directories - chown := exec.Command("chown", "-R", "orama:orama", fp.oramaDir) - if output, err := chown.CombinedOutput(); err != nil { - return fmt.Errorf("failed to chown %s: %w\n%s", fp.oramaDir, err, string(output)) - } - - // Also chown the bin directory - binDir := filepath.Join(fp.oramaHome, "bin") - if _, err := os.Stat(binDir); err == nil { - chown = exec.Command("chown", "-R", "orama:orama", binDir) - if output, err := chown.CombinedOutput(); err != nil { - return fmt.Errorf("failed to chown %s: %w\n%s", binDir, err, string(output)) + // Check if user already exists; create if not + if err := exec.Command("id", "orama").Run(); err != nil { + cmd := exec.Command("useradd", "--system", "--no-create-home", + "--home-dir", fp.oramaHome, "--shell", "/usr/sbin/nologin", "orama") + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to create orama user: %w\n%s", err, string(output)) } + + // Set ownership of orama directories (only on first create) + chown := exec.Command("chown", "-R", "orama:orama", fp.oramaDir) + if output, err := chown.CombinedOutput(); err != nil { + return fmt.Errorf("failed to chown %s: %w\n%s", fp.oramaDir, err, string(output)) + } + + binDir := filepath.Join(fp.oramaHome, "bin") + if _, err := os.Stat(binDir); err == nil { + chown = exec.Command("chown", "-R", "orama:orama", binDir) + if output, err := chown.CombinedOutput(); err != nil { + return fmt.Errorf("failed to chown %s: %w\n%s", binDir, err, string(output)) + } + } + } + + // Always ensure the sudoers rule is up-to-date (handles upgrades too). + // Resolve systemctl path to avoid hardcoding /bin vs /usr/bin. + systemctlPath, err := exec.LookPath("systemctl") + if err != nil { + systemctlPath = "/bin/systemctl" // fallback + } + + // Grant orama user permission to manage namespace and deployment services. + sudoersRule := fmt.Sprintf( + "orama ALL=(root) NOPASSWD: %[1]s start orama-namespace-*, %[1]s stop orama-namespace-*, %[1]s enable orama-namespace-*, %[1]s disable orama-namespace-*, %[1]s restart orama-namespace-*, %[1]s start orama-deploy-*, %[1]s stop orama-deploy-*, %[1]s enable orama-deploy-*, %[1]s disable orama-deploy-*, %[1]s restart orama-deploy-*, %[1]s daemon-reload\n", + systemctlPath, + ) + sudoersPath := "/etc/sudoers.d/orama-namespaces" + if err := os.WriteFile(sudoersPath, []byte(sudoersRule), 0440); err != nil { + return fmt.Errorf("failed to write sudoers rule: %w", err) } return nil diff --git a/core/pkg/environments/production/services.go b/core/pkg/environments/production/services.go index 4101e0b..2e8728b 100644 --- a/core/pkg/environments/production/services.go +++ b/core/pkg/environments/production/services.go @@ -19,6 +19,18 @@ ProtectKernelTunables=yes ProtectKernelModules=yes RestrictNamespaces=yes` +// oramaNodeHardening is like oramaServiceHardening but WITHOUT NoNewPrivileges. +// The node process (which includes the gateway) needs to use sudo to manage +// namespace systemd services. NoNewPrivileges prevents sudo from working. +const oramaNodeHardening = `User=orama +Group=orama +ProtectSystem=strict +ProtectHome=yes +PrivateDevices=yes +ProtectKernelTunables=yes +ProtectKernelModules=yes +RestrictNamespaces=yes` + // SystemdServiceGenerator generates systemd unit files type SystemdServiceGenerator struct { oramaHome string @@ -233,7 +245,7 @@ OOMScoreAdjust=-500 [Install] WantedBy=multi-user.target -`, ssg.oramaHome, ssg.oramaDir, configFile, logFile, oramaServiceHardening) +`, ssg.oramaHome, ssg.oramaDir, configFile, logFile, oramaNodeHardening) } // GenerateVaultService generates the Orama Vault Guardian systemd unit. diff --git a/core/pkg/gateway/handlers/operator/handler.go b/core/pkg/gateway/handlers/operator/handler.go index 89f4741..d11d2e1 100644 --- a/core/pkg/gateway/handlers/operator/handler.go +++ b/core/pkg/gateway/handlers/operator/handler.go @@ -6,8 +6,10 @@ package operator import ( + "context" "encoding/json" "net/http" + "strings" "github.com/DeBrosOfficial/network/pkg/gateway/auth" "github.com/DeBrosOfficial/network/pkg/gateway/ctxkeys" @@ -29,14 +31,61 @@ func NewHandler(logger *zap.Logger, rqliteClient rqlite.Client) *Handler { } } -// walletFromRequest extracts the operator's wallet address from the JWT -// stored in the request context by the auth middleware. -func walletFromRequest(r *http.Request) string { - claims, ok := r.Context().Value(ctxkeys.JWT).(*auth.JWTClaims) - if !ok || claims == nil { +// walletFromRequest extracts the operator's wallet address from the request. +// Supports both JWT auth (wallet in Sub claim) and API key auth (wallet looked +// up from wallet_api_keys table). +func (h *Handler) walletFromRequest(r *http.Request) string { + // 1. Try JWT claims first (wallet JWT auth sets Sub = "0x...") + if claims, ok := r.Context().Value(ctxkeys.JWT).(*auth.JWTClaims); ok && claims != nil { + sub := strings.TrimSpace(claims.Sub) + if strings.HasPrefix(strings.ToLower(sub), "0x") { + return sub + } + // JWT with API key subject + if strings.HasPrefix(strings.ToLower(sub), "ak_") { + return h.resolveWalletFromAPIKey(r.Context(), sub) + } + } + + // 2. Try API key from context (X-API-Key header, no JWT) + if apiKey, ok := r.Context().Value(ctxkeys.APIKey).(string); ok && apiKey != "" { + return h.resolveWalletFromAPIKey(r.Context(), apiKey) + } + + return "" +} + +// resolveWalletFromAPIKey looks up the wallet address linked to an API key. +// It queries namespace_ownership for a wallet-type owner of the namespace. +func (h *Handler) resolveWalletFromAPIKey(ctx context.Context, apiKeySub string) string { + if h.rqliteClient == nil { return "" } - return claims.Sub + ns := extractNamespace(apiKeySub) + if ns == "" { + return "" + } + var rows []struct { + OwnerID string `db:"owner_id"` + } + if err := h.rqliteClient.Query(ctx, &rows, + `SELECT no.owner_id FROM namespace_ownership no + JOIN namespaces n ON no.namespace_id = n.id + WHERE n.name = ? AND no.owner_type = 'wallet' + LIMIT 1`, + ns); err != nil || len(rows) == 0 { + return "" + } + return rows[0].OwnerID +} + +// extractNamespace extracts the namespace from an API key subject like "ak_xxx:namespace". +func extractNamespace(apiKeySub string) string { + parts := strings.SplitN(apiKeySub, ":", 2) + if len(parts) == 2 { + return parts[1] + } + return apiKeySub } func writeJSON(w http.ResponseWriter, status int, v interface{}) { diff --git a/core/pkg/gateway/handlers/operator/handler_test.go b/core/pkg/gateway/handlers/operator/handler_test.go index 120ac68..b136c9a 100644 --- a/core/pkg/gateway/handlers/operator/handler_test.go +++ b/core/pkg/gateway/handlers/operator/handler_test.go @@ -12,37 +12,73 @@ import ( ) func TestWalletFromRequest_withClaims(t *testing.T) { + h := NewHandler(nil, nil) r := httptest.NewRequest(http.MethodGet, "/", nil) claims := &auth.JWTClaims{Sub: "0xabc123"} ctx := context.WithValue(r.Context(), ctxkeys.JWT, claims) r = r.WithContext(ctx) - wallet := walletFromRequest(r) + wallet := h.walletFromRequest(r) if wallet != "0xabc123" { t.Errorf("wallet = %q, want %q", wallet, "0xabc123") } } func TestWalletFromRequest_noClaims(t *testing.T) { + h := NewHandler(nil, nil) r := httptest.NewRequest(http.MethodGet, "/", nil) - wallet := walletFromRequest(r) + wallet := h.walletFromRequest(r) if wallet != "" { t.Errorf("wallet = %q, want empty", wallet) } } func TestWalletFromRequest_nilClaims(t *testing.T) { + h := NewHandler(nil, nil) r := httptest.NewRequest(http.MethodGet, "/", nil) ctx := context.WithValue(r.Context(), ctxkeys.JWT, (*auth.JWTClaims)(nil)) r = r.WithContext(ctx) - wallet := walletFromRequest(r) + wallet := h.walletFromRequest(r) if wallet != "" { t.Errorf("wallet = %q, want empty", wallet) } } +func TestWalletFromRequest_apiKeyContext(t *testing.T) { + // When auth middleware sets ctxkeys.APIKey (no JWT), walletFromRequest + // should try to resolve via the API key. With nil rqliteClient it returns + // empty (can't query DB), but it shouldn't panic. + h := NewHandler(nil, nil) + r := httptest.NewRequest(http.MethodGet, "/", nil) + ctx := context.WithValue(r.Context(), ctxkeys.APIKey, "ak_test:myns") + r = r.WithContext(ctx) + + // Should not panic — returns empty because no DB to query + wallet := h.walletFromRequest(r) + if wallet != "" { + t.Errorf("wallet = %q, want empty (no DB)", wallet) + } +} + +func TestExtractNamespace(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"ak_abc123:myns", "myns"}, + {"ak_abc123", "ak_abc123"}, + {"", ""}, + } + for _, tt := range tests { + got := extractNamespace(tt.input) + if got != tt.want { + t.Errorf("extractNamespace(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} + func TestDecodeJSON_valid(t *testing.T) { body := strings.NewReader(`{"node_id":"test-node","environment":"devnet"}`) r := httptest.NewRequest(http.MethodPost, "/", body) diff --git a/core/pkg/gateway/handlers/operator/invite.go b/core/pkg/gateway/handlers/operator/invite.go index fc8f3a9..244fdf7 100644 --- a/core/pkg/gateway/handlers/operator/invite.go +++ b/core/pkg/gateway/handlers/operator/invite.go @@ -31,7 +31,7 @@ func (h *Handler) HandleInvite(w http.ResponseWriter, r *http.Request) { return } - wallet := walletFromRequest(r) + wallet := h.walletFromRequest(r) if wallet == "" { writeError(w, http.StatusUnauthorized, "wallet authentication required") return diff --git a/core/pkg/gateway/handlers/operator/nodes.go b/core/pkg/gateway/handlers/operator/nodes.go index 98c7022..09afce3 100644 --- a/core/pkg/gateway/handlers/operator/nodes.go +++ b/core/pkg/gateway/handlers/operator/nodes.go @@ -35,7 +35,7 @@ func (h *Handler) HandleListNodes(w http.ResponseWriter, r *http.Request) { return } - wallet := walletFromRequest(r) + wallet := h.walletFromRequest(r) if wallet == "" { writeError(w, http.StatusUnauthorized, "wallet authentication required") return diff --git a/core/pkg/gateway/handlers/operator/register.go b/core/pkg/gateway/handlers/operator/register.go index e4aa0ce..8a1b457 100644 --- a/core/pkg/gateway/handlers/operator/register.go +++ b/core/pkg/gateway/handlers/operator/register.go @@ -37,7 +37,7 @@ func (h *Handler) HandleRegister(w http.ResponseWriter, r *http.Request) { return } - wallet := walletFromRequest(r) + wallet := h.walletFromRequest(r) if wallet == "" { writeError(w, http.StatusUnauthorized, "wallet authentication required") return diff --git a/core/pkg/namespace/cluster_manager.go b/core/pkg/namespace/cluster_manager.go index 136630f..71c3446 100644 --- a/core/pkg/namespace/cluster_manager.go +++ b/core/pkg/namespace/cluster_manager.go @@ -1199,12 +1199,26 @@ func (cm *ClusterManager) ProvisionNamespaceCluster(ctx context.Context, namespa // provisionClusterAsync performs the actual cluster provisioning in the background func (cm *ClusterManager) provisionClusterAsync(cluster *NamespaceCluster, namespaceID int, namespaceName, provisionedBy string) { defer func() { + // Recover from panics (e.g., gorqlite index-out-of-range) so the + // goroutine doesn't die silently leaving status stuck at "provisioning". + if r := recover(); r != nil { + cm.logger.Error("Provisioning panicked", + zap.String("namespace", namespaceName), + zap.Any("panic", r), + ) + bgCtx := context.Background() + cm.updateClusterStatus(bgCtx, cluster.ID, ClusterStatusFailed, + fmt.Sprintf("provisioning panicked: %v", r)) + } cm.provisioningMu.Lock() delete(cm.provisioning, namespaceName) cm.provisioningMu.Unlock() }() - ctx := context.Background() + // Overall timeout — prevents the goroutine from hanging indefinitely + // if a remote spawn request or RQLite write blocks. + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() cm.logger.Info("Starting async cluster provisioning", zap.String("cluster_id", cluster.ID), diff --git a/core/pkg/rqlite/client.go b/core/pkg/rqlite/client.go index f222b22..baded8b 100644 --- a/core/pkg/rqlite/client.go +++ b/core/pkg/rqlite/client.go @@ -25,7 +25,15 @@ type client struct { } // Query runs an arbitrary SELECT and scans rows into dest. -func (c *client) Query(ctx context.Context, dest any, query string, args ...any) error { +// Query runs a SELECT and scans results into dest. +// Includes panic recovery because the gorqlite stdlib driver can panic +// with "index out of range" when RQLite is temporarily unavailable. +func (c *client) Query(ctx context.Context, dest any, query string, args ...any) (err error) { + defer func() { + if r := recover(); r != nil { + err = fmt.Errorf("gorqlite panic (QueryContext): %v", r) + } + }() rows, err := c.db.QueryContext(ctx, query, args...) if err != nil { return err diff --git a/core/pkg/rqlite/transaction.go b/core/pkg/rqlite/transaction.go index 4cd9563..e9ea811 100644 --- a/core/pkg/rqlite/transaction.go +++ b/core/pkg/rqlite/transaction.go @@ -5,6 +5,7 @@ package rqlite import ( "context" "database/sql" + "fmt" ) // txClient implements Tx over *sql.Tx. @@ -13,7 +14,13 @@ type txClient struct { } // Query executes a SELECT query within the transaction. -func (t *txClient) Query(ctx context.Context, dest any, query string, args ...any) error { +// Includes panic recovery for the gorqlite stdlib driver. +func (t *txClient) Query(ctx context.Context, dest any, query string, args ...any) (err error) { + defer func() { + if r := recover(); r != nil { + err = fmt.Errorf("gorqlite panic (QueryContext): %v", r) + } + }() rows, err := t.tx.QueryContext(ctx, query, args...) if err != nil { return err @@ -23,7 +30,13 @@ func (t *txClient) Query(ctx context.Context, dest any, query string, args ...an } // Exec executes a write statement within the transaction. -func (t *txClient) Exec(ctx context.Context, query string, args ...any) (sql.Result, error) { +// Includes panic recovery for the gorqlite stdlib driver. +func (t *txClient) Exec(ctx context.Context, query string, args ...any) (result sql.Result, err error) { + defer func() { + if r := recover(); r != nil { + err = fmt.Errorf("gorqlite panic (ExecContext): %v", r) + } + }() return t.tx.ExecContext(ctx, query, args...) } diff --git a/core/pkg/rwagent/client.go b/core/pkg/rwagent/client.go index 64e7c3d..3859c5d 100644 --- a/core/pkg/rwagent/client.go +++ b/core/pkg/rwagent/client.go @@ -134,6 +134,21 @@ func (c *Client) GetAddress(ctx context.Context, chain string) (*WalletAddressDa return &resp.Data, nil } +// Sign signs a message with the wallet's private key. +// The desktop app may prompt the user for approval on first use. +func (c *Client) Sign(ctx context.Context, message, chain string) (*WalletSignData, error) { + body := map[string]any{"message": message, "chain": chain} + + var resp apiResponse[WalletSignData] + if err := c.doJSON(ctx, "POST", "/v1/wallet/sign", body, &resp); err != nil { + return nil, err + } + if !resp.OK { + return nil, c.apiError(resp.Error, resp.Code, 0) + } + return &resp.Data, nil +} + // Unlock sends the password to unlock the agent. func (c *Client) Unlock(ctx context.Context, password string, ttlMinutes int) error { body := map[string]any{"password": password, "ttlMinutes": ttlMinutes} diff --git a/core/pkg/rwagent/types.go b/core/pkg/rwagent/types.go index 4d04f95..aa5fa46 100644 --- a/core/pkg/rwagent/types.go +++ b/core/pkg/rwagent/types.go @@ -31,6 +31,11 @@ type WalletAddressData struct { Chain string `json:"chain"` } +// WalletSignData from POST /v1/wallet/sign. +type WalletSignData struct { + Signature string `json:"signature"` +} + // AppPermission represents an approved app in the permission database. type AppPermission struct { BinaryHash string `json:"binaryHash"` diff --git a/core/pkg/systemd/manager.go b/core/pkg/systemd/manager.go index 4648d99..aea4f3b 100644 --- a/core/pkg/systemd/manager.go +++ b/core/pkg/systemd/manager.go @@ -42,6 +42,15 @@ func (m *Manager) serviceName(namespace string, serviceType ServiceType) string return fmt.Sprintf("orama-namespace-%s@%s.service", serviceType, namespace) } +// systemctl builds an exec.Command for systemctl, prepending sudo when +// the current process is not running as root. +func systemctl(args ...string) *exec.Cmd { + if os.Getuid() == 0 { + return exec.Command("systemctl", args...) + } + return exec.Command("sudo", append([]string{"systemctl"}, args...)...) +} + // StartService starts a namespace service func (m *Manager) StartService(namespace string, serviceType ServiceType) error { svcName := m.serviceName(namespace, serviceType) @@ -49,7 +58,7 @@ func (m *Manager) StartService(namespace string, serviceType ServiceType) error zap.String("service", svcName), zap.String("namespace", namespace)) - cmd := exec.Command("systemctl", "start", svcName) + cmd := systemctl("start", svcName) m.logger.Debug("Executing systemctl command", zap.String("cmd", cmd.String()), zap.Strings("args", cmd.Args)) @@ -77,7 +86,7 @@ func (m *Manager) StopService(namespace string, serviceType ServiceType) error { zap.String("service", svcName), zap.String("namespace", namespace)) - cmd := exec.Command("systemctl", "stop", svcName) + cmd := systemctl("stop", svcName) if output, err := cmd.CombinedOutput(); err != nil { // Don't error if service is already stopped or doesn't exist if strings.Contains(string(output), "not loaded") || strings.Contains(string(output), "inactive") { @@ -98,7 +107,7 @@ func (m *Manager) RestartService(namespace string, serviceType ServiceType) erro zap.String("service", svcName), zap.String("namespace", namespace)) - cmd := exec.Command("systemctl", "restart", svcName) + cmd := systemctl("restart", svcName) if output, err := cmd.CombinedOutput(); err != nil { return fmt.Errorf("failed to restart %s: %w; output: %s", svcName, err, string(output)) } @@ -114,7 +123,7 @@ func (m *Manager) EnableService(namespace string, serviceType ServiceType) error zap.String("service", svcName), zap.String("namespace", namespace)) - cmd := exec.Command("systemctl", "enable", svcName) + cmd := systemctl("enable", svcName) if output, err := cmd.CombinedOutput(); err != nil { return fmt.Errorf("failed to enable %s: %w; output: %s", svcName, err, string(output)) } @@ -130,7 +139,7 @@ func (m *Manager) DisableService(namespace string, serviceType ServiceType) erro zap.String("service", svcName), zap.String("namespace", namespace)) - cmd := exec.Command("systemctl", "disable", svcName) + cmd := systemctl("disable", svcName) if output, err := cmd.CombinedOutput(); err != nil { // Don't error if service is already disabled or doesn't exist if strings.Contains(string(output), "not loaded") { @@ -187,7 +196,7 @@ func (m *Manager) IsServiceActive(namespace string, serviceType ServiceType) (bo // ReloadDaemon reloads systemd daemon configuration func (m *Manager) ReloadDaemon() error { m.logger.Info("Reloading systemd daemon") - cmd := exec.Command("systemctl", "daemon-reload") + cmd := systemctl("daemon-reload") if output, err := cmd.CombinedOutput(); err != nil { return fmt.Errorf("failed to reload systemd daemon: %w; output: %s", err, string(output)) } @@ -290,7 +299,7 @@ func (m *Manager) StopAllNamespaceServicesGlobally() error { for _, svc := range services { m.logger.Info("Stopping service", zap.String("service", svc)) - cmd := exec.Command("systemctl", "stop", svc) + cmd := systemctl("stop", svc) if output, err := cmd.CombinedOutput(); err != nil { m.logger.Warn("Failed to stop service", zap.String("service", svc), @@ -338,7 +347,7 @@ func (m *Manager) StopDeploymentServicesForNamespace(namespace string) { svc := fields[0] // Stop the service - if stopOut, stopErr := exec.Command("systemctl", "stop", svc).CombinedOutput(); stopErr != nil { + if stopOut, stopErr := systemctl("stop", svc).CombinedOutput(); stopErr != nil { m.logger.Warn("Failed to stop deployment service", zap.String("service", svc), zap.Error(stopErr), @@ -346,7 +355,7 @@ func (m *Manager) StopDeploymentServicesForNamespace(namespace string) { } // Disable the service - if disOut, disErr := exec.Command("systemctl", "disable", svc).CombinedOutput(); disErr != nil { + if disOut, disErr := systemctl("disable", svc).CombinedOutput(); disErr != nil { m.logger.Warn("Failed to disable deployment service", zap.String("service", svc), zap.Error(disErr),