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
43 changes: 43 additions & 0 deletions cmd/obol/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"github.com/ObolNetwork/obol-stack/internal/app"
"github.com/ObolNetwork/obol-stack/internal/config"
"github.com/ObolNetwork/obol-stack/internal/stack"
"github.com/ObolNetwork/obol-stack/internal/tunnel"
"github.com/ObolNetwork/obol-stack/internal/version"
"github.com/urfave/cli/v2"
)
Expand Down Expand Up @@ -57,6 +58,11 @@ COMMANDS:
app sync Deploy application to cluster
app delete Remove application and cluster resources

Tunnel Management:
tunnel status Show tunnel status and public URL
tunnel restart Restart tunnel to get a new URL
tunnel logs View cloudflared logs

Kubernetes Tools (with auto-configured KUBECONFIG):
kubectl Run kubectl with stack kubeconfig (passthrough)
helm Run helm with stack kubeconfig (passthrough)
Expand Down Expand Up @@ -157,6 +163,43 @@ GLOBAL OPTIONS:
},
},
// ============================================================
// Tunnel Management Commands
// ============================================================
{
Name: "tunnel",
Usage: "Manage Cloudflare tunnel for public access",
Subcommands: []*cli.Command{
{
Name: "status",
Usage: "Show tunnel status and public URL",
Action: func(c *cli.Context) error {
return tunnel.Status(cfg)
},
},
{
Name: "restart",
Usage: "Restart the tunnel to get a new URL",
Action: func(c *cli.Context) error {
return tunnel.Restart(cfg)
},
},
{
Name: "logs",
Usage: "View cloudflared logs",
Flags: []cli.Flag{
&cli.BoolFlag{
Name: "follow",
Aliases: []string{"f"},
Usage: "Follow log output",
},
},
Action: func(c *cli.Context) error {
return tunnel.Logs(cfg, c.Bool("follow"))
},
},
},
},
// ============================================================
// Kubernetes Tool Passthroughs (with auto-configured KUBECONFIG)
// ============================================================
{
Expand Down
6 changes: 6 additions & 0 deletions internal/embed/infrastructure/cloudflared/Chart.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
apiVersion: v2
name: cloudflared
description: Cloudflare Tunnel for public access
type: application
version: 0.1.0
appVersion: "2024.12.2"
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: cloudflared
labels:
app.kubernetes.io/name: cloudflared
app.kubernetes.io/part-of: obol-stack
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: cloudflared
template:
metadata:
labels:
app.kubernetes.io/name: cloudflared
spec:
containers:
- name: cloudflared
image: cloudflare/cloudflared:2024.12.2
args:
- tunnel
- --no-autoupdate
- --metrics
- 0.0.0.0:2000
- --url
- http://traefik.traefik.svc.cluster.local:80
ports:
- name: metrics
containerPort: 2000
livenessProbe:
httpGet:
path: /ready
port: metrics
initialDelaySeconds: 10
periodSeconds: 10
resources:
requests:
cpu: 10m
memory: 64Mi
limits:
cpu: 100m
memory: 128Mi
restartPolicy: Always
7 changes: 7 additions & 0 deletions internal/embed/infrastructure/helmfile.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,13 @@ releases:
dashboard:
enabled: false

# Cloudflare Tunnel (quick tunnel mode for public access)
- name: cloudflared
namespace: traefik
chart: ./cloudflared
needs:
- traefik/traefik

# eRPC
- name: erpc
namespace: erpc
Expand Down
177 changes: 177 additions & 0 deletions internal/tunnel/tunnel.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
package tunnel

import (
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"time"

"github.com/ObolNetwork/obol-stack/internal/config"
)

const (
tunnelNamespace = "traefik"
tunnelLabelSelector = "app.kubernetes.io/name=cloudflared"
)

// Status displays the current tunnel status and URL
func Status(cfg *config.Config) error {
kubectlPath := filepath.Join(cfg.BinDir, "kubectl")
kubeconfigPath := filepath.Join(cfg.ConfigDir, "kubeconfig.yaml")

// Check if kubeconfig exists
if _, err := os.Stat(kubeconfigPath); os.IsNotExist(err) {
return fmt.Errorf("stack not running, use 'obol stack up' first")
}

// Check pod status first
podStatus, err := getPodStatus(kubectlPath, kubeconfigPath)
if err != nil {
printStatusBox("quick", "not deployed", "", time.Now())
fmt.Println("\nTroubleshooting:")
fmt.Println(" - Start the stack: obol stack up")
return nil
}

// Try to get tunnel URL from logs
url, err := GetTunnelURL(cfg)
if err != nil {
printStatusBox("quick", podStatus, "(not available)", time.Now())
fmt.Println("\nTroubleshooting:")
fmt.Println(" - Check logs: obol tunnel logs")
fmt.Println(" - Restart tunnel: obol tunnel restart")
return nil
}

printStatusBox("quick", "active", url, time.Now())
fmt.Printf("\nTest with: curl %s/\n", url)

return nil
}

// GetTunnelURL parses cloudflared logs to extract the quick tunnel URL
func GetTunnelURL(cfg *config.Config) (string, error) {
kubectlPath := filepath.Join(cfg.BinDir, "kubectl")
kubeconfigPath := filepath.Join(cfg.ConfigDir, "kubeconfig.yaml")

cmd := exec.Command(kubectlPath,
"--kubeconfig", kubeconfigPath,
"logs", "-n", tunnelNamespace,
"-l", tunnelLabelSelector,
"--tail=100",
)

output, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("failed to get tunnel logs: %w", err)
}

// Parse URL from logs (quick tunnel uses cfargotunnel.com)
re := regexp.MustCompile(`https://[a-z0-9-]+\.cfargotunnel\.com`)
matches := re.FindString(string(output))
if matches == "" {
// Also try trycloudflare.com as fallback
re = regexp.MustCompile(`https://[a-z0-9-]+\.trycloudflare\.com`)
matches = re.FindString(string(output))
}
if matches == "" {
return "", fmt.Errorf("tunnel URL not found in logs")
}

return matches, nil
}

// Restart restarts the cloudflared deployment to get a new tunnel URL
func Restart(cfg *config.Config) error {
kubectlPath := filepath.Join(cfg.BinDir, "kubectl")
kubeconfigPath := filepath.Join(cfg.ConfigDir, "kubeconfig.yaml")

// Check if kubeconfig exists
if _, err := os.Stat(kubeconfigPath); os.IsNotExist(err) {
return fmt.Errorf("stack not running, use 'obol stack up' first")
}

fmt.Println("Restarting cloudflared tunnel...")

cmd := exec.Command(kubectlPath,
"--kubeconfig", kubeconfigPath,
"rollout", "restart", "deployment/cloudflared",
"-n", tunnelNamespace,
)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr

if err := cmd.Run(); err != nil {
return fmt.Errorf("failed to restart tunnel: %w", err)
}

fmt.Println("\nTunnel restarting...")
fmt.Println("Run 'obol tunnel status' to see the new URL once ready (may take 10-30 seconds).")

return nil
}

// Logs displays cloudflared logs
func Logs(cfg *config.Config, follow bool) error {
kubectlPath := filepath.Join(cfg.BinDir, "kubectl")
kubeconfigPath := filepath.Join(cfg.ConfigDir, "kubeconfig.yaml")

// Check if kubeconfig exists
if _, err := os.Stat(kubeconfigPath); os.IsNotExist(err) {
return fmt.Errorf("stack not running, use 'obol stack up' first")
}

args := []string{
"--kubeconfig", kubeconfigPath,
"logs", "-n", tunnelNamespace,
"-l", tunnelLabelSelector,
}

if follow {
args = append(args, "-f")
}

cmd := exec.Command(kubectlPath, args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Stdin = os.Stdin

return cmd.Run()
}

// getPodStatus returns the status of the cloudflared pod
func getPodStatus(kubectlPath, kubeconfigPath string) (string, error) {
cmd := exec.Command(kubectlPath,
"--kubeconfig", kubeconfigPath,
"get", "pods", "-n", tunnelNamespace,
"-l", tunnelLabelSelector,
"-o", "jsonpath={.items[0].status.phase}",
)

output, err := cmd.Output()
if err != nil {
return "", err
}

status := strings.TrimSpace(string(output))
if status == "" {
return "", fmt.Errorf("no pods found")
}

return strings.ToLower(status), nil
}

// printStatusBox prints a formatted status box
func printStatusBox(mode, status, url string, lastUpdated time.Time) {
fmt.Println()
fmt.Println("Cloudflare Tunnel Status")
fmt.Println(strings.Repeat("─", 50))
fmt.Printf("Mode: %s\n", mode)
fmt.Printf("Status: %s\n", status)
fmt.Printf("URL: %s\n", url)
fmt.Printf("Last Updated: %s\n", lastUpdated.Format(time.RFC3339))
fmt.Println(strings.Repeat("─", 50))
}