Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion internal/openclaw/import.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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"`
Expand Down Expand Up @@ -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
Expand Down
38 changes: 33 additions & 5 deletions internal/openclaw/openclaw.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"bufio"
"bytes"
"context"
"crypto/rand"
"embed"
"encoding/base64"
"encoding/json"
Expand All @@ -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"
)

Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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++
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand All @@ -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 != "" {
Expand All @@ -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,
Expand All @@ -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

`)
}
Expand Down
2 changes: 1 addition & 1 deletion internal/openclaw/overlay_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down
83 changes: 83 additions & 0 deletions internal/stack/stack.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
93 changes: 93 additions & 0 deletions internal/stack/stack_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
Loading