commit 23e3e4c1607718efdeb193f134541eea70180867 Author: Felix Förtsch Date: Wed Feb 18 10:50:25 2026 +0100 snapshot current state before gitea sync diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..32b71b8 Binary files /dev/null and b/.DS_Store differ diff --git a/README.md b/README.md new file mode 100644 index 0000000..93ec468 --- /dev/null +++ b/README.md @@ -0,0 +1,31 @@ +# uberspace-cli (experimental) + +This is a Go CLI scaffold intended to work against the Uberspace dashboard by replaying the same HTTP calls your browser makes. It does not include hardcoded endpoints because there is no public API. + +## Quick start + +1. Copy `uberspace-cli.example.yaml` to your config path. +2. Update the endpoint paths, headers, and bodies to match real dashboard requests. +3. Run `go build ./cmd/uberspace-cli`. +4. Use `./uberspace-cli login` to save a session, then `create-asteroid` or `add-ssh-key`. + +## Capture requests + +Use your browser devtools network tab on the Uberspace dashboard. + +1. Log in and create an asteroid in the UI. +2. Find the request in the network list. +3. Copy method, path, required headers, and payload into the config. +4. If there is a CSRF cookie or header, configure `csrf` in `login`. + +## Example commands + +```bash +./uberspace-cli login --email you@example.com --password '...' +./uberspace-cli create-asteroid --name my-asteroid +./uberspace-cli add-ssh-key --name laptop --public-key-file ~/.ssh/id_ed25519.pub +``` + +## Notes + +This is unofficial and may break if the dashboard changes. diff --git a/cmd/.DS_Store b/cmd/.DS_Store new file mode 100644 index 0000000..4273e54 Binary files /dev/null and b/cmd/.DS_Store differ diff --git a/cmd/uberspace-cli/main.go b/cmd/uberspace-cli/main.go new file mode 100644 index 0000000..fbddb88 --- /dev/null +++ b/cmd/uberspace-cli/main.go @@ -0,0 +1,12 @@ +package main + +import ( + "os" + + "uberspace-cli/internal/cli" +) + +func main() { + c := cli.New() + os.Exit(c.Run(os.Args[1:])) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..4807f05 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module uberspace-cli + +go 1.22 + +require gopkg.in/yaml.v3 v3.0.1 diff --git a/internal/.DS_Store b/internal/.DS_Store new file mode 100644 index 0000000..f52fbb6 Binary files /dev/null and b/internal/.DS_Store differ diff --git a/internal/cli/cli.go b/internal/cli/cli.go new file mode 100644 index 0000000..128de9a --- /dev/null +++ b/internal/cli/cli.go @@ -0,0 +1,305 @@ +package cli + +import ( + "errors" + "flag" + "fmt" + "net/http" + "os" + "path/filepath" + "strings" + + "uberspace-cli/internal/config" + "uberspace-cli/internal/httpapi" + "uberspace-cli/internal/session" +) + +type Command func(args []string) error + +type CLI struct { + Stdout *os.File + Stderr *os.File +} + +func New() *CLI { + return &CLI{Stdout: os.Stdout, Stderr: os.Stderr} +} + +func (c *CLI) Run(args []string) int { + if len(args) < 1 { + c.usage() + return 2 + } + switch args[0] { + case "help", "-h", "--help": + c.usage() + return 0 + case "login": + if err := c.login(args[1:]); err != nil { + fmt.Fprintln(c.Stderr, "error:", err) + return 1 + } + return 0 + case "request": + if err := c.request(args[1:]); err != nil { + fmt.Fprintln(c.Stderr, "error:", err) + return 1 + } + return 0 + case "create-asteroid": + if err := c.createAsteroid(args[1:]); err != nil { + fmt.Fprintln(c.Stderr, "error:", err) + return 1 + } + return 0 + case "add-ssh-key": + if err := c.addSSHKey(args[1:]); err != nil { + fmt.Fprintln(c.Stderr, "error:", err) + return 1 + } + return 0 + default: + fmt.Fprintln(c.Stderr, "unknown command:", args[0]) + c.usage() + return 2 + } +} + +func (c *CLI) usage() { + fmt.Fprintln(c.Stdout, "uberspace-cli (experimental)") + fmt.Fprintln(c.Stdout, "") + fmt.Fprintln(c.Stdout, "Commands:") + fmt.Fprintln(c.Stdout, " login Authenticate and store a session") + fmt.Fprintln(c.Stdout, " request Execute a configured endpoint") + fmt.Fprintln(c.Stdout, " create-asteroid Create a new asteroid") + fmt.Fprintln(c.Stdout, " add-ssh-key Add an SSH public key") + fmt.Fprintln(c.Stdout, "") + fmt.Fprintln(c.Stdout, "Use 'command -h' for command help.") +} + +func (c *CLI) login(args []string) error { + fs := flag.NewFlagSet("login", flag.ContinueOnError) + fs.SetOutput(c.Stderr) + configPath := fs.String("config", "", "config file path") + sessionPath := fs.String("session", "", "session file path") + email := fs.String("email", "", "account email") + password := fs.String("password", "", "account password") + if err := fs.Parse(args); err != nil { + return err + } + if *email == "" || *password == "" { + return errors.New("email and password are required") + } + cfg, cfgPath, err := loadConfig(*configPath) + if err != nil { + return err + } + path, err := resolveSessionPath(*sessionPath) + if err != nil { + return err + } + + ep, ok := cfg.Endpoints["login"] + if !ok { + return fmt.Errorf("login endpoint not found in %s", cfgPath) + } + + client, err := httpapi.New(cfg.BaseURL, nil, nil) + if err != nil { + return err + } + vars := map[string]string{ + "email": *email, + "password": *password, + } + resp, body, err := client.DoEndpoint(ep, vars) + if err != nil { + return err + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("login failed: %s: %s", resp.Status, strings.TrimSpace(string(body))) + } + csrf := client.CaptureCSRF(ep, resp, body) + if err := session.Save(path, cfg.BaseURL, client.HTTP.Jar, csrf); err != nil { + return err + } + fmt.Fprintln(c.Stdout, "session saved to", path) + return nil +} + +func (c *CLI) request(args []string) error { + fs := flag.NewFlagSet("request", flag.ContinueOnError) + fs.SetOutput(c.Stderr) + configPath := fs.String("config", "", "config file path") + sessionPath := fs.String("session", "", "session file path") + endpoint := fs.String("endpoint", "", "endpoint name from config") + varsFlag := fs.String("var", "", "template variables (key=value,comma-separated)") + if err := fs.Parse(args); err != nil { + return err + } + if *endpoint == "" { + return errors.New("endpoint is required") + } + cfg, cfgPath, err := loadConfig(*configPath) + if err != nil { + return err + } + ep, ok := cfg.Endpoints[*endpoint] + if !ok { + return fmt.Errorf("endpoint %q not found in %s", *endpoint, cfgPath) + } + path, err := resolveSessionPath(*sessionPath) + if err != nil { + return err + } + var file *session.File + var jar http.CookieJar + if _, err := os.Stat(path); err == nil { + f, j, err := session.Load(path) + if err != nil { + return err + } + file, jar = f, j + } + client, err := httpapi.New(cfg.BaseURL, jar, nil) + if err != nil { + return err + } + if file != nil { + client.CSRF = file.CSRF + } + vars := parseVars(*varsFlag) + resp, body, err := client.DoEndpoint(ep, vars) + if err != nil { + return err + } + if resp.StatusCode >= 400 { + return fmt.Errorf("request failed: %s: %s", resp.Status, strings.TrimSpace(string(body))) + } + fmt.Fprintln(c.Stdout, string(body)) + return nil +} + +func (c *CLI) createAsteroid(args []string) error { + fs := flag.NewFlagSet("create-asteroid", flag.ContinueOnError) + fs.SetOutput(c.Stderr) + configPath := fs.String("config", "", "config file path") + sessionPath := fs.String("session", "", "session file path") + name := fs.String("name", "", "asteroid name") + if err := fs.Parse(args); err != nil { + return err + } + if *name == "" { + return errors.New("name is required") + } + return c.callEndpoint("create_asteroid", map[string]string{"asteroid": *name}, *configPath, *sessionPath) +} + +func (c *CLI) addSSHKey(args []string) error { + fs := flag.NewFlagSet("add-ssh-key", flag.ContinueOnError) + fs.SetOutput(c.Stderr) + configPath := fs.String("config", "", "config file path") + sessionPath := fs.String("session", "", "session file path") + name := fs.String("name", "", "key name") + publicKeyFile := fs.String("public-key-file", "", "path to public key file") + publicKey := fs.String("public-key", "", "public key string") + if err := fs.Parse(args); err != nil { + return err + } + if *name == "" { + return errors.New("name is required") + } + key, err := readPublicKey(*publicKeyFile, *publicKey) + if err != nil { + return err + } + vars := map[string]string{ + "key_name": *name, + "public_key": key, + } + return c.callEndpoint("add_ssh_key", vars, *configPath, *sessionPath) +} + +func (c *CLI) callEndpoint(endpoint string, vars map[string]string, configPath string, sessionPath string) error { + cfg, cfgPath, err := loadConfig(configPath) + if err != nil { + return err + } + ep, ok := cfg.Endpoints[endpoint] + if !ok { + return fmt.Errorf("endpoint %q not found in %s", endpoint, cfgPath) + } + path, err := resolveSessionPath(sessionPath) + if err != nil { + return err + } + file, jar, err := session.Load(path) + if err != nil { + return fmt.Errorf("session load failed (%s). Run login first", path) + } + client, err := httpapi.New(cfg.BaseURL, jar, nil) + if err != nil { + return err + } + client.CSRF = file.CSRF + resp, body, err := client.DoEndpoint(ep, vars) + if err != nil { + return err + } + if resp.StatusCode >= 400 { + return fmt.Errorf("request failed: %s: %s", resp.Status, strings.TrimSpace(string(body))) + } + fmt.Fprintln(c.Stdout, string(body)) + return nil +} + +func loadConfig(path string) (*config.Config, string, error) { + if path == "" { + p, err := config.DefaultPath() + if err != nil { + return nil, "", err + } + path = p + } + cfg, err := config.Load(path) + if err != nil { + return nil, "", err + } + return cfg, path, nil +} + +func resolveSessionPath(path string) (string, error) { + if path != "" { + return path, nil + } + return session.DefaultPath() +} + +func parseVars(raw string) map[string]string { + vars := map[string]string{} + if raw == "" { + return vars + } + pairs := strings.Split(raw, ",") + for _, p := range pairs { + parts := strings.SplitN(strings.TrimSpace(p), "=", 2) + if len(parts) == 2 { + vars[parts[0]] = parts[1] + } + } + return vars +} + +func readPublicKey(path string, literal string) (string, error) { + if literal != "" { + return strings.TrimSpace(literal), nil + } + if path == "" { + path = filepath.Join(os.Getenv("HOME"), ".ssh", "id_ed25519.pub") + } + data, err := os.ReadFile(path) + if err != nil { + return "", err + } + return strings.TrimSpace(string(data)), nil +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..5abb5f9 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,54 @@ +package config + +import ( + "errors" + "os" + "path/filepath" + + "gopkg.in/yaml.v3" +) + +type Config struct { + BaseURL string `yaml:"base_url"` + Endpoints map[string]Endpoint `yaml:"endpoints"` +} + +type Endpoint struct { + Method string `yaml:"method"` + Path string `yaml:"path"` + Headers map[string]string `yaml:"headers"` + Body string `yaml:"body"` + CSRF *CSRFConfig `yaml:"csrf"` +} + +type CSRFConfig struct { + From string `yaml:"from"` // "cookie" or "header" + Name string `yaml:"name"` // cookie or header name to read from response + Header string `yaml:"header"` // header to send on requests +} + +func DefaultPath() (string, error) { + dir, err := os.UserConfigDir() + if err != nil { + return "", err + } + return filepath.Join(dir, "uberspace-cli", "config.yaml"), nil +} + +func Load(path string) (*Config, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var cfg Config + if err := yaml.Unmarshal(data, &cfg); err != nil { + return nil, err + } + if cfg.BaseURL == "" { + return nil, errors.New("base_url is required in config") + } + if len(cfg.Endpoints) == 0 { + return nil, errors.New("endpoints are required in config") + } + return &cfg, nil +} diff --git a/internal/httpapi/client.go b/internal/httpapi/client.go new file mode 100644 index 0000000..ed868f0 --- /dev/null +++ b/internal/httpapi/client.go @@ -0,0 +1,113 @@ +package httpapi + +import ( + "bytes" + "errors" + "fmt" + "io" + "net/http" + "net/http/cookiejar" + "net/url" + "strings" + "text/template" + "time" + + "uberspace-cli/internal/config" + "uberspace-cli/internal/session" +) + +type Client struct { + BaseURL string + HTTP *http.Client + CSRF *session.CSRFToken +} + +func New(baseURL string, jar http.CookieJar, csrf *session.CSRFToken) (*Client, error) { + if baseURL == "" { + return nil, errors.New("base_url is required") + } + if jar == nil { + jar, _ = cookiejar.New(nil) + } + return &Client{ + BaseURL: strings.TrimRight(baseURL, "/"), + HTTP: &http.Client{ + Timeout: 30 * time.Second, + Jar: jar, + }, + CSRF: csrf, + }, nil +} + +func (c *Client) DoEndpoint(ep config.Endpoint, vars map[string]string) (*http.Response, []byte, error) { + body, err := renderTemplate(ep.Body, vars) + if err != nil { + return nil, nil, err + } + + urlStr := c.BaseURL + ep.Path + req, err := http.NewRequest(ep.Method, urlStr, strings.NewReader(body)) + if err != nil { + return nil, nil, err + } + + for k, v := range ep.Headers { + req.Header.Set(k, v) + } + if c.CSRF != nil && c.CSRF.Header != "" && c.CSRF.Value != "" { + req.Header.Set(c.CSRF.Header, c.CSRF.Value) + } + + resp, err := c.HTTP.Do(req) + if err != nil { + return nil, nil, err + } + defer resp.Body.Close() + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return resp, nil, err + } + return resp, respBody, nil +} + +func (c *Client) CaptureCSRF(ep config.Endpoint, resp *http.Response, body []byte) *session.CSRFToken { + if ep.CSRF == nil { + return nil + } + cfg := ep.CSRF + switch strings.ToLower(cfg.From) { + case "cookie": + u, err := url.Parse(c.BaseURL) + if err != nil { + return nil + } + cookies := c.HTTP.Jar.Cookies(u) + for _, c := range cookies { + if c.Name == cfg.Name { + return &session.CSRFToken{Header: cfg.Header, Value: c.Value} + } + } + case "header": + val := resp.Header.Get(cfg.Name) + if val != "" { + return &session.CSRFToken{Header: cfg.Header, Value: val} + } + } + _ = body + return nil +} + +func renderTemplate(tpl string, vars map[string]string) (string, error) { + if tpl == "" { + return "", nil + } + t, err := template.New("body").Option("missingkey=error").Parse(tpl) + if err != nil { + return "", fmt.Errorf("invalid template: %w", err) + } + var buf bytes.Buffer + if err := t.Execute(&buf, vars); err != nil { + return "", fmt.Errorf("template render failed: %w", err) + } + return buf.String(), nil +} diff --git a/internal/session/session.go b/internal/session/session.go new file mode 100644 index 0000000..a1c800e --- /dev/null +++ b/internal/session/session.go @@ -0,0 +1,116 @@ +package session + +import ( + "encoding/json" + "errors" + "net/http" + "net/http/cookiejar" + "net/url" + "os" + "path/filepath" + "time" +) + +type File struct { + BaseURL string `json:"base_url"` + Cookies []Cookie `json:"cookies"` + CSRF *CSRFToken `json:"csrf,omitempty"` +} + +type Cookie struct { + Name string `json:"name"` + Value string `json:"value"` + Domain string `json:"domain"` + Path string `json:"path"` + Expires int64 `json:"expires"` + Secure bool `json:"secure"` + HttpOnly bool `json:"http_only"` +} + +type CSRFToken struct { + Header string `json:"header"` + Value string `json:"value"` +} + +func DefaultPath() (string, error) { + dir, err := os.UserConfigDir() + if err != nil { + return "", err + } + return filepath.Join(dir, "uberspace-cli", "session.json"), nil +} + +func Load(path string) (*File, http.CookieJar, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, nil, err + } + var f File + if err := json.Unmarshal(data, &f); err != nil { + return nil, nil, err + } + if f.BaseURL == "" { + return nil, nil, errors.New("session missing base_url") + } + u, err := url.Parse(f.BaseURL) + if err != nil { + return nil, nil, err + } + jar, err := cookiejar.New(nil) + if err != nil { + return nil, nil, err + } + cookies := make([]*http.Cookie, 0, len(f.Cookies)) + for _, c := range f.Cookies { + cookies = append(cookies, &http.Cookie{ + Name: c.Name, + Value: c.Value, + Domain: c.Domain, + Path: c.Path, + Expires: unixToTime(c.Expires), + Secure: c.Secure, + HttpOnly: c.HttpOnly, + }) + } + jar.SetCookies(u, cookies) + return &f, jar, nil +} + +func Save(path string, baseURL string, jar http.CookieJar, csrf *CSRFToken) error { + u, err := url.Parse(baseURL) + if err != nil { + return err + } + cookies := jar.Cookies(u) + out := File{BaseURL: baseURL, CSRF: csrf} + for _, c := range cookies { + expires := int64(0) + if !c.Expires.IsZero() { + expires = c.Expires.Unix() + } + out.Cookies = append(out.Cookies, Cookie{ + Name: c.Name, + Value: c.Value, + Domain: c.Domain, + Path: c.Path, + Expires: expires, + Secure: c.Secure, + HttpOnly: c.HttpOnly, + }) + } + data, err := json.MarshalIndent(out, "", " ") + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return err + } + return os.WriteFile(path, data, 0o600) +} + +func unixToTime(v int64) (t time.Time) { + if v == 0 { + return time.Time{} + } + return time.Unix(v, 0) +} diff --git a/uberspace-cli.example.yaml b/uberspace-cli.example.yaml new file mode 100644 index 0000000..8217205 --- /dev/null +++ b/uberspace-cli.example.yaml @@ -0,0 +1,25 @@ +# Example config. You must replace endpoints with real values from the dashboard network calls. +base_url: https://dashboard.uberspace.de +endpoints: + login: + method: POST + path: /api/login + headers: + Content-Type: application/json + body: '{"email":"{{email}}","password":"{{password}}"}' + csrf: + from: cookie + name: csrf_token + header: X-CSRF-Token + create_asteroid: + method: POST + path: /api/asteroids + headers: + Content-Type: application/json + body: '{"name":"{{asteroid}}"}' + add_ssh_key: + method: POST + path: /api/ssh-keys + headers: + Content-Type: application/json + body: '{"name":"{{key_name}}","key":"{{public_key}}"}'