Skip to content

Commit 1f1348a

Browse files
authored
Migrate to XDG Base Directory Specification and add config file support (#398)
* Migrate from ~/.upterm to XDG Base Directory Specification (#371) Replaces ~/.upterm with XDG-compliant directories for better cross-platform support and automatic cleanup. Sockets now use XDG_RUNTIME_DIR (transient, cleaned on logout/reboot) and logs use XDG_STATE_HOME (persistent across sessions). * Add config file support with XDG Base Directory Specification Adds persistent configuration file support following XDG Base Directory Specification. Config files are stored in XDG_CONFIG_HOME/upterm/config.yaml and provide an alternative to command-line flags and environment variables. Features: - Configuration priority: flags > env vars > config file > defaults - YAML format with comprehensive example template - Three new commands: - `upterm config path`: Show config file location - `upterm config view`: View current config or example template - `upterm config edit`: Edit config in $VISUAL/$EDITOR with validation - Auto-creates config directory and template on first edit - Silent fail if config doesn't exist, warns on parse errors - Follows CLI best practices for editor selection Platform-specific config paths: - Linux: ~/.config/upterm/config.yaml - macOS: ~/Library/Application Support/upterm/config.yaml - Windows: %LOCALAPPDATA%\upterm\config.yaml All documentation and help text updated to reflect new configuration system. * Standardize and improve CLI help text for consistency Improves all command help text to follow CLI best practices and ensure consistency across the entire CLI interface. Changes: - Break up long run-on sentences into clear, scannable paragraphs - Standardize example format (remove inconsistent $ prefix usage) - Improve short descriptions to be more descriptive - Ensure all flag descriptions start with capital letter and end with period - Restructure complex descriptions with numbered lists for clarity Specific improvements: - host: Restructured authentication explanation with numbered priority list - session current: Split run-on sentence into clear paragraphs - session: Enhanced short description from "Display session" to "Display and manage terminal sessions" - upgrade: Removed inconsistent $ prefix from examples, added colons - All flags: Standardized capitalization and punctuation All documentation and man pages regenerated to reflect these improvements. * Use generic XDG paths in generated documentation Sets XDG environment variables to generic Linux paths during doc generation to avoid hardcoding developer's machine-specific paths in committed docs. Changes: - Updated Makefile 'docs' target to set XDG env vars before running gendoc - Added comment in gendoc explaining XDG env var requirement - Regenerated all docs with generic paths: - Log file: /home/user/.local/state/upterm/upterm.log - Config file: /home/user/.config/upterm/config.yaml - Runtime dir: /run/user/1000/upterm When users run the CLI, they still see their actual platform-specific paths. * Set config file permissions to 0600 for security Changes config file permissions from 0644 (world-readable) to 0600 (owner-only) to protect security-sensitive information. Security rationale: - Config file may contain paths to private keys - Config file may contain paths to authorized_keys - Config file contains security settings (accept, read-only, hide-client-ip) - Follows industry standard for config files (Docker, Kubernetes, SSH all use 0600) The config file doesn't store actual secrets, but it contains security-sensitive configuration that should not be world-readable.
1 parent eeb7c40 commit 1f1348a

38 files changed

+1057
-157
lines changed

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ generate: proto
1616
docs:
1717
rm -rf docs && mkdir docs
1818
rm -rf etc && mkdir -p etc/man/man1 && mkdir -p etc/completion
19-
go run cmd/gendoc/main.go
19+
XDG_STATE_HOME=/home/user/.local/state XDG_CONFIG_HOME=/home/user/.config XDG_RUNTIME_DIR=/run/user/1000 go run cmd/gendoc/main.go
2020

2121
.PHONY: proto
2222
proto:

cmd/gendoc/main.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ func main() {
1515
_ = logger.Close()
1616
}()
1717

18+
// Note: XDG environment variables should be set externally before running this command
19+
// to generate docs with generic paths instead of machine-specific paths.
20+
// See Makefile 'docs' target for proper environment variable setup.
1821
rootCmd := command.Root()
1922

2023
if err := doc.GenMarkdownTree(rootCmd, "./docs"); err != nil {

cmd/upterm/command/config.go

Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
package command
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"os/exec"
7+
"runtime"
8+
9+
"github.com/owenthereal/upterm/utils"
10+
"github.com/spf13/cobra"
11+
"github.com/spf13/viper"
12+
)
13+
14+
func configCmd() *cobra.Command {
15+
configPath := utils.UptermConfigFilePath()
16+
cmd := &cobra.Command{
17+
Use: "config",
18+
Short: "Manage upterm configuration",
19+
Long: fmt.Sprintf(`Manage upterm configuration file.
20+
21+
Config file: %s
22+
23+
This follows the XDG Base Directory Specification.
24+
25+
Configuration priority (highest to lowest):
26+
1. Command-line flags
27+
2. Environment variables (UPTERM_ prefix)
28+
3. Config file
29+
4. Default values`, configPath),
30+
}
31+
32+
cmd.AddCommand(configPathCmd())
33+
cmd.AddCommand(configViewCmd())
34+
cmd.AddCommand(configEditCmd())
35+
36+
return cmd
37+
}
38+
39+
func configPathCmd() *cobra.Command {
40+
configPath := utils.UptermConfigFilePath()
41+
cmd := &cobra.Command{
42+
Use: "path",
43+
Short: "Show the path to the config file",
44+
Long: fmt.Sprintf(`Show the path to the config file.
45+
46+
Config file: %s
47+
48+
The config file is optional and created manually by users.`, configPath),
49+
Example: ` # Show config file path:
50+
upterm config path
51+
52+
# Create config file directory:
53+
mkdir -p "$(dirname "$(upterm config path)")"`,
54+
RunE: configPathRunE,
55+
}
56+
57+
return cmd
58+
}
59+
60+
func configViewCmd() *cobra.Command {
61+
configPath := utils.UptermConfigFilePath()
62+
cmd := &cobra.Command{
63+
Use: "view",
64+
Short: "View the config file contents",
65+
Long: fmt.Sprintf(`View the config file contents.
66+
67+
Config file: %s
68+
69+
If the config file exists, this command displays its contents. If it doesn't
70+
exist, this command shows an example config file that you can use as a template.`, configPath),
71+
Example: ` # View current config:
72+
upterm config view
73+
74+
# View and save as new config:
75+
upterm config view > "$(upterm config path)"`,
76+
RunE: configViewRunE,
77+
}
78+
79+
return cmd
80+
}
81+
82+
func configEditCmd() *cobra.Command {
83+
configPath := utils.UptermConfigFilePath()
84+
cmd := &cobra.Command{
85+
Use: "edit",
86+
Short: "Edit the config file",
87+
Long: fmt.Sprintf(`Edit the config file in your default editor.
88+
89+
Config file: %s
90+
91+
This command opens the config file in your editor (determined by $VISUAL, $EDITOR,
92+
or a sensible default). If the config file doesn't exist, it creates a template
93+
with example settings and comments.
94+
95+
The config directory is created automatically if it doesn't exist.`, configPath),
96+
Example: ` # Edit config file:
97+
upterm config edit
98+
99+
# Use a specific editor:
100+
EDITOR=nano upterm config edit`,
101+
RunE: configEditRunE,
102+
}
103+
104+
return cmd
105+
}
106+
107+
func configPathRunE(c *cobra.Command, args []string) error {
108+
configPath := utils.UptermConfigFilePath()
109+
fmt.Println(configPath)
110+
return nil
111+
}
112+
113+
func configViewRunE(c *cobra.Command, args []string) error {
114+
configPath := utils.UptermConfigFilePath()
115+
116+
// Check if file exists
117+
if _, err := os.Stat(configPath); os.IsNotExist(err) {
118+
// Show example config
119+
fmt.Println("# Config file does not exist. Example config:")
120+
fmt.Println()
121+
fmt.Print(exampleConfig())
122+
return nil
123+
}
124+
125+
// Read and display file
126+
content, err := os.ReadFile(configPath)
127+
if err != nil {
128+
return fmt.Errorf("failed to read config file: %w", err)
129+
}
130+
131+
fmt.Print(string(content))
132+
return nil
133+
}
134+
135+
func configEditRunE(c *cobra.Command, args []string) error {
136+
configPath := utils.UptermConfigFilePath()
137+
configDir := utils.UptermConfigDir()
138+
139+
// Create config directory if it doesn't exist
140+
if err := os.MkdirAll(configDir, 0755); err != nil {
141+
return fmt.Errorf("failed to create config directory: %w", err)
142+
}
143+
144+
// Create example config if file doesn't exist
145+
if _, err := os.Stat(configPath); os.IsNotExist(err) {
146+
if err := os.WriteFile(configPath, []byte(exampleConfig()), 0600); err != nil {
147+
return fmt.Errorf("failed to create config file: %w", err)
148+
}
149+
}
150+
151+
// Determine editor to use
152+
editor := getEditor()
153+
154+
// Open editor
155+
cmd := exec.Command(editor, configPath)
156+
cmd.Stdin = os.Stdin
157+
cmd.Stdout = os.Stdout
158+
cmd.Stderr = os.Stderr
159+
160+
if err := cmd.Run(); err != nil {
161+
return fmt.Errorf("failed to open editor: %w", err)
162+
}
163+
164+
// Validate config after editing
165+
if err := validateConfig(configPath); err != nil {
166+
fmt.Fprintf(os.Stderr, "Warning: config file has syntax errors: %v\n", err)
167+
fmt.Fprintf(os.Stderr, "Edit again with 'upterm config edit' or view with 'upterm config view'.\n")
168+
}
169+
170+
return nil
171+
}
172+
173+
// getEditor returns the editor to use, checking $VISUAL, $EDITOR, then defaults.
174+
func getEditor() string {
175+
// Check $VISUAL first (for full-screen editors)
176+
if editor := os.Getenv("VISUAL"); editor != "" {
177+
return editor
178+
}
179+
180+
// Check $EDITOR (for line editors)
181+
if editor := os.Getenv("EDITOR"); editor != "" {
182+
return editor
183+
}
184+
185+
// Platform-specific defaults
186+
switch runtime.GOOS {
187+
case "windows":
188+
return "notepad"
189+
default:
190+
// Unix-like systems: prefer nano for better UX, fall back to vi
191+
if _, err := exec.LookPath("nano"); err == nil {
192+
return "nano"
193+
}
194+
return "vi"
195+
}
196+
}
197+
198+
// validateConfig validates the config file by attempting to parse it.
199+
func validateConfig(path string) error {
200+
v := viper.New()
201+
v.SetConfigFile(path)
202+
return v.ReadInConfig()
203+
}
204+
205+
// exampleConfig returns an example config file with comments.
206+
func exampleConfig() string {
207+
return `# Upterm Configuration File
208+
#
209+
# This file follows the XDG Base Directory Specification.
210+
# Settings here are overridden by environment variables (UPTERM_*) and command-line flags.
211+
#
212+
# Configuration priority (highest to lowest):
213+
# 1. Command-line flags
214+
# 2. Environment variables (UPTERM_* prefix)
215+
# 3. This config file
216+
# 4. Default values
217+
218+
# Debug logging (default: false)
219+
# When enabled, writes debug-level logs to the log file.
220+
# debug: true
221+
222+
# Default server address for hosting sessions (default: ssh://uptermd.upterm.dev:22)
223+
# Supported protocols: ssh, ws, wss
224+
# server: ssh://uptermd.upterm.dev:22
225+
226+
# Force a specific command for clients (default: none)
227+
# When set, clients cannot run arbitrary commands.
228+
# Use YAML array syntax: ["command", "arg1", "arg2"]
229+
# force-command: ["/bin/bash", "-l"]
230+
231+
# Path to authorized_keys file for client authentication (default: none)
232+
# authorized-keys: /path/to/authorized_keys
233+
234+
# Paths to private key files (default: generates ephemeral key)
235+
# private-key:
236+
# - /path/to/private/key1
237+
# - /path/to/private/key2
238+
239+
# Read-only mode (default: false)
240+
# When enabled, clients can view but not interact with the session.
241+
# read-only: false
242+
243+
# Auto-accept clients without confirmation (default: false)
244+
# WARNING: Only use this in trusted environments.
245+
# accept: false
246+
247+
# Hide client IP addresses from logs and display (default: false)
248+
# hide-client-ip: false
249+
`
250+
}

cmd/upterm/command/host.go

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -54,11 +54,16 @@ func hostCmd() *cobra.Command {
5454
cmd := &cobra.Command{
5555
Use: "host",
5656
Short: "Host a terminal session",
57-
Long: `Host a terminal session via a reverse SSH tunnel to the Upterm server, linking the IO of the host
58-
and client to a command's IO. Authentication against the Upterm server defaults to using private key files located
59-
at ~/.ssh/id_dsa, ~/.ssh/id_ecdsa, ~/.ssh/id_ed25519, and ~/.ssh/id_rsa. If no private key file is found, it resorts
60-
to reading private keys from the SSH Agent. Absence of private keys in files or SSH Agent generates an on-the-fly
61-
private key. To authorize client connections, specify a authorized_key file with public keys using --authorized-keys.`,
57+
Long: `Host a terminal session via a reverse SSH tunnel to the Upterm server.
58+
59+
The session links the host and client IO to a command's IO. Authentication with the
60+
Upterm server uses private keys in this order:
61+
1. Private key files: ~/.ssh/id_dsa, ~/.ssh/id_ecdsa, ~/.ssh/id_ed25519, ~/.ssh/id_rsa
62+
2. SSH Agent keys
63+
3. Auto-generated ephemeral key (if no keys found)
64+
65+
To authorize client connections, use --authorized-keys to specify an authorized_keys file
66+
containing client public keys.`,
6267
Example: ` # Host a terminal session running $SHELL, attaching client's IO to the host's:
6368
upterm host
6469
@@ -97,7 +102,7 @@ private key. To authorize client connections, specify a authorized_key file with
97102
cmd.PersistentFlags().StringSliceVar(&flagSourceHutUsers, "srht-user", nil, "Authorize specified SourceHut users by allowing their public keys to connect.")
98103
cmd.PersistentFlags().BoolVar(&flagAccept, "accept", false, "Automatically accept client connections without prompts.")
99104
cmd.PersistentFlags().BoolVarP(&flagReadOnly, "read-only", "r", false, "Host a read-only session, preventing client interaction.")
100-
cmd.PersistentFlags().BoolVar(&flagHideClientIP, "hide-client-ip", false, "Hide client IP addresses from output (auto-enabled in CI environments)")
105+
cmd.PersistentFlags().BoolVar(&flagHideClientIP, "hide-client-ip", false, "Hide client IP addresses from output (auto-enabled in CI environments).")
101106

102107
return cmd
103108
}

0 commit comments

Comments
 (0)