Files
TZGyn ebae420ab1 Add the native Go client and finish M3 hardening
The new cmd/tunnel client keeps the API URL and tunnel identity in
~/.config/tunnel/config.json (0600), provisions the key/CSR and
certificate, terminates TLS, proxies to local routes, reloads routes
without a restart, and runs as a background service. `tunnel import`
takes over an existing official CLI profile, preserving its tunnel ID,
token, key and certificate.

Server hardening: per-IP and per-tunnel rate limits for creates and
binds, a CA cool-down after repeated ACME failures, a startup sweep for
stale _acme-challenge records, admin metrics, a 60-second half-close
timeout, and separate prioritized control / ordered data bridge queues
(with `end` ordered after the final bytes). The dashboard shows the new
metrics.
2026-10-26 00:32:31 +08:00

261 lines
6.7 KiB
Go

// Package client implements the OpenTunnel client side: provisioning a
// tunnel, fetching and renewing its certificate, and carrying visitor
// connections over the bridge.
package client
import (
"encoding/json"
"errors"
"fmt"
"net"
"net/url"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
)
// Route is one local target for a tunnel route.
type Route struct {
Target string `json:"target"`
ProxyProtocol string `json:"proxy_protocol,omitempty"` // "", "v1", or "v2"
}
// Config is the single JSON file the CLI owns. It holds the API setting so
// commands no longer need OPENTUNNEL_API. The file is 0600 because it also
// holds the tunnel token.
type Config struct {
API string `json:"api"`
TunnelID string `json:"tunnel_id,omitempty"`
Hostname string `json:"hostname,omitempty"`
Token string `json:"token,omitempty"`
CertificateExpiry string `json:"certificate_expiry,omitempty"`
Routes map[string]Route `json:"routes,omitempty"`
}
// DefaultPath is ~/.config/tunnel/config.json (XDG_CONFIG_HOME is honored).
func DefaultPath() string {
if dir := os.Getenv("XDG_CONFIG_HOME"); dir != "" {
return filepath.Join(dir, "tunnel", "config.json")
}
home, err := os.UserHomeDir()
if err != nil {
return "config.json"
}
return filepath.Join(home, ".config", "tunnel", "config.json")
}
// Load reads the config, returning an empty one when the file does not exist.
func Load(path string) (*Config, error) {
cfg := &Config{Routes: map[string]Route{}}
data, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
return cfg, nil
}
if err != nil {
return nil, err
}
if err := json.Unmarshal(data, cfg); err != nil {
return nil, fmt.Errorf("config %s: %w", path, err)
}
if cfg.Routes == nil {
cfg.Routes = map[string]Route{}
}
if err := cfg.Normalize(); err != nil {
return nil, fmt.Errorf("config %s: %w", path, err)
}
return cfg, nil
}
// Save writes the config atomically as 0600.
func (c *Config) Save(path string) error {
if err := c.Normalize(); err != nil {
return err
}
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o700); err != nil {
return err
}
if err := os.Chmod(dir, 0o700); err != nil {
return err
}
data, err := json.MarshalIndent(c, "", " ")
if err != nil {
return err
}
data = append(data, '\n')
tmp, err := os.CreateTemp(dir, ".config-*.tmp")
if err != nil {
return err
}
name := tmp.Name()
defer os.Remove(name)
if err := tmp.Chmod(0o600); err != nil {
tmp.Close()
return err
}
if _, err := tmp.Write(data); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
return os.Rename(name, path)
}
// Normalize trims and validates the API and every route.
func (c *Config) Normalize() error {
if c.API != "" {
api, err := NormalizeAPI(c.API)
if err != nil {
return err
}
c.API = api
}
if c.Routes == nil {
c.Routes = map[string]Route{}
}
for name, route := range c.Routes {
if !ValidRouteName(name) {
return fmt.Errorf("invalid route name %q", name)
}
target, err := NormalizeTarget(route.Target)
if err != nil {
return fmt.Errorf("route %q: %w", name, err)
}
route.Target = target
switch route.ProxyProtocol {
case "", "v1", "v2":
default:
return fmt.Errorf("route %q: invalid proxy_protocol %q", name, route.ProxyProtocol)
}
c.Routes[name] = route
}
return nil
}
// NormalizeAPI accepts URL, host, or host:port and returns a scheme-qualified
// URL without a trailing slash.
func NormalizeAPI(raw string) (string, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", errors.New("api is required")
}
if !strings.Contains(raw, "://") {
raw = "https://" + raw
}
u, err := url.Parse(raw)
if err != nil || u.Host == "" {
return "", fmt.Errorf("invalid api URL %q", raw)
}
if u.Scheme != "http" && u.Scheme != "https" {
return "", fmt.Errorf("api scheme must be http or https, got %q", u.Scheme)
}
u.Path = strings.TrimRight(u.Path, "/")
u.Fragment = ""
u.RawQuery = ""
return u.String(), nil
}
var routeNameRE = regexp.MustCompile(`^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$`)
// ValidRouteName reports whether name is "@" or one lowercase DNS label.
func ValidRouteName(name string) bool {
return name == "@" || routeNameRE.MatchString(name)
}
// NormalizeTarget turns a bare port into 127.0.0.1:port and validates the
// resulting host:port.
func NormalizeTarget(raw string) (string, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", errors.New("target is required")
}
if _, err := strconv.Atoi(raw); err == nil {
raw = "127.0.0.1:" + raw
} else if strings.HasPrefix(raw, ":") {
raw = "127.0.0.1" + raw
}
host, port, err := net.SplitHostPort(raw)
if err != nil {
return "", fmt.Errorf("target %q is not host:port or a port", raw)
}
if host == "" {
host = "127.0.0.1"
}
if n, err := strconv.Atoi(port); err != nil || n < 1 || n > 65535 {
return "", fmt.Errorf("target %q has an invalid port", raw)
}
return net.JoinHostPort(host, port), nil
}
// AddRoute validates and stores a route. proxy may be empty, "v1", or "v2".
func (c *Config) AddRoute(name, target, proxy string) error {
if !ValidRouteName(name) {
return fmt.Errorf("invalid route name %q (use @ or one lowercase DNS label)", name)
}
normalized, err := NormalizeTarget(target)
if err != nil {
return err
}
switch proxy {
case "", "v1", "v2":
default:
return fmt.Errorf("invalid proxy protocol %q", proxy)
}
if c.Routes == nil {
c.Routes = map[string]Route{}
}
c.Routes[name] = Route{Target: normalized, ProxyProtocol: proxy}
return nil
}
// RemoveRoute deletes a route, reporting whether it existed.
func (c *Config) RemoveRoute(name string) bool {
if _, ok := c.Routes[name]; !ok {
return false
}
delete(c.Routes, name)
return true
}
// RouteURL is the public URL for a route on this tunnel.
func (c *Config) RouteURL(name string) string {
if c.Hostname == "" {
return ""
}
if name == "@" {
return "https://" + c.Hostname
}
return "https://" + name + "." + c.Hostname
}
// Paths are the files stored next to the config.
type Paths struct {
Config string
Dir string
Key string
CSR string
Cert string
Chain string
PID string
Log string
}
// ConfigPaths returns the standard file layout for a config path.
func ConfigPaths(configPath string) Paths {
dir := filepath.Dir(configPath)
return Paths{
Config: configPath,
Dir: dir,
Key: filepath.Join(dir, "private-key.pem"),
CSR: filepath.Join(dir, "csr.pem"),
Cert: filepath.Join(dir, "certificate.pem"),
Chain: filepath.Join(dir, "chain.pem"),
PID: filepath.Join(dir, "tunnel.pid"),
Log: filepath.Join(dir, "tunnel.log"),
}
}