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.
This commit is contained in:
@@ -0,0 +1,260 @@
|
||||
// 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"),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user