mirror of
https://github.com/DeBrosOfficial/orama.git
synced 2026-05-01 06:04:12 +00:00
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:
parent
8d7d1c6621
commit
c27faa02fa
@ -3,54 +3,58 @@ package auth
|
|||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/DeBrosOfficial/network/pkg/rwagent"
|
||||||
"github.com/DeBrosOfficial/network/pkg/tlsutil"
|
"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 {
|
func IsRootWalletInstalled() bool {
|
||||||
_, err := exec.LookPath("rw")
|
client := rwagent.New(os.Getenv("RW_AGENT_SOCK"))
|
||||||
return err == nil
|
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) {
|
func getRootWalletAddress() (string, error) {
|
||||||
cmd := exec.Command("rw", "address", "--chain", "evm")
|
client := rwagent.New(os.Getenv("RW_AGENT_SOCK"))
|
||||||
cmd.Stderr = os.Stderr
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
out, err := cmd.Output()
|
defer cancel()
|
||||||
|
|
||||||
|
data, err := client.GetAddress(ctx, "evm")
|
||||||
if err != nil {
|
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 data.Address == "" {
|
||||||
if addr == "" {
|
return "", fmt.Errorf("rootwallet agent returned empty address")
|
||||||
return "", fmt.Errorf("rw returned empty address — run 'rw init' first")
|
|
||||||
}
|
}
|
||||||
return addr, nil
|
return data.Address, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// signWithRootWallet signs a message using RootWallet's EVM key.
|
// signWithRootWallet signs a message using the rootwallet agent's EVM key.
|
||||||
// Stdin is passed through so the user can enter their password if the session is expired.
|
// The desktop app may prompt the user for approval.
|
||||||
func signWithRootWallet(message string) (string, error) {
|
func signWithRootWallet(message string) (string, error) {
|
||||||
cmd := exec.Command("rw", "sign", message, "--chain", "evm")
|
client := rwagent.New(os.Getenv("RW_AGENT_SOCK"))
|
||||||
cmd.Stdin = os.Stdin
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||||
cmd.Stderr = os.Stderr
|
defer cancel()
|
||||||
out, err := cmd.Output()
|
|
||||||
|
data, err := client.Sign(ctx, message, "evm")
|
||||||
if err != nil {
|
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 data.Signature == "" {
|
||||||
if sig == "" {
|
return "", fmt.Errorf("rootwallet agent returned empty signature")
|
||||||
return "", fmt.Errorf("rw returned empty signature")
|
|
||||||
}
|
}
|
||||||
return sig, nil
|
return data.Signature, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// PerformRootWalletAuthentication performs a challenge-response authentication flow
|
// PerformRootWalletAuthentication performs a challenge-response authentication flow
|
||||||
|
|||||||
@ -197,8 +197,8 @@ func (b *Builder) buildVaultGuardian() error {
|
|||||||
return fmt.Errorf("zig not found in PATH — install from https://ziglang.org/download/")
|
return fmt.Errorf("zig not found in PATH — install from https://ziglang.org/download/")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Vault source is sibling to orama project
|
// Vault source is sibling to core/ within the orama monorepo
|
||||||
vaultDir := filepath.Join(b.projectDir, "..", "orama-vault")
|
vaultDir := filepath.Join(b.projectDir, "..", "vault")
|
||||||
if _, err := os.Stat(filepath.Join(vaultDir, "build.zig")); err != nil {
|
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)
|
return fmt.Errorf("vault source not found at %s — expected orama-vault as sibling directory: %w", vaultDir, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -110,12 +110,17 @@ func resolveFromNetworkWithURL(gatewayURL, apiKey, env string) ([]inspector.Node
|
|||||||
if user == "" {
|
if user == "" {
|
||||||
user = "root"
|
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{
|
nodes = append(nodes, inspector.Node{
|
||||||
Environment: n.Environment,
|
Environment: n.Environment,
|
||||||
User: user,
|
User: user,
|
||||||
Host: n.IPAddress,
|
Host: n.IPAddress,
|
||||||
Role: n.Role,
|
Role: n.Role,
|
||||||
VaultTarget: fmt.Sprintf("%s/%s", n.IPAddress, user),
|
VaultTarget: vaultTarget,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -86,31 +86,44 @@ func (fp *FilesystemProvisioner) EnsureDirectoryStructure() error {
|
|||||||
// EnsureOramaUser creates the 'orama' system user and group for running services.
|
// EnsureOramaUser creates the 'orama' system user and group for running services.
|
||||||
// Sets ownership of the orama data directory to the new user.
|
// Sets ownership of the orama data directory to the new user.
|
||||||
func (fp *FilesystemProvisioner) EnsureOramaUser() error {
|
func (fp *FilesystemProvisioner) EnsureOramaUser() error {
|
||||||
// Check if user already exists
|
// Check if user already exists; create if not
|
||||||
if err := exec.Command("id", "orama").Run(); err == nil {
|
if err := exec.Command("id", "orama").Run(); err != nil {
|
||||||
return nil // user already exists
|
cmd := exec.Command("useradd", "--system", "--no-create-home",
|
||||||
}
|
"--home-dir", fp.oramaHome, "--shell", "/usr/sbin/nologin", "orama")
|
||||||
|
if output, err := cmd.CombinedOutput(); err != nil {
|
||||||
// Create system user with no login shell and home at /opt/orama
|
return fmt.Errorf("failed to create orama user: %w\n%s", err, string(output))
|
||||||
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))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
return nil
|
||||||
|
|||||||
@ -19,6 +19,18 @@ ProtectKernelTunables=yes
|
|||||||
ProtectKernelModules=yes
|
ProtectKernelModules=yes
|
||||||
RestrictNamespaces=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
|
// SystemdServiceGenerator generates systemd unit files
|
||||||
type SystemdServiceGenerator struct {
|
type SystemdServiceGenerator struct {
|
||||||
oramaHome string
|
oramaHome string
|
||||||
@ -233,7 +245,7 @@ OOMScoreAdjust=-500
|
|||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
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.
|
// GenerateVaultService generates the Orama Vault Guardian systemd unit.
|
||||||
|
|||||||
@ -6,8 +6,10 @@
|
|||||||
package operator
|
package operator
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/DeBrosOfficial/network/pkg/gateway/auth"
|
"github.com/DeBrosOfficial/network/pkg/gateway/auth"
|
||||||
"github.com/DeBrosOfficial/network/pkg/gateway/ctxkeys"
|
"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
|
// walletFromRequest extracts the operator's wallet address from the request.
|
||||||
// stored in the request context by the auth middleware.
|
// Supports both JWT auth (wallet in Sub claim) and API key auth (wallet looked
|
||||||
func walletFromRequest(r *http.Request) string {
|
// up from wallet_api_keys table).
|
||||||
claims, ok := r.Context().Value(ctxkeys.JWT).(*auth.JWTClaims)
|
func (h *Handler) walletFromRequest(r *http.Request) string {
|
||||||
if !ok || claims == nil {
|
// 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 ""
|
||||||
}
|
}
|
||||||
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{}) {
|
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
|
||||||
|
|||||||
@ -12,37 +12,73 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestWalletFromRequest_withClaims(t *testing.T) {
|
func TestWalletFromRequest_withClaims(t *testing.T) {
|
||||||
|
h := NewHandler(nil, nil)
|
||||||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
claims := &auth.JWTClaims{Sub: "0xabc123"}
|
claims := &auth.JWTClaims{Sub: "0xabc123"}
|
||||||
ctx := context.WithValue(r.Context(), ctxkeys.JWT, claims)
|
ctx := context.WithValue(r.Context(), ctxkeys.JWT, claims)
|
||||||
r = r.WithContext(ctx)
|
r = r.WithContext(ctx)
|
||||||
|
|
||||||
wallet := walletFromRequest(r)
|
wallet := h.walletFromRequest(r)
|
||||||
if wallet != "0xabc123" {
|
if wallet != "0xabc123" {
|
||||||
t.Errorf("wallet = %q, want %q", wallet, "0xabc123")
|
t.Errorf("wallet = %q, want %q", wallet, "0xabc123")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWalletFromRequest_noClaims(t *testing.T) {
|
func TestWalletFromRequest_noClaims(t *testing.T) {
|
||||||
|
h := NewHandler(nil, nil)
|
||||||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
|
||||||
wallet := walletFromRequest(r)
|
wallet := h.walletFromRequest(r)
|
||||||
if wallet != "" {
|
if wallet != "" {
|
||||||
t.Errorf("wallet = %q, want empty", wallet)
|
t.Errorf("wallet = %q, want empty", wallet)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWalletFromRequest_nilClaims(t *testing.T) {
|
func TestWalletFromRequest_nilClaims(t *testing.T) {
|
||||||
|
h := NewHandler(nil, nil)
|
||||||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
ctx := context.WithValue(r.Context(), ctxkeys.JWT, (*auth.JWTClaims)(nil))
|
ctx := context.WithValue(r.Context(), ctxkeys.JWT, (*auth.JWTClaims)(nil))
|
||||||
r = r.WithContext(ctx)
|
r = r.WithContext(ctx)
|
||||||
|
|
||||||
wallet := walletFromRequest(r)
|
wallet := h.walletFromRequest(r)
|
||||||
if wallet != "" {
|
if wallet != "" {
|
||||||
t.Errorf("wallet = %q, want empty", 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) {
|
func TestDecodeJSON_valid(t *testing.T) {
|
||||||
body := strings.NewReader(`{"node_id":"test-node","environment":"devnet"}`)
|
body := strings.NewReader(`{"node_id":"test-node","environment":"devnet"}`)
|
||||||
r := httptest.NewRequest(http.MethodPost, "/", body)
|
r := httptest.NewRequest(http.MethodPost, "/", body)
|
||||||
|
|||||||
@ -31,7 +31,7 @@ func (h *Handler) HandleInvite(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
wallet := walletFromRequest(r)
|
wallet := h.walletFromRequest(r)
|
||||||
if wallet == "" {
|
if wallet == "" {
|
||||||
writeError(w, http.StatusUnauthorized, "wallet authentication required")
|
writeError(w, http.StatusUnauthorized, "wallet authentication required")
|
||||||
return
|
return
|
||||||
|
|||||||
@ -35,7 +35,7 @@ func (h *Handler) HandleListNodes(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
wallet := walletFromRequest(r)
|
wallet := h.walletFromRequest(r)
|
||||||
if wallet == "" {
|
if wallet == "" {
|
||||||
writeError(w, http.StatusUnauthorized, "wallet authentication required")
|
writeError(w, http.StatusUnauthorized, "wallet authentication required")
|
||||||
return
|
return
|
||||||
|
|||||||
@ -37,7 +37,7 @@ func (h *Handler) HandleRegister(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
wallet := walletFromRequest(r)
|
wallet := h.walletFromRequest(r)
|
||||||
if wallet == "" {
|
if wallet == "" {
|
||||||
writeError(w, http.StatusUnauthorized, "wallet authentication required")
|
writeError(w, http.StatusUnauthorized, "wallet authentication required")
|
||||||
return
|
return
|
||||||
|
|||||||
@ -1199,12 +1199,26 @@ func (cm *ClusterManager) ProvisionNamespaceCluster(ctx context.Context, namespa
|
|||||||
// provisionClusterAsync performs the actual cluster provisioning in the background
|
// provisionClusterAsync performs the actual cluster provisioning in the background
|
||||||
func (cm *ClusterManager) provisionClusterAsync(cluster *NamespaceCluster, namespaceID int, namespaceName, provisionedBy string) {
|
func (cm *ClusterManager) provisionClusterAsync(cluster *NamespaceCluster, namespaceID int, namespaceName, provisionedBy string) {
|
||||||
defer func() {
|
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()
|
cm.provisioningMu.Lock()
|
||||||
delete(cm.provisioning, namespaceName)
|
delete(cm.provisioning, namespaceName)
|
||||||
cm.provisioningMu.Unlock()
|
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",
|
cm.logger.Info("Starting async cluster provisioning",
|
||||||
zap.String("cluster_id", cluster.ID),
|
zap.String("cluster_id", cluster.ID),
|
||||||
|
|||||||
@ -25,7 +25,15 @@ type client struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Query runs an arbitrary SELECT and scans rows into dest.
|
// 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...)
|
rows, err := c.db.QueryContext(ctx, query, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@ -5,6 +5,7 @@ package rqlite
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
)
|
)
|
||||||
|
|
||||||
// txClient implements Tx over *sql.Tx.
|
// txClient implements Tx over *sql.Tx.
|
||||||
@ -13,7 +14,13 @@ type txClient struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Query executes a SELECT query within the transaction.
|
// 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...)
|
rows, err := t.tx.QueryContext(ctx, query, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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.
|
// 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...)
|
return t.tx.ExecContext(ctx, query, args...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -134,6 +134,21 @@ func (c *Client) GetAddress(ctx context.Context, chain string) (*WalletAddressDa
|
|||||||
return &resp.Data, nil
|
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.
|
// Unlock sends the password to unlock the agent.
|
||||||
func (c *Client) Unlock(ctx context.Context, password string, ttlMinutes int) error {
|
func (c *Client) Unlock(ctx context.Context, password string, ttlMinutes int) error {
|
||||||
body := map[string]any{"password": password, "ttlMinutes": ttlMinutes}
|
body := map[string]any{"password": password, "ttlMinutes": ttlMinutes}
|
||||||
|
|||||||
@ -31,6 +31,11 @@ type WalletAddressData struct {
|
|||||||
Chain string `json:"chain"`
|
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.
|
// AppPermission represents an approved app in the permission database.
|
||||||
type AppPermission struct {
|
type AppPermission struct {
|
||||||
BinaryHash string `json:"binaryHash"`
|
BinaryHash string `json:"binaryHash"`
|
||||||
|
|||||||
@ -42,6 +42,15 @@ func (m *Manager) serviceName(namespace string, serviceType ServiceType) string
|
|||||||
return fmt.Sprintf("orama-namespace-%s@%s.service", serviceType, namespace)
|
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
|
// StartService starts a namespace service
|
||||||
func (m *Manager) StartService(namespace string, serviceType ServiceType) error {
|
func (m *Manager) StartService(namespace string, serviceType ServiceType) error {
|
||||||
svcName := m.serviceName(namespace, serviceType)
|
svcName := m.serviceName(namespace, serviceType)
|
||||||
@ -49,7 +58,7 @@ func (m *Manager) StartService(namespace string, serviceType ServiceType) error
|
|||||||
zap.String("service", svcName),
|
zap.String("service", svcName),
|
||||||
zap.String("namespace", namespace))
|
zap.String("namespace", namespace))
|
||||||
|
|
||||||
cmd := exec.Command("systemctl", "start", svcName)
|
cmd := systemctl("start", svcName)
|
||||||
m.logger.Debug("Executing systemctl command",
|
m.logger.Debug("Executing systemctl command",
|
||||||
zap.String("cmd", cmd.String()),
|
zap.String("cmd", cmd.String()),
|
||||||
zap.Strings("args", cmd.Args))
|
zap.Strings("args", cmd.Args))
|
||||||
@ -77,7 +86,7 @@ func (m *Manager) StopService(namespace string, serviceType ServiceType) error {
|
|||||||
zap.String("service", svcName),
|
zap.String("service", svcName),
|
||||||
zap.String("namespace", namespace))
|
zap.String("namespace", namespace))
|
||||||
|
|
||||||
cmd := exec.Command("systemctl", "stop", svcName)
|
cmd := systemctl("stop", svcName)
|
||||||
if output, err := cmd.CombinedOutput(); err != nil {
|
if output, err := cmd.CombinedOutput(); err != nil {
|
||||||
// Don't error if service is already stopped or doesn't exist
|
// Don't error if service is already stopped or doesn't exist
|
||||||
if strings.Contains(string(output), "not loaded") || strings.Contains(string(output), "inactive") {
|
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("service", svcName),
|
||||||
zap.String("namespace", namespace))
|
zap.String("namespace", namespace))
|
||||||
|
|
||||||
cmd := exec.Command("systemctl", "restart", svcName)
|
cmd := systemctl("restart", svcName)
|
||||||
if output, err := cmd.CombinedOutput(); err != nil {
|
if output, err := cmd.CombinedOutput(); err != nil {
|
||||||
return fmt.Errorf("failed to restart %s: %w; output: %s", svcName, err, string(output))
|
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("service", svcName),
|
||||||
zap.String("namespace", namespace))
|
zap.String("namespace", namespace))
|
||||||
|
|
||||||
cmd := exec.Command("systemctl", "enable", svcName)
|
cmd := systemctl("enable", svcName)
|
||||||
if output, err := cmd.CombinedOutput(); err != nil {
|
if output, err := cmd.CombinedOutput(); err != nil {
|
||||||
return fmt.Errorf("failed to enable %s: %w; output: %s", svcName, err, string(output))
|
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("service", svcName),
|
||||||
zap.String("namespace", namespace))
|
zap.String("namespace", namespace))
|
||||||
|
|
||||||
cmd := exec.Command("systemctl", "disable", svcName)
|
cmd := systemctl("disable", svcName)
|
||||||
if output, err := cmd.CombinedOutput(); err != nil {
|
if output, err := cmd.CombinedOutput(); err != nil {
|
||||||
// Don't error if service is already disabled or doesn't exist
|
// Don't error if service is already disabled or doesn't exist
|
||||||
if strings.Contains(string(output), "not loaded") {
|
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
|
// ReloadDaemon reloads systemd daemon configuration
|
||||||
func (m *Manager) ReloadDaemon() error {
|
func (m *Manager) ReloadDaemon() error {
|
||||||
m.logger.Info("Reloading systemd daemon")
|
m.logger.Info("Reloading systemd daemon")
|
||||||
cmd := exec.Command("systemctl", "daemon-reload")
|
cmd := systemctl("daemon-reload")
|
||||||
if output, err := cmd.CombinedOutput(); err != nil {
|
if output, err := cmd.CombinedOutput(); err != nil {
|
||||||
return fmt.Errorf("failed to reload systemd daemon: %w; output: %s", err, string(output))
|
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 {
|
for _, svc := range services {
|
||||||
m.logger.Info("Stopping service", zap.String("service", svc))
|
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 {
|
if output, err := cmd.CombinedOutput(); err != nil {
|
||||||
m.logger.Warn("Failed to stop service",
|
m.logger.Warn("Failed to stop service",
|
||||||
zap.String("service", svc),
|
zap.String("service", svc),
|
||||||
@ -338,7 +347,7 @@ func (m *Manager) StopDeploymentServicesForNamespace(namespace string) {
|
|||||||
svc := fields[0]
|
svc := fields[0]
|
||||||
|
|
||||||
// Stop the service
|
// 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",
|
m.logger.Warn("Failed to stop deployment service",
|
||||||
zap.String("service", svc),
|
zap.String("service", svc),
|
||||||
zap.Error(stopErr),
|
zap.Error(stopErr),
|
||||||
@ -346,7 +355,7 @@ func (m *Manager) StopDeploymentServicesForNamespace(namespace string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Disable the service
|
// 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",
|
m.logger.Warn("Failed to disable deployment service",
|
||||||
zap.String("service", svc),
|
zap.String("service", svc),
|
||||||
zap.Error(disErr),
|
zap.Error(disErr),
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user