f99010fae1
Desktop Artifacts / Desktop Build (Linux) (push) Waiting to run
Desktop Artifacts / Desktop Build (Windows) (push) Waiting to run
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Waiting to run
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Waiting to run
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Waiting to run
CI / lint (push) Failing after 1s
CI / frontend (push) Failing after 1s
CI / scripts (push) Failing after 1s
CI / Go Test (ubuntu-latest) (push) Failing after 0s
CI / frontend-node-25 (push) Failing after 1s
CI / docs (push) Failing after 0s
CI / coverage (push) Failing after 0s
CI / e2e (push) Failing after 0s
Docker / build-and-push (push) Failing after 1s
CI / integration (push) Failing after 4m43s
CI / Go Test (windows-latest) (push) Has been cancelled
CI / Desktop Unit Tests (Windows) (push) Has been cancelled
561 lines
12 KiB
Go
561 lines
12 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log"
|
|
"net"
|
|
"net/url"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"go.kenn.io/agentsview/internal/config"
|
|
)
|
|
|
|
const managedCaddyStartGrace = 300 * time.Millisecond
|
|
|
|
type managedCaddy struct {
|
|
cancel context.CancelFunc
|
|
errCh chan error
|
|
guard caddyGuard
|
|
pid int
|
|
}
|
|
|
|
// Pid returns the managed Caddy process id, or 0 when no Caddy is running.
|
|
// `serve stop` records it so it can terminate an orphaned Caddy if the server
|
|
// is force-killed before it can stop Caddy itself.
|
|
func (m *managedCaddy) Pid() int {
|
|
if m == nil {
|
|
return 0
|
|
}
|
|
return m.pid
|
|
}
|
|
|
|
// caddyGuard ties the managed Caddy child to the server's lifetime. On Windows
|
|
// it holds a job-object handle whose closure -- when the server exits for any
|
|
// reason, including the uncatchable kill `serve stop` issues there -- tears
|
|
// down Caddy with it. On other platforms it is a no-op: `serve stop` shuts the
|
|
// server down with SIGTERM, so the server's own cleanup stops Caddy.
|
|
type caddyGuard interface{ Close() error }
|
|
|
|
type noopCaddyGuard struct{}
|
|
|
|
func (noopCaddyGuard) Close() error { return nil }
|
|
|
|
func browserURL(cfg config.Config) string {
|
|
return browserURLWithPlatform(cfg, runningInWSL, interfaceIPv4)
|
|
}
|
|
|
|
func browserURLWithPlatform(
|
|
cfg config.Config,
|
|
isWSL func() bool,
|
|
ifaceIPv4 func(string) (string, bool),
|
|
) string {
|
|
if cfg.PublicURL != "" {
|
|
return cfg.PublicURL
|
|
}
|
|
host := cfg.Host
|
|
if host == "0.0.0.0" || host == "::" {
|
|
if isWSL != nil && isWSL() {
|
|
if ip, ok := ifaceIPv4("eth0"); ok {
|
|
host = ip
|
|
} else {
|
|
host = "127.0.0.1"
|
|
}
|
|
} else {
|
|
host = "127.0.0.1"
|
|
}
|
|
}
|
|
return fmt.Sprintf("http://%s:%d", host, cfg.Port)
|
|
}
|
|
|
|
func runningInWSL() bool {
|
|
if os.Getenv("WSL_DISTRO_NAME") != "" {
|
|
return true
|
|
}
|
|
if _, err := os.Stat("/proc/sys/fs/binfmt_misc/WSLInterop"); err == nil {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func interfaceIPv4(name string) (string, bool) {
|
|
iface, err := net.InterfaceByName(name)
|
|
if err != nil || iface == nil {
|
|
return "", false
|
|
}
|
|
addrs, err := iface.Addrs()
|
|
if err != nil {
|
|
return "", false
|
|
}
|
|
for _, addr := range addrs {
|
|
var ip net.IP
|
|
switch v := addr.(type) {
|
|
case *net.IPNet:
|
|
ip = v.IP
|
|
case *net.IPAddr:
|
|
ip = v.IP
|
|
default:
|
|
continue
|
|
}
|
|
if ip == nil || ip.IsLoopback() {
|
|
continue
|
|
}
|
|
if v4 := ip.To4(); v4 != nil {
|
|
return v4.String(), true
|
|
}
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
func rewriteConfiguredPublicURLPort(
|
|
publicURL string,
|
|
publicOrigins []string,
|
|
fromPort int,
|
|
toPort int,
|
|
) (string, []string, bool, error) {
|
|
if publicURL == "" || fromPort == toPort {
|
|
return publicURL, publicOrigins, false, nil
|
|
}
|
|
u, err := url.Parse(publicURL)
|
|
if err != nil {
|
|
return publicURL, publicOrigins, false, err
|
|
}
|
|
if u == nil || u.Host == "" {
|
|
return publicURL, publicOrigins, false, fmt.Errorf(
|
|
"%q must include a host", publicURL,
|
|
)
|
|
}
|
|
|
|
shouldRewrite := false
|
|
if port := u.Port(); port != "" {
|
|
explicitPort, err := strconv.Atoi(port)
|
|
if err != nil {
|
|
return publicURL, publicOrigins, false, err
|
|
}
|
|
shouldRewrite = explicitPort == fromPort
|
|
} else {
|
|
shouldRewrite = defaultSchemePort(u.Scheme) == fromPort
|
|
}
|
|
if !shouldRewrite {
|
|
return publicURL, publicOrigins, false, nil
|
|
}
|
|
|
|
updatedURL := withURLPort(u, toPort)
|
|
updatedOrigins := make([]string, 0, len(publicOrigins))
|
|
replaced := false
|
|
for _, origin := range publicOrigins {
|
|
if origin == publicURL {
|
|
updatedOrigins = append(updatedOrigins, updatedURL)
|
|
replaced = true
|
|
continue
|
|
}
|
|
updatedOrigins = append(updatedOrigins, origin)
|
|
}
|
|
if !replaced {
|
|
updatedOrigins = append(updatedOrigins, updatedURL)
|
|
}
|
|
return updatedURL, updatedOrigins, true, nil
|
|
}
|
|
|
|
func validateServeConfig(cfg config.Config) error {
|
|
// A persistent non-loopback bind from config.toml exposes the
|
|
// API on every restart, so it must not silently ship without
|
|
// authentication. An explicit --host flag stays exempt: it is
|
|
// a deliberate, per-invocation choice and existing behavior.
|
|
if !cfg.HostExplicit && !isLoopbackHost(cfg.Host) &&
|
|
!cfg.RequireAuth {
|
|
return fmt.Errorf(
|
|
"host = %q in config.toml exposes the API beyond this "+
|
|
"machine; set require_auth = true in config.toml to "+
|
|
"serve it with bearer-token authentication, or use "+
|
|
"the --host flag for a one-off unauthenticated bind",
|
|
cfg.Host,
|
|
)
|
|
}
|
|
if cfg.Proxy.Mode == "" {
|
|
return nil
|
|
}
|
|
if cfg.Proxy.Mode != "caddy" {
|
|
return fmt.Errorf("unsupported proxy mode %q", cfg.Proxy.Mode)
|
|
}
|
|
if cfg.PublicURL == "" {
|
|
return fmt.Errorf("managed caddy requires public_url")
|
|
}
|
|
if !isLoopbackHost(cfg.Host) {
|
|
return fmt.Errorf(
|
|
"managed caddy requires a loopback backend host, got %q",
|
|
cfg.Host,
|
|
)
|
|
}
|
|
bindHost := cfg.Proxy.BindHost
|
|
if strings.TrimSpace(bindHost) == "" {
|
|
bindHost = "127.0.0.1"
|
|
}
|
|
if !isLoopbackHost(bindHost) &&
|
|
len(cfg.Proxy.AllowedSubnets) == 0 {
|
|
return fmt.Errorf(
|
|
"managed caddy non-loopback binds require at least one allowed_subnet",
|
|
)
|
|
}
|
|
if _, err := exec.LookPath(cfg.Proxy.Bin); err != nil {
|
|
return fmt.Errorf(
|
|
"finding caddy binary %q: %w",
|
|
cfg.Proxy.Bin, err,
|
|
)
|
|
}
|
|
|
|
u, err := url.Parse(cfg.PublicURL)
|
|
if err != nil {
|
|
return fmt.Errorf("parsing public url: %w", err)
|
|
}
|
|
if u == nil {
|
|
return fmt.Errorf("parsing public url: invalid URL")
|
|
}
|
|
switch u.Scheme {
|
|
case "https":
|
|
if cfg.Proxy.TLSCert == "" || cfg.Proxy.TLSKey == "" {
|
|
return fmt.Errorf(
|
|
"managed caddy HTTPS mode requires both tls_cert and tls_key",
|
|
)
|
|
}
|
|
if err := requireReadableFile(cfg.Proxy.TLSCert); err != nil {
|
|
return fmt.Errorf("tls_cert: %w", err)
|
|
}
|
|
if err := requireReadableFile(cfg.Proxy.TLSKey); err != nil {
|
|
return fmt.Errorf("tls_key: %w", err)
|
|
}
|
|
case "http":
|
|
if cfg.Proxy.TLSCert != "" || cfg.Proxy.TLSKey != "" {
|
|
return fmt.Errorf(
|
|
"managed caddy HTTP mode must not set tls_cert or tls_key",
|
|
)
|
|
}
|
|
default:
|
|
return fmt.Errorf(
|
|
"managed caddy requires public_url to use http or https",
|
|
)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func isLoopbackHost(host string) bool {
|
|
switch host {
|
|
case "127.0.0.1", "localhost", "::1":
|
|
return true
|
|
}
|
|
ip := net.ParseIP(host)
|
|
return ip != nil && ip.IsLoopback()
|
|
}
|
|
|
|
func requireReadableFile(path string) error {
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if info.IsDir() {
|
|
return fmt.Errorf("%s is a directory", path)
|
|
}
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return f.Close()
|
|
}
|
|
|
|
func managedCaddyConfigPath(dataDir, mode string) string {
|
|
return filepath.Join(dataDir, "managed-caddy", mode, "Caddyfile")
|
|
}
|
|
|
|
func prepareManagedCaddyConfig(
|
|
cfg config.Config,
|
|
mode string,
|
|
backendAddr string,
|
|
) (path string, content string, err error) {
|
|
mode = strings.TrimSpace(mode)
|
|
if mode == "" {
|
|
return "", "", fmt.Errorf("managed caddy mode must not be empty")
|
|
}
|
|
|
|
path = managedCaddyConfigPath(cfg.DataDir, mode)
|
|
content = buildManagedCaddyfile(
|
|
cfg.PublicURL,
|
|
cfg.Proxy.BindHost,
|
|
backendAddr,
|
|
cfg.Proxy.TLSCert,
|
|
cfg.Proxy.TLSKey,
|
|
cfg.Proxy.AllowedSubnets,
|
|
)
|
|
return path, content, nil
|
|
}
|
|
|
|
func startManagedCaddy(
|
|
parent context.Context,
|
|
cfg config.Config,
|
|
mode string,
|
|
) (*managedCaddy, error) {
|
|
configPath, content, err := prepareManagedCaddyConfig(
|
|
cfg,
|
|
mode,
|
|
net.JoinHostPort(cfg.Host, strconv.Itoa(cfg.Port)),
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(configPath), 0o700); err != nil {
|
|
return nil, fmt.Errorf("creating managed caddy dir: %w", err)
|
|
}
|
|
if err := os.WriteFile(configPath, []byte(content), 0o600); err != nil {
|
|
return nil, fmt.Errorf("writing managed caddy config: %w", err)
|
|
}
|
|
|
|
validateCmd := exec.CommandContext(
|
|
parent,
|
|
cfg.Proxy.Bin,
|
|
"validate",
|
|
"--config", configPath,
|
|
"--adapter", "caddyfile",
|
|
)
|
|
if out, err := validateCmd.CombinedOutput(); err != nil {
|
|
msg := strings.TrimSpace(string(out))
|
|
if msg != "" {
|
|
return nil, fmt.Errorf(
|
|
"validating managed caddy config: %w: %s",
|
|
err, msg,
|
|
)
|
|
}
|
|
return nil, fmt.Errorf("validating managed caddy config: %w", err)
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cmd := exec.CommandContext(
|
|
ctx,
|
|
cfg.Proxy.Bin,
|
|
"run",
|
|
"--config", configPath,
|
|
"--adapter", "caddyfile",
|
|
)
|
|
cmd.Stdout = os.Stderr
|
|
cmd.Stderr = os.Stderr
|
|
if err := cmd.Start(); err != nil {
|
|
cancel()
|
|
return nil, fmt.Errorf("starting managed caddy: %w", err)
|
|
}
|
|
|
|
// Bind Caddy's lifetime to this server process so it cannot outlive a
|
|
// `serve stop` that kills the server without a graceful shutdown (Windows).
|
|
// Best-effort: a failure leaves the prior behavior, so log and continue.
|
|
guard, gErr := newCaddyGuard(cmd)
|
|
if gErr != nil {
|
|
log.Printf("warning: could not confine managed caddy: %v", gErr)
|
|
}
|
|
if guard == nil {
|
|
guard = noopCaddyGuard{}
|
|
}
|
|
|
|
errCh := make(chan error, 1)
|
|
go func() {
|
|
errCh <- cmd.Wait()
|
|
}()
|
|
|
|
select {
|
|
case err := <-errCh:
|
|
cancel()
|
|
_ = guard.Close()
|
|
if err == nil {
|
|
return nil, fmt.Errorf("managed caddy exited immediately")
|
|
}
|
|
return nil, fmt.Errorf("managed caddy exited immediately: %w", err)
|
|
case <-time.After(managedCaddyStartGrace):
|
|
case <-parent.Done():
|
|
cancel()
|
|
_ = guard.Close()
|
|
return nil, parent.Err()
|
|
}
|
|
|
|
return &managedCaddy{
|
|
cancel: cancel,
|
|
errCh: errCh,
|
|
guard: guard,
|
|
pid: cmd.Process.Pid,
|
|
}, nil
|
|
}
|
|
|
|
func (m *managedCaddy) Stop() {
|
|
if m == nil {
|
|
return
|
|
}
|
|
if m.cancel != nil {
|
|
m.cancel()
|
|
}
|
|
if m.guard != nil {
|
|
_ = m.guard.Close()
|
|
}
|
|
}
|
|
|
|
func (m *managedCaddy) Err() <-chan error {
|
|
if m == nil {
|
|
return nil
|
|
}
|
|
return m.errCh
|
|
}
|
|
|
|
func buildManagedCaddyfile(
|
|
publicURL string,
|
|
bindHost string,
|
|
backendAddr string,
|
|
tlsCert string,
|
|
tlsKey string,
|
|
allowedSubnets []string,
|
|
) string {
|
|
var b strings.Builder
|
|
b.WriteString("{\n")
|
|
b.WriteString("\tadmin off\n")
|
|
b.WriteString("\tauto_https off\n")
|
|
b.WriteString("}\n\n")
|
|
b.WriteString(publicURL)
|
|
b.WriteString(" {\n")
|
|
if bindHost != "" {
|
|
fmt.Fprintf(&b, "\tbind %s\n", bindHost)
|
|
}
|
|
if len(allowedSubnets) > 0 {
|
|
b.WriteString("\t@blocked not remote_ip")
|
|
for _, subnet := range allowedSubnets {
|
|
b.WriteString(" ")
|
|
b.WriteString(subnet)
|
|
}
|
|
b.WriteString("\n")
|
|
b.WriteString("\trespond @blocked \"Forbidden\" 403\n")
|
|
}
|
|
if tlsCert != "" || tlsKey != "" {
|
|
fmt.Fprintf(
|
|
&b,
|
|
"\ttls %s %s\n",
|
|
strconv.Quote(tlsCert),
|
|
strconv.Quote(tlsKey),
|
|
)
|
|
}
|
|
fmt.Fprintf(&b, "\treverse_proxy %s\n", backendAddr)
|
|
b.WriteString("}\n")
|
|
return b.String()
|
|
}
|
|
|
|
func waitForLocalPort(
|
|
ctx context.Context,
|
|
host string,
|
|
port int,
|
|
timeout time.Duration,
|
|
errCh <-chan error,
|
|
) error {
|
|
deadline := time.Now().Add(timeout)
|
|
address := net.JoinHostPort(readinessProbeHost(host), strconv.Itoa(port))
|
|
var lastErr error
|
|
for time.Now().Before(deadline) {
|
|
if ctx.Err() != nil {
|
|
return ctx.Err()
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case err := <-errCh:
|
|
if ctx.Err() != nil {
|
|
return ctx.Err()
|
|
}
|
|
if err == nil {
|
|
return fmt.Errorf(
|
|
"service exited before becoming ready on %s",
|
|
address,
|
|
)
|
|
}
|
|
return err
|
|
default:
|
|
}
|
|
conn, err := net.DialTimeout("tcp", address, 200*time.Millisecond)
|
|
if err == nil {
|
|
conn.Close()
|
|
return nil
|
|
}
|
|
lastErr = err
|
|
timer := time.NewTimer(50 * time.Millisecond)
|
|
select {
|
|
case <-ctx.Done():
|
|
timer.Stop()
|
|
return ctx.Err()
|
|
case err := <-errCh:
|
|
timer.Stop()
|
|
if ctx.Err() != nil {
|
|
return ctx.Err()
|
|
}
|
|
if err == nil {
|
|
return fmt.Errorf(
|
|
"service exited before becoming ready on %s",
|
|
address,
|
|
)
|
|
}
|
|
return err
|
|
case <-timer.C:
|
|
}
|
|
}
|
|
if lastErr == nil {
|
|
lastErr = fmt.Errorf("timed out waiting for %s", address)
|
|
}
|
|
return lastErr
|
|
}
|
|
|
|
func readinessProbeHost(host string) string {
|
|
switch host {
|
|
case "", "0.0.0.0":
|
|
return "127.0.0.1"
|
|
case "::":
|
|
return "::1"
|
|
default:
|
|
return host
|
|
}
|
|
}
|
|
|
|
func defaultSchemePort(scheme string) int {
|
|
if strings.EqualFold(scheme, "https") {
|
|
return 443
|
|
}
|
|
return 80
|
|
}
|
|
|
|
func withURLPort(u *url.URL, port int) string {
|
|
host := u.Hostname()
|
|
if host == "" {
|
|
return u.String()
|
|
}
|
|
scheme := strings.ToLower(u.Scheme)
|
|
defaultPort := defaultSchemePort(scheme)
|
|
if port == defaultPort {
|
|
return scheme + "://" + hostLiteral(host)
|
|
}
|
|
return scheme + "://" + net.JoinHostPort(host, strconv.Itoa(port))
|
|
}
|
|
|
|
func hostLiteral(host string) string {
|
|
if strings.Contains(host, ":") {
|
|
return "[" + host + "]"
|
|
}
|
|
return host
|
|
}
|
|
|
|
func publicURLPort(publicURL string) (int, error) {
|
|
u, err := url.Parse(publicURL)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
if u == nil {
|
|
return 0, fmt.Errorf("invalid public URL")
|
|
}
|
|
if port := u.Port(); port != "" {
|
|
return strconv.Atoi(port)
|
|
}
|
|
return defaultSchemePort(u.Scheme), nil
|
|
}
|