// 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"), } }