From 795647104bf5fcd99cabcf29df3d9231f4bbb390 Mon Sep 17 00:00:00 2001 From: bussyjd Date: Fri, 13 Feb 2026 18:25:06 +0400 Subject: [PATCH] feat(tls): add mkcert-based TLS for HTTPS access on port 8443 Enable crypto.subtle in browsers by serving *.obol.stack over HTTPS. This allows OpenClaw's Control UI to use Web Crypto API for device identity (Ed25519 keypair) instead of falling back to token-only auth. - New internal/tls package: cert generation, K8s Secret management - obolup.sh: install mkcert v1.4.4 as optional dependency - stack init: generate wildcard cert for *.obol.stack via mkcert - stack up: create obol-stack-tls Secret, patch helmfile for websecure Gateway listener with HTTPS on port 8443 - openclaw: add websecure parentRef, HTTPS dashboard URL when certs exist - Import gateway token from ~/.openclaw/openclaw.json on setup - Default model switched to gpt-oss:20b-cloud (Ollama cloud) HTTP fallback on port 8080 remains fully functional. allowInsecureAuth kept for HTTP-only environments. Closes #155 --- internal/openclaw/import.go | 9 +- internal/openclaw/openclaw.go | 38 ++++++- internal/openclaw/overlay_test.go | 2 +- internal/stack/stack.go | 83 ++++++++++++++ internal/stack/stack_test.go | 93 +++++++++++++++ internal/tls/tls.go | 183 ++++++++++++++++++++++++++++++ internal/tls/tls_test.go | 106 +++++++++++++++++ obolup.sh | 43 +++++++ 8 files changed, 550 insertions(+), 7 deletions(-) create mode 100644 internal/stack/stack_test.go create mode 100644 internal/tls/tls.go create mode 100644 internal/tls/tls_test.go diff --git a/internal/openclaw/import.go b/internal/openclaw/import.go index 52ca5dd..159c702 100644 --- a/internal/openclaw/import.go +++ b/internal/openclaw/import.go @@ -12,6 +12,7 @@ import ( type ImportResult struct { Providers []ImportedProvider AgentModel string + GatewayToken string // gateway.remote.token from openclaw.json Channels ImportedChannels WorkspaceDir string // path to ~/.openclaw/workspace/ if it exists and contains marker files } @@ -69,6 +70,11 @@ type openclawConfig struct { Workspace string `json:"workspace"` } `json:"defaults"` } `json:"agents"` + Gateway struct { + Remote struct { + Token string `json:"token"` + } `json:"remote"` + } `json:"gateway"` Channels struct { Telegram *struct { BotToken string `json:"botToken"` @@ -118,7 +124,8 @@ func DetectExistingConfig() (*ImportResult, error) { } result := &ImportResult{ - AgentModel: cfg.Agents.Defaults.Model.Primary, + AgentModel: cfg.Agents.Defaults.Model.Primary, + GatewayToken: cfg.Gateway.Remote.Token, } // Detect workspace directory diff --git a/internal/openclaw/openclaw.go b/internal/openclaw/openclaw.go index 7bfec8d..0c31bca 100644 --- a/internal/openclaw/openclaw.go +++ b/internal/openclaw/openclaw.go @@ -4,6 +4,7 @@ import ( "bufio" "bytes" "context" + "crypto/rand" "embed" "encoding/base64" "encoding/json" @@ -22,6 +23,7 @@ import ( "github.com/ObolNetwork/obol-stack/internal/config" "github.com/ObolNetwork/obol-stack/internal/llm" + oboltls "github.com/ObolNetwork/obol-stack/internal/tls" "github.com/dustinkirkland/golang-petname" ) @@ -282,7 +284,7 @@ func doSync(cfg *config.Config, id string) error { hostname := fmt.Sprintf("openclaw-%s.%s", id, defaultDomain) fmt.Printf("\n✓ OpenClaw synced successfully!\n") fmt.Printf(" Namespace: %s\n", namespace) - fmt.Printf(" URL: http://%s\n", hostname) + fmt.Printf(" URL: %s\n", instanceURL(cfg, hostname)) fmt.Printf("\nRetrieve gateway token:\n") fmt.Printf(" obol openclaw token %s\n", id) fmt.Printf("\nPort-forward fallback:\n") @@ -686,7 +688,7 @@ func List(cfg *config.Config) error { hostname := fmt.Sprintf("openclaw-%s.%s", id, defaultDomain) fmt.Printf(" %s\n", id) fmt.Printf(" Namespace: %s\n", namespace) - fmt.Printf(" URL: http://%s\n", hostname) + fmt.Printf(" URL: %s\n", instanceURL(cfg, hostname)) fmt.Println() count++ } @@ -956,6 +958,15 @@ func cliViaKubectlExec(cfg *config.Config, namespace string, args []string) erro return nil } +// instanceURL returns the URL for an OpenClaw instance, using HTTPS on port 8443 +// when TLS certs are available, or HTTP (default port) otherwise. +func instanceURL(cfg *config.Config, hostname string) string { + if oboltls.CertsExist(cfg.ConfigDir) { + return fmt.Sprintf("https://%s:8443", hostname) + } + return fmt.Sprintf("http://%s", hostname) +} + // deploymentPath returns the path to a deployment directory func deploymentPath(cfg *config.Config, id string) string { return filepath.Join(cfg.ConfigDir, "applications", appName, id) @@ -1009,6 +1020,9 @@ httpRoute: - name: traefik-gateway namespace: traefik sectionName: web + - name: traefik-gateway + namespace: traefik + sectionName: websecure # SA needs API token mount for K8s read access serviceAccount: @@ -1020,6 +1034,20 @@ rbac: `) + // Gateway token: import from existing config or generate a new one + token := "" + if imported != nil && imported.GatewayToken != "" { + token = imported.GatewayToken + } else { + tokenBytes := make([]byte, 32) + if _, err := rand.Read(tokenBytes); err == nil { + token = base64.RawURLEncoding.EncodeToString(tokenBytes) + } + } + if token != "" { + b.WriteString(fmt.Sprintf("secrets:\n gatewayToken:\n value: %s\n\n", token)) + } + // Provider and agent model configuration importedOverlay := TranslateToOverlayYAML(imported) if importedOverlay != "" { @@ -1038,7 +1066,7 @@ rbac: } else { b.WriteString(`# Route agent traffic to in-cluster Ollama via llmspy proxy openclaw: - agentModel: ollama/glm-4.7-flash + agentModel: ollama/gpt-oss:20b-cloud gateway: # Allow control UI over HTTP behind Traefik (local dev stack). # Required: browser on non-localhost HTTP has no crypto.subtle, @@ -1054,8 +1082,8 @@ models: apiKeyEnvVar: OLLAMA_API_KEY apiKeyValue: ollama-local models: - - id: glm-4.7-flash - name: GLM-4.7 Flash + - id: gpt-oss:20b-cloud + name: GPT-OSS 20B `) } diff --git a/internal/openclaw/overlay_test.go b/internal/openclaw/overlay_test.go index fdeed61..4df6808 100644 --- a/internal/openclaw/overlay_test.go +++ b/internal/openclaw/overlay_test.go @@ -127,7 +127,7 @@ func TestGenerateOverlayValues_OllamaDefault(t *testing.T) { // When imported is nil, generateOverlayValues should use Ollama defaults yaml := generateOverlayValues("openclaw-default.obol.stack", nil) - if !strings.Contains(yaml, "agentModel: ollama/glm-4.7-flash") { + if !strings.Contains(yaml, "agentModel: ollama/gpt-oss:20b-cloud") { t.Errorf("default overlay missing ollama agentModel, got:\n%s", yaml) } if !strings.Contains(yaml, "baseUrl: http://llmspy.llm.svc.cluster.local:8000/v1") { diff --git a/internal/stack/stack.go b/internal/stack/stack.go index fbbf9f9..0fa10dc 100644 --- a/internal/stack/stack.go +++ b/internal/stack/stack.go @@ -12,6 +12,7 @@ import ( "github.com/ObolNetwork/obol-stack/internal/dns" "github.com/ObolNetwork/obol-stack/internal/embed" "github.com/ObolNetwork/obol-stack/internal/openclaw" + oboltls "github.com/ObolNetwork/obol-stack/internal/tls" petname "github.com/dustinkirkland/golang-petname" ) @@ -95,6 +96,27 @@ func Init(cfg *config.Config, force bool) error { } fmt.Printf("Defaults copied to: %s\n", defaultsDir) + // Generate TLS certificates for *.obol.stack (if mkcert is available) + if oboltls.MkcertAvailable(cfg.BinDir) { + fmt.Println("Generating TLS certificates for *.obol.stack...") + if err := oboltls.GenerateCerts(cfg.BinDir, cfg.ConfigDir); err != nil { + fmt.Printf("Warning: TLS cert generation failed: %v\n", err) + fmt.Println("Stack will use HTTP-only mode") + } else { + fmt.Println("TLS certificates generated (trusted by OS)") + } + } else { + fmt.Println("mkcert not found — TLS disabled (install via obolup.sh to enable)") + } + + // Patch the defaults helmfile to enable TLS if certs were generated + if oboltls.CertsExist(cfg.ConfigDir) { + helmfilePath := filepath.Join(defaultsDir, "helmfile.yaml") + if err := enableHelmfileTLS(helmfilePath); err != nil { + fmt.Printf("Warning: failed to enable TLS in helmfile: %v\n", err) + } + } + // Store stack ID for later use (stackIDPath already declared above) if err := os.WriteFile(stackIDPath, []byte(stackID), 0644); err != nil { return fmt.Errorf("failed to write stack ID: %w", err) @@ -384,6 +406,17 @@ func syncDefaults(cfg *config.Config, kubeconfigPath string) error { fmt.Println("Default infrastructure deployed") + // Create TLS Secret in traefik namespace (if certs exist) + if oboltls.CertsExist(cfg.ConfigDir) { + fmt.Println("Creating TLS Secret for Traefik...") + if err := oboltls.EnsureK8sSecret(cfg.BinDir, cfg.ConfigDir, kubeconfigPath); err != nil { + fmt.Printf("Warning: TLS Secret creation failed: %v\n", err) + fmt.Println("HTTPS will be unavailable until the Secret is created") + } else { + fmt.Println("TLS Secret created — HTTPS available on port 8443") + } + } + // Deploy default OpenClaw instance (non-fatal on failure) fmt.Println("Setting up default OpenClaw instance...") if err := openclaw.SetupDefault(cfg); err != nil { @@ -413,3 +446,53 @@ func migrateDefaultsHTTPRouteHostnames(helmfilePath string) error { } return os.WriteFile(helmfilePath, []byte(updated), 0644) } + +// enableHelmfileTLS patches the defaults helmfile to enable TLS on the Traefik +// websecure port and add a websecure Gateway listener with certificateRefs. +// Also adds websecure parentRef to infrastructure HTTPRoutes (erpc, obol-frontend). +func enableHelmfileTLS(helmfilePath string) error { + data, err := os.ReadFile(helmfilePath) + if err != nil { + return err + } + s := string(data) + + // Patch 1: Enable TLS on websecure port + tlsOld := "enabled: false # TLS termination disabled for local dev" + tlsNew := "enabled: true # TLS termination via mkcert" + if strings.Contains(s, tlsOld) { + s = strings.Replace(s, tlsOld, tlsNew, 1) + } + + // Patch 2: Add websecure Gateway listener after the web listener block. + // Find the end of the web listener's namespacePolicy block and insert websecure. + webListenerEnd := " namespacePolicy:\n from: All\n" + websecureListener := webListenerEnd + + " websecure:\n" + + " port: 8443\n" + + " protocol: HTTPS\n" + + " certificateRefs:\n" + + " - name: obol-stack-tls\n" + + " namespacePolicy:\n" + + " from: All\n" + if strings.Contains(s, webListenerEnd) && !strings.Contains(s, "protocol: HTTPS") { + // Only the first occurrence (the Gateway listeners block, not the ports block) + s = strings.Replace(s, webListenerEnd, websecureListener, 1) + } + + // Patch 3: Add websecure parentRef to infrastructure HTTPRoutes. + // Each route has "sectionName: web" — add a second parentRef for websecure. + webRef := " sectionName: web\n" + dualRef := webRef + + " - name: traefik-gateway\n" + + " namespace: traefik\n" + + " sectionName: websecure\n" + if strings.Contains(s, webRef) && !strings.Contains(s, "sectionName: websecure") { + s = strings.ReplaceAll(s, webRef, dualRef) + } + + if s == string(data) { + return nil // No changes needed + } + return os.WriteFile(helmfilePath, []byte(s), 0644) +} diff --git a/internal/stack/stack_test.go b/internal/stack/stack_test.go new file mode 100644 index 0000000..ba5b86a --- /dev/null +++ b/internal/stack/stack_test.go @@ -0,0 +1,93 @@ +package stack + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestEnableHelmfileTLS(t *testing.T) { + // Use the actual embedded helmfile content for the test. + // This ensures the string patterns match the real file. + srcPath := filepath.Join("..", "embed", "infrastructure", "helmfile.yaml") + data, err := os.ReadFile(srcPath) + if err != nil { + t.Fatalf("failed to read source helmfile: %v", err) + } + + // Write to a temp file + tmpDir := t.TempDir() + helmfilePath := filepath.Join(tmpDir, "helmfile.yaml") + if err := os.WriteFile(helmfilePath, data, 0644); err != nil { + t.Fatal(err) + } + + // Run the patching function + if err := enableHelmfileTLS(helmfilePath); err != nil { + t.Fatalf("enableHelmfileTLS failed: %v", err) + } + + // Read patched content + patched, err := os.ReadFile(helmfilePath) + if err != nil { + t.Fatal(err) + } + content := string(patched) + + // Verify Patch 1: TLS enabled + if strings.Contains(content, "enabled: false # TLS termination disabled for local dev") { + t.Error("Patch 1 failed: TLS still disabled") + } + if !strings.Contains(content, "enabled: true # TLS termination via mkcert") { + t.Error("Patch 1 failed: TLS enabled marker not found") + } + + // Verify Patch 2: websecure Gateway listener added + if !strings.Contains(content, "protocol: HTTPS") { + t.Error("Patch 2 failed: HTTPS protocol not found") + } + if !strings.Contains(content, "obol-stack-tls") { + t.Error("Patch 2 failed: certificateRefs not found") + } + + // Verify Patch 3: websecure parentRef added to HTTPRoutes + if !strings.Contains(content, "sectionName: websecure") { + t.Error("Patch 3 failed: websecure sectionName not found in routes") + } + + // Count exact occurrences (use "\n" boundary to avoid substring matching) + // "sectionName: web\n" matches only the web refs, not websecure + webCount := strings.Count(content, "sectionName: web\n") + websecureCount := strings.Count(content, "sectionName: websecure\n") + if webCount != websecureCount { + t.Errorf("Patch 3: web refs (%d) != websecure refs (%d)", webCount, websecureCount) + } + if websecureCount < 2 { + t.Errorf("Patch 3: expected at least 2 websecure refs, got %d", websecureCount) + } + + // Verify the patched content is valid YAML structure (basic check) + // Each websecure parentRef should appear after a web parentRef + lines := strings.Split(content, "\n") + for i, line := range lines { + if strings.Contains(line, "sectionName: websecure") { + // Should be preceded by a line with "namespace: traefik" + if i < 1 || !strings.Contains(lines[i-1], "namespace: traefik") { + t.Errorf("Patch 3: websecure sectionName at line %d not preceded by namespace: traefik", i+1) + } + } + } + + // Verify idempotency — running again should be a no-op + if err := enableHelmfileTLS(helmfilePath); err != nil { + t.Fatalf("second enableHelmfileTLS call failed: %v", err) + } + patched2, err := os.ReadFile(helmfilePath) + if err != nil { + t.Fatal(err) + } + if string(patched2) != content { + t.Error("enableHelmfileTLS is not idempotent — second call changed the file") + } +} diff --git a/internal/tls/tls.go b/internal/tls/tls.go new file mode 100644 index 0000000..9783e0f --- /dev/null +++ b/internal/tls/tls.go @@ -0,0 +1,183 @@ +package tls + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +const ( + // certFile is the TLS certificate filename. + certFile = "obol-stack.pem" + // keyFile is the TLS private key filename. + keyFile = "obol-stack-key.pem" + // tlsDir is the subdirectory under configDir for TLS files. + tlsDir = "tls" + // k8sSecretName is the Kubernetes TLS Secret name. + k8sSecretName = "obol-stack-tls" + // k8sNamespace is the namespace for the TLS Secret. + k8sNamespace = "traefik" +) + +// CertDir returns the TLS directory path. +func CertDir(configDir string) string { + return filepath.Join(configDir, tlsDir) +} + +// CertPath returns the path to the TLS certificate. +func CertPath(configDir string) string { + return filepath.Join(configDir, tlsDir, certFile) +} + +// KeyPath returns the path to the TLS private key. +func KeyPath(configDir string) string { + return filepath.Join(configDir, tlsDir, keyFile) +} + +// CertsExist checks if both cert and key files exist. +func CertsExist(configDir string) bool { + _, certErr := os.Stat(CertPath(configDir)) + _, keyErr := os.Stat(KeyPath(configDir)) + return certErr == nil && keyErr == nil +} + +// MkcertAvailable checks if the mkcert binary exists in binDir or PATH. +func MkcertAvailable(binDir string) bool { + // Check binDir first + if _, err := os.Stat(filepath.Join(binDir, "mkcert")); err == nil { + return true + } + // Fall back to PATH + _, err := exec.LookPath("mkcert") + return err == nil +} + +// mkcertPath returns the path to the mkcert binary, preferring binDir. +func mkcertPath(binDir string) string { + p := filepath.Join(binDir, "mkcert") + if _, err := os.Stat(p); err == nil { + return p + } + if path, err := exec.LookPath("mkcert"); err == nil { + return path + } + return "mkcert" +} + +// mkcertEnv returns the current environment with JAVA_HOME cleared. +// mkcert checks all trust stores including Java keytool, which can fail +// if the Java keystore is missing or corrupted. Since we only need browser +// trust (OS keychain), we skip the Java trust store entirely. +func mkcertEnv() []string { + var env []string + for _, e := range os.Environ() { + if !strings.HasPrefix(e, "JAVA_HOME=") { + env = append(env, e) + } + } + return env +} + +// GenerateCerts generates a wildcard TLS certificate for *.obol.stack using mkcert. +// It installs the mkcert CA into the system trust store and creates a certificate +// covering wildcard subdomains, the bare domain, localhost, and loopback addresses. +func GenerateCerts(binDir, configDir string) error { + mkcert := mkcertPath(binDir) + env := mkcertEnv() + + // Create TLS directory + dir := CertDir(configDir) + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("failed to create TLS directory: %w", err) + } + + // Install the local CA into the system trust store. + // On macOS this adds to the login keychain; on Linux it updates ca-certificates. + installCmd := exec.Command(mkcert, "-install") + installCmd.Env = env + installCmd.Stdout = os.Stdout + installCmd.Stderr = os.Stderr + if err := installCmd.Run(); err != nil { + return fmt.Errorf("mkcert -install failed: %w", err) + } + + // Generate wildcard certificate. + // SANs: *.obol.stack (wildcard subdomains), obol.stack (bare domain), + // localhost + loopback (fallback). + certPath := CertPath(configDir) + keyPath := KeyPath(configDir) + genCmd := exec.Command(mkcert, + "-cert-file", certPath, + "-key-file", keyPath, + "*.obol.stack", + "obol.stack", + "localhost", + "127.0.0.1", + "::1", + ) + genCmd.Env = env + genCmd.Stdout = os.Stdout + genCmd.Stderr = os.Stderr + if err := genCmd.Run(); err != nil { + return fmt.Errorf("mkcert cert generation failed: %w", err) + } + + return nil +} + +// EnsureK8sSecret creates or updates the TLS Secret in the traefik namespace. +// Uses kubectl with --dry-run=client piped to apply for idempotent creation. +func EnsureK8sSecret(binDir, configDir, kubeconfigPath string) error { + kubectl := filepath.Join(binDir, "kubectl") + certPath := CertPath(configDir) + keyPath := KeyPath(configDir) + + // Verify cert files exist + if !CertsExist(configDir) { + return fmt.Errorf("TLS certificate files not found at %s", CertDir(configDir)) + } + + // kubectl create secret tls obol-stack-tls \ + // --cert= --key= -n traefik \ + // --dry-run=client -o yaml | kubectl apply -f - + createCmd := exec.Command(kubectl, + "create", "secret", "tls", k8sSecretName, + "--cert="+certPath, + "--key="+keyPath, + "-n", k8sNamespace, + "--dry-run=client", + "-o", "yaml", + ) + createCmd.Env = append(os.Environ(), "KUBECONFIG="+kubeconfigPath) + + applyCmd := exec.Command(kubectl, + "apply", "-f", "-", + ) + applyCmd.Env = append(os.Environ(), "KUBECONFIG="+kubeconfigPath) + applyCmd.Stderr = os.Stderr + + // Pipe create output to apply stdin + pipe, err := createCmd.StdoutPipe() + if err != nil { + return fmt.Errorf("failed to create pipe: %w", err) + } + applyCmd.Stdin = pipe + + if err := createCmd.Start(); err != nil { + return fmt.Errorf("kubectl create secret failed to start: %w", err) + } + if err := applyCmd.Start(); err != nil { + return fmt.Errorf("kubectl apply failed to start: %w", err) + } + + if err := createCmd.Wait(); err != nil { + return fmt.Errorf("kubectl create secret failed: %w", err) + } + if err := applyCmd.Wait(); err != nil { + return fmt.Errorf("kubectl apply failed: %w", err) + } + + return nil +} diff --git a/internal/tls/tls_test.go b/internal/tls/tls_test.go new file mode 100644 index 0000000..180a9ab --- /dev/null +++ b/internal/tls/tls_test.go @@ -0,0 +1,106 @@ +package tls + +import ( + "os" + "path/filepath" + "runtime" + "testing" +) + +func TestMkcertAvailableAndGenerateCerts(t *testing.T) { + // Find the project workspace bin directory relative to this test file + _, thisFile, _, _ := runtime.Caller(0) + projectRoot := filepath.Join(filepath.Dir(thisFile), "..", "..") + workspaceBin := filepath.Join(projectRoot, ".workspace", "bin") + + // Try common locations for mkcert + binDirs := []string{ + workspaceBin, + os.Getenv("HOME") + "/.local/bin", + } + + var binDir string + for _, d := range binDirs { + if MkcertAvailable(d) { + binDir = d + break + } + } + + if binDir == "" { + t.Skip("mkcert not available in any known location") + } + + t.Logf("mkcert found in: %s", binDir) + + tmpDir := t.TempDir() + + if err := GenerateCerts(binDir, tmpDir); err != nil { + t.Fatalf("GenerateCerts failed: %v", err) + } + + if !CertsExist(tmpDir) { + t.Fatal("CertsExist returned false after GenerateCerts") + } + + // Verify cert files are non-empty + certData, err := os.ReadFile(CertPath(tmpDir)) + if err != nil { + t.Fatalf("failed to read cert: %v", err) + } + if len(certData) == 0 { + t.Error("cert file is empty") + } + + keyData, err := os.ReadFile(KeyPath(tmpDir)) + if err != nil { + t.Fatalf("failed to read key: %v", err) + } + if len(keyData) == 0 { + t.Error("key file is empty") + } + + t.Logf("cert: %d bytes, key: %d bytes", len(certData), len(keyData)) +} + +func TestCertsExist(t *testing.T) { + tmpDir := t.TempDir() + + // No certs yet + if CertsExist(tmpDir) { + t.Error("CertsExist should return false for empty dir") + } + + // Create the tls dir and cert file only + tlsDir := CertDir(tmpDir) + if err := os.MkdirAll(tlsDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(CertPath(tmpDir), []byte("cert"), 0644); err != nil { + t.Fatal(err) + } + if CertsExist(tmpDir) { + t.Error("CertsExist should return false with only cert file") + } + + // Create key file too + if err := os.WriteFile(KeyPath(tmpDir), []byte("key"), 0644); err != nil { + t.Fatal(err) + } + if !CertsExist(tmpDir) { + t.Error("CertsExist should return true with both cert and key files") + } +} + +func TestPaths(t *testing.T) { + dir := "/test/config" + if got := CertDir(dir); got != "/test/config/tls" { + t.Errorf("CertDir = %q, want /test/config/tls", got) + } + if got := CertPath(dir); got != "/test/config/tls/obol-stack.pem" { + t.Errorf("CertPath = %q, want /test/config/tls/obol-stack.pem", got) + } + if got := KeyPath(dir); got != "/test/config/tls/obol-stack-key.pem" { + t.Errorf("KeyPath = %q, want /test/config/tls/obol-stack-key.pem", got) + } +} diff --git a/obolup.sh b/obolup.sh index 7af0f80..3d0c515 100755 --- a/obolup.sh +++ b/obolup.sh @@ -55,6 +55,7 @@ readonly K3D_VERSION="5.8.3" readonly HELMFILE_VERSION="1.2.3" readonly K9S_VERSION="0.50.18" readonly HELM_DIFF_VERSION="3.14.1" +readonly MKCERT_VERSION="1.4.4" # Repository URL for building from source readonly OBOL_REPO_URL="git@github.com:ObolNetwork/obol-stack.git" @@ -1064,6 +1065,47 @@ WRAPPER return 1 } +# Install mkcert (local CA for TLS certificates) +install_mkcert() { + local platform=$(detect_platform) + local arch=$(detect_arch) + + # Remove broken symlink if exists + remove_broken_symlink "mkcert" + + # Check for global mkcert first + local global_mkcert + if global_mkcert=$(check_global_binary "mkcert"); then + if create_binary_symlink "mkcert" "$global_mkcert"; then + log_success "mkcert already installed at: $global_mkcert (symlinked)" + else + log_success "mkcert already installed at: $global_mkcert" + fi + return 0 + fi + + # Check if already in OBOL_BIN_DIR + if [[ -f "$OBOL_BIN_DIR/mkcert" ]]; then + log_success "mkcert already installed" + return 0 + fi + + log_info "Installing mkcert v$MKCERT_VERSION..." + + # Download mkcert binary + local download_url="https://github.com/FiloSottile/mkcert/releases/download/v${MKCERT_VERSION}/mkcert-v${MKCERT_VERSION}-${platform}-${arch}" + + if curl -sSL "$download_url" -o "$OBOL_BIN_DIR/mkcert.tmp"; then + chmod +x "$OBOL_BIN_DIR/mkcert.tmp" + mv "$OBOL_BIN_DIR/mkcert.tmp" "$OBOL_BIN_DIR/mkcert" + log_success "mkcert v$MKCERT_VERSION installed" + else + log_error "Failed to download mkcert" + rm -f "$OBOL_BIN_DIR/mkcert.tmp" + return 1 + fi +} + # Install all dependencies install_dependencies() { log_info "Checking and installing dependencies..." @@ -1077,6 +1119,7 @@ install_dependencies() { install_k9s || log_warn "k9s installation failed (continuing...)" install_helm_diff || log_warn "helm-diff plugin installation failed (continuing...)" install_openclaw || log_warn "openclaw CLI installation failed (continuing...)" + install_mkcert || log_warn "mkcert installation failed (TLS will be unavailable)" echo "" log_success "Dependencies check complete"