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
This commit is contained in:
anonpenguin23 2026-03-28 08:59:11 +02:00
parent 8d7d1c6621
commit c27faa02fa
16 changed files with 260 additions and 77 deletions

View File

@ -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

View File

@ -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)
}

View File

@ -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,
})
}

View File

@ -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

View File

@ -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.

View File

@ -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{}) {

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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),

View File

@ -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

View File

@ -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...)
}

View File

@ -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}

View File

@ -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"`

View File

@ -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),