package proxy
import (
"bufio"
"context"
"fmt"
"os/exec"
"regexp"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/standardbeagle/agnt/internal/protocol"
)
// TunnelManager manages a tunnel process alongside a proxy.
type TunnelManager struct {
config *protocol.TunnelConfig
proxyPort int
publicURL atomic.Value // stores string
running atomic.Bool
cmd *exec.Cmd
cancel context.CancelFunc
mu sync.Mutex
output []string // recent output lines for debugging
outputMu sync.RWMutex
}
// Common patterns to extract public URLs from tunnel output
var tunnelURLPatterns = []*regexp.Regexp{
// ngrok patterns
regexp.MustCompile(`Forwarding\s+(https?://[^\s]+)\s+->`),
regexp.MustCompile(`url=(https?://[^\s]+)`),
regexp.MustCompile(`https?://[a-zA-Z0-9-]+\.ngrok(?:-free)?\.(?:app|io)[^\s]*`),
// cloudflared patterns
regexp.MustCompile(`https?://[a-zA-Z0-9-]+\.trycloudflare\.com[^\s]*`),
regexp.MustCompile(`\|\s+(https?://[^\s|]+)`),
// tailscale funnel patterns (https://machine.tailnet.ts.net)
regexp.MustCompile(`(https://[a-zA-Z0-9-]+\.[a-zA-Z0-9-]+\.ts\.net[^\s]*)`),
// localtunnel patterns
regexp.MustCompile(`your url is:\s*(https?://[^\s]+)`),
// Generic https URL pattern (fallback)
regexp.MustCompile(`(https://[a-zA-Z0-9][a-zA-Z0-9-]*\.[a-zA-Z0-9.-]+[^\s]*)`),
}
// NewTunnelManager creates a new tunnel manager.
func NewTunnelManager(config *protocol.TunnelConfig, proxyPort int) *TunnelManager {
tm := &TunnelManager{
config: config,
proxyPort: proxyPort,
output: make([]string, 0, 100),
}
tm.publicURL.Store("")
return tm
}
// Start starts the tunnel process.
func (tm *TunnelManager) Start(ctx context.Context) error {
tm.mu.Lock()
defer tm.mu.Unlock()
if tm.running.Load() {
return fmt.Errorf("tunnel already running")
}
// Build the command based on provider
cmdName, args, err := tm.buildCommand()
if err != nil {
return err
}
// Create cancellable context
tunnelCtx, cancel := context.WithCancel(ctx)
tm.cancel = cancel
// Create command
tm.cmd = exec.CommandContext(tunnelCtx, cmdName, args...)
// Capture stdout and stderr
stdout, err := tm.cmd.StdoutPipe()
if err != nil {
cancel()
return fmt.Errorf("failed to create stdout pipe: %w", err)
}
stderr, err := tm.cmd.StderrPipe()
if err != nil {
cancel()
return fmt.Errorf("failed to create stderr pipe: %w", err)
}
// Start the process
if err := tm.cmd.Start(); err != nil {
cancel()
return fmt.Errorf("failed to start tunnel: %w", err)
}
tm.running.Store(true)
// Monitor output for URL extraction
go tm.monitorOutput(bufio.NewScanner(stdout))
go tm.monitorOutput(bufio.NewScanner(stderr))
// Wait for process to exit
go func() {
tm.cmd.Wait()
tm.running.Store(false)
}()
return nil
}
// buildCommand builds the tunnel command based on provider.
func (tm *TunnelManager) buildCommand() (string, []string, error) {
switch strings.ToLower(tm.config.Provider) {
case "ngrok":
return tm.buildNgrokCommand()
case "cloudflared", "cloudflare":
return tm.buildCloudflaredCommand()
case "tailscale":
return tm.buildTailscaleCommand()
case "custom":
return tm.buildCustomCommand()
default:
return "", nil, fmt.Errorf("unknown tunnel provider: %s", tm.config.Provider)
}
}
func (tm *TunnelManager) buildNgrokCommand() (string, []string, error) {
args := []string{"http", fmt.Sprintf("%d", tm.proxyPort)}
if tm.config.AuthToken != "" {
args = append(args, "--authtoken", tm.config.AuthToken)
}
if tm.config.Region != "" {
args = append(args, "--region", tm.config.Region)
}
// Add any extra args
args = append(args, tm.config.Args...)
return "ngrok", args, nil
}
func (tm *TunnelManager) buildCloudflaredCommand() (string, []string, error) {
args := []string{"tunnel", "--url", fmt.Sprintf("http://localhost:%d", tm.proxyPort)}
// Add any extra args
args = append(args, tm.config.Args...)
return "cloudflared", args, nil
}
func (tm *TunnelManager) buildTailscaleCommand() (string, []string, error) {
// tailscale funnel exposes a local port via Tailscale Funnel
// Usage: tailscale funnel <port>
// Or: tailscale funnel --bg <port> (background mode, but we manage the process)
//
// The funnel URL is typically: https://<machine>.<tailnet>.ts.net
// Tailscale will output the URL when funnel starts
args := []string{"funnel", fmt.Sprintf("%d", tm.proxyPort)}
// Add any extra args (e.g., --bg for background, --https for custom port)
args = append(args, tm.config.Args...)
return "tailscale", args, nil
}
func (tm *TunnelManager) buildCustomCommand() (string, []string, error) {
if tm.config.Command == "" {
return "", nil, fmt.Errorf("custom tunnel requires command to be set")
}
// Replace {{PORT}} placeholder in command and args
cmd := strings.ReplaceAll(tm.config.Command, "{{PORT}}", fmt.Sprintf("%d", tm.proxyPort))
args := make([]string, len(tm.config.Args))
for i, arg := range tm.config.Args {
args[i] = strings.ReplaceAll(arg, "{{PORT}}", fmt.Sprintf("%d", tm.proxyPort))
}
return cmd, args, nil
}
// monitorOutput monitors tunnel output for URL extraction.
func (tm *TunnelManager) monitorOutput(scanner *bufio.Scanner) {
for scanner.Scan() {
line := scanner.Text()
// Store recent output for debugging
tm.outputMu.Lock()
tm.output = append(tm.output, line)
if len(tm.output) > 100 {
tm.output = tm.output[1:]
}
tm.outputMu.Unlock()
// Try to extract URL if we don't have one yet
if tm.publicURL.Load().(string) == "" {
if url := tm.extractURL(line); url != "" {
tm.publicURL.Store(url)
}
}
}
}
// extractURL tries to extract a public URL from a line of output.
func (tm *TunnelManager) extractURL(line string) string {
for _, pattern := range tunnelURLPatterns {
if matches := pattern.FindStringSubmatch(line); len(matches) > 0 {
// Return the captured group if present, otherwise the full match
if len(matches) > 1 {
return matches[1]
}
return matches[0]
}
}
return ""
}
// Stop stops the tunnel process.
func (tm *TunnelManager) Stop() error {
tm.mu.Lock()
defer tm.mu.Unlock()
if !tm.running.Load() {
return nil
}
if tm.cancel != nil {
tm.cancel()
}
// Give it a moment to exit gracefully
done := make(chan struct{})
go func() {
if tm.cmd != nil && tm.cmd.Process != nil {
tm.cmd.Wait()
}
close(done)
}()
select {
case <-done:
case <-time.After(5 * time.Second):
// Force kill if still running
if tm.cmd != nil && tm.cmd.Process != nil {
tm.cmd.Process.Kill()
}
}
tm.running.Store(false)
return nil
}
// PublicURL returns the detected public URL.
func (tm *TunnelManager) PublicURL() string {
return tm.publicURL.Load().(string)
}
// IsRunning returns whether the tunnel is running.
func (tm *TunnelManager) IsRunning() bool {
return tm.running.Load()
}
// RecentOutput returns recent output lines for debugging.
func (tm *TunnelManager) RecentOutput() []string {
tm.outputMu.RLock()
defer tm.outputMu.RUnlock()
result := make([]string, len(tm.output))
copy(result, tm.output)
return result
}
// WaitForURL waits for the public URL to be detected with a timeout.
func (tm *TunnelManager) WaitForURL(timeout time.Duration) (string, error) {
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
if url := tm.PublicURL(); url != "" {
return url, nil
}
if !tm.running.Load() {
return "", fmt.Errorf("tunnel process exited before URL was detected")
}
time.Sleep(100 * time.Millisecond)
}
return "", fmt.Errorf("timeout waiting for tunnel URL")
}