snapshot current state before gitea sync

This commit is contained in:
2026-02-18 10:50:25 +01:00
commit 23e3e4c160
11 changed files with 661 additions and 0 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

31
README.md Normal file
View File

@@ -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.

BIN
cmd/.DS_Store vendored Normal file

Binary file not shown.

12
cmd/uberspace-cli/main.go Normal file
View File

@@ -0,0 +1,12 @@
package main
import (
"os"
"uberspace-cli/internal/cli"
)
func main() {
c := cli.New()
os.Exit(c.Run(os.Args[1:]))
}

5
go.mod Normal file
View File

@@ -0,0 +1,5 @@
module uberspace-cli
go 1.22
require gopkg.in/yaml.v3 v3.0.1

BIN
internal/.DS_Store vendored Normal file

Binary file not shown.

305
internal/cli/cli.go Normal file
View File

@@ -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
}

54
internal/config/config.go Normal file
View File

@@ -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
}

113
internal/httpapi/client.go Normal file
View File

@@ -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
}

116
internal/session/session.go Normal file
View File

@@ -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)
}

View File

@@ -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}}"}'