package client import ( "bufio" "encoding/json" "errors" "fmt" "os" "path/filepath" "strconv" "strings" ) // ImportOptions describes importing an official opentunnel CLI profile. type ImportOptions struct { Profile string API string ConfigPath string Force bool } // ImportResult summarizes a successful import. type ImportResult struct { TunnelID string Hostname string Routes []string ConfigPath string Source string } type officialTunnel struct { ID string `json:"id"` Hostname string `json:"hostname"` CertificateExpiry string `json:"certificateExpiry"` } // Import copies the identity and routes of the official Rust CLI into this // client's JSON config. The tunnel ID, token, private key and certificate are // preserved, so the server sees the same tunnel. func Import(opts ImportOptions) (ImportResult, error) { if opts.Profile == "" { opts.Profile = "default" } if !routeNameRE.MatchString(opts.Profile) { return ImportResult{}, fmt.Errorf("invalid profile name %q", opts.Profile) } if opts.API == "" { return ImportResult{}, errors.New("--api is required") } api, err := NormalizeAPI(opts.API) if err != nil { return ImportResult{}, err } if opts.ConfigPath == "" { opts.ConfigPath = DefaultPath() } if _, err := os.Stat(opts.ConfigPath); err == nil && !opts.Force { return ImportResult{}, fmt.Errorf("%s already exists; use --force to overwrite", opts.ConfigPath) } dataDir := xdgDir("XDG_DATA_HOME", ".local/share", "opentunnel", opts.Profile) configDir := xdgDir("XDG_CONFIG_HOME", ".config", "opentunnel") routesPath := filepath.Join(configDir, opts.Profile+".toml") rawTunnel, err := os.ReadFile(filepath.Join(dataDir, "tunnel.json")) if err != nil { return ImportResult{}, fmt.Errorf("official tunnel.json: %w", err) } var official officialTunnel if err := json.Unmarshal(rawTunnel, &official); err != nil { return ImportResult{}, fmt.Errorf("official tunnel.json: %w", err) } if official.ID == "" || official.Hostname == "" { return ImportResult{}, errors.New("official tunnel.json has no id/hostname") } token, err := os.ReadFile(filepath.Join(dataDir, "token")) if err != nil { return ImportResult{}, fmt.Errorf("official token: %w", err) } keyPEM, err := os.ReadFile(filepath.Join(dataDir, "private-key.pem")) if err != nil { return ImportResult{}, fmt.Errorf("official private key: %w", err) } if _, err := parseKey(keyPEM); err != nil { return ImportResult{}, fmt.Errorf("official private key: %w", err) } certPEM, err := os.ReadFile(filepath.Join(dataDir, "certificate.pem")) if err != nil { return ImportResult{}, fmt.Errorf("official certificate: %w", err) } chainPEM, err := os.ReadFile(filepath.Join(dataDir, "chain.pem")) if err != nil && !errors.Is(err, os.ErrNotExist) { return ImportResult{}, fmt.Errorf("official chain: %w", err) } paths := ConfigPaths(opts.ConfigPath) if err := os.MkdirAll(paths.Dir, 0o700); err != nil { return ImportResult{}, err } if err := writeAtomic(paths.Key, keyPEM, 0o600); err != nil { return ImportResult{}, err } if err := writeAtomic(paths.Cert, certPEM, 0o644); err != nil { return ImportResult{}, err } if err := writeAtomic(paths.Chain, chainPEM, 0o644); err != nil { return ImportResult{}, err } if _, _, err := EnsureIdentity(paths, official.Hostname); err != nil { return ImportResult{}, fmt.Errorf("local CSR: %w", err) } cfg := &Config{ API: api, TunnelID: official.ID, Hostname: official.Hostname, Token: strings.TrimSpace(string(token)), CertificateExpiry: official.CertificateExpiry, Routes: map[string]Route{}, } routes, err := readOfficialRoutes(routesPath) if err != nil { return ImportResult{}, err } for name, route := range routes { if err := cfg.AddRoute(name, route.Target, route.ProxyProtocol); err != nil { return ImportResult{}, err } } if cfg.Token == "" { return ImportResult{}, errors.New("official token file is empty") } if err := cfg.Save(opts.ConfigPath); err != nil { return ImportResult{}, err } names := make([]string, 0, len(cfg.Routes)) for name := range cfg.Routes { names = append(names, name) } return ImportResult{ TunnelID: cfg.TunnelID, Hostname: cfg.Hostname, Routes: names, ConfigPath: opts.ConfigPath, Source: dataDir, }, nil } func xdgDir(env, fallback string, parts ...string) string { base := os.Getenv(env) if base == "" { home, err := os.UserHomeDir() if err != nil { base = "." } else { base = filepath.Join(home, fallback) } } return filepath.Join(append([]string{base}, parts...)...) } // readOfficialRoutes parses the subset of TOML the official CLI writes: // // [routes] // web = "127.0.0.1:3000" // api = { target = "127.0.0.1:4000", proxy_protocol = "v2" } func readOfficialRoutes(path string) (map[string]Route, error) { out := map[string]Route{} file, err := os.Open(path) if errors.Is(err, os.ErrNotExist) { return out, nil } if err != nil { return nil, err } defer file.Close() inRoutes := false scanner := bufio.NewScanner(file) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if line == "" || strings.HasPrefix(line, "#") { continue } if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") { inRoutes = line == "[routes]" continue } if !inRoutes { continue } name, value, ok := strings.Cut(line, "=") if !ok { return nil, fmt.Errorf("%s: bad route line %q", path, line) } name = strings.TrimSpace(name) if unquoted, err := strconv.Unquote(name); err == nil { name = unquoted } value = strings.TrimSpace(value) route, err := parseRouteValue(value) if err != nil { return nil, fmt.Errorf("%s: route %s: %w", path, name, err) } out[name] = route } if err := scanner.Err(); err != nil { return nil, err } return out, nil } func parseRouteValue(value string) (Route, error) { if strings.HasPrefix(value, "\"") { target, err := strconv.Unquote(value) if err != nil { return Route{}, err } return Route{Target: target}, nil } if strings.HasPrefix(value, "{") && strings.HasSuffix(value, "}") { route := Route{} body := strings.TrimSpace(value[1 : len(value)-1]) for _, field := range strings.Split(body, ",") { key, val, ok := strings.Cut(strings.TrimSpace(field), "=") if !ok { continue } key = strings.TrimSpace(key) val = strings.TrimSpace(val) unquoted, err := strconv.Unquote(val) if err != nil { return Route{}, err } switch key { case "target": route.Target = unquoted case "proxy_protocol": route.ProxyProtocol = unquoted } } if route.Target == "" { return Route{}, errors.New("inline route has no target") } return route, nil } return Route{}, fmt.Errorf("unsupported route value %q", value) }