From 23e3e4c1607718efdeb193f134541eea70180867 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20F=C3=B6rtsch?= Date: Wed, 18 Feb 2026 10:50:25 +0100 Subject: [PATCH] snapshot current state before gitea sync --- .DS_Store | Bin 0 -> 6148 bytes README.md | 31 ++++ cmd/.DS_Store | Bin 0 -> 6148 bytes cmd/uberspace-cli/main.go | 12 ++ go.mod | 5 + internal/.DS_Store | Bin 0 -> 6148 bytes internal/cli/cli.go | 305 ++++++++++++++++++++++++++++++++++++ internal/config/config.go | 54 +++++++ internal/httpapi/client.go | 113 +++++++++++++ internal/session/session.go | 116 ++++++++++++++ uberspace-cli.example.yaml | 25 +++ 11 files changed, 661 insertions(+) create mode 100644 .DS_Store create mode 100644 README.md create mode 100644 cmd/.DS_Store create mode 100644 cmd/uberspace-cli/main.go create mode 100644 go.mod create mode 100644 internal/.DS_Store create mode 100644 internal/cli/cli.go create mode 100644 internal/config/config.go create mode 100644 internal/httpapi/client.go create mode 100644 internal/session/session.go create mode 100644 uberspace-cli.example.yaml diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..32b71b8e09b3c5f8e48e65b3c138b02feaf916c9 GIT binary patch literal 6148 zcmeHKy-EW?5S~o}o(PgMU~|17rm#Q5S=yKv(BwiC@4TR~vAWKe5X96LzJ+fg*jm_F z+4;@xknD0XjUXa3u>0-J&+hEEmzyjRncj8QC2A8MFl5Y5i&Osi`Of%D|5qK+R@}HXLfN3@8K2 zz?=d8KE!B@iD2c>emcucSWT5FD+g$$-zQ6y6LHeW&C$MfDu?qB3~VWeFPGvDnic3{9sm=;${{om{Ucy$&`ufnRR+EQ9T{@Z literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..4273e54ee49746639ccc07e962e1f24a1c198a26 GIT binary patch literal 6148 zcmeHK%}T>S5T4Nr0TFWaxG&(LcMq||C+G`k5>X*#gQYhQx%e2KJoqTSmf!p+EryC0 z5t)JAZ+3QOGy9e9>=F?#Hr<@4B%%UMkVTmgF;7k%xbqQ^b&WL*bWJVY(AmU7f6*kl zpU{RH5eYbA=Cg)yK-{aQb&X;Xo4Zg$2{obow*G-=rcopT#>(lx3+r@Vg z-47#%yX}nR=M4!4f`MQl7zhT80n}`f;@B|yU?3O>20j^(^C6)LR*s{g9v$fP2>_hI ztO8vxo4_OnVC6U(!UACn1zITk6N4=r_T+w*<7jB%#QJ5d<6r)Gys%$&IjK8wWf*-h z5De@xaA?C)>i;GFN|TTL-H;ds1Hr&QV}NJ%vR+_QUR%FxPik#KyFwEYzflwjbS=d| kEyWnfIdZC#HlIYteC0SA$|_>la9~^nl#u9xfnQ+Y9XWoZj-r4&~x>{tY`s03%=IEWK)1dhNO z-q=G9S<6N%6!)qW9^ni!1HE~X8_b{-Yk@|u01;x)Sck(nrSj z= 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}}"}'