Files
warmbly/cmd/warmblyctl/remote_client.go
T

318 lines
11 KiB
Go

package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
"regexp"
"strconv"
"strings"
"time"
)
type remoteError struct {
Exit int `json:"-"`
Code string `json:"code"`
Message string `json:"message"`
Status int `json:"status,omitempty"`
RequestID string `json:"request_id,omitempty"`
Retry int `json:"retry_after_seconds,omitempty"`
}
func (e *remoteError) Error() string { return e.Message }
func remoteFailure(exit int, code, message string) *remoteError {
return &remoteError{Exit: exit, Code: code, Message: message}
}
var remoteSafeCode = regexp.MustCompile(`^[a-z][a-z0-9_]{0,79}$`)
var remoteSafeID = regexp.MustCompile(`^[a-zA-Z0-9-]{1,80}$`)
var remoteProfileName = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$`)
func normalizeRemoteURL(raw string) (string, error) {
u, err := url.Parse(raw)
if err != nil || u.Hostname() == "" || u.User != nil || u.RawQuery != "" || u.ForceQuery || strings.Contains(raw, "#") || u.Opaque != "" || (u.Path != "" && u.Path != "/") || u.RawPath != "" {
return "", remoteFailure(2, "invalid_url", "--url must be a bare HTTPS backend origin without credentials, path, query or fragment.")
}
host := strings.ToLower(u.Hostname())
ip := net.ParseIP(host)
if u.Scheme != "https" && !(u.Scheme == "http" && (host == "localhost" || (ip != nil && ip.IsLoopback()))) {
return "", remoteFailure(2, "invalid_url", "Remote credentials require HTTPS. HTTP is allowed only for loopback development.")
}
port := u.Port()
if port != "" {
n, err := strconv.Atoi(port)
if err != nil || n < 1 || n > 65535 {
return "", remoteFailure(2, "invalid_url", "The backend port must be between 1 and 65535.")
}
if (u.Scheme == "https" && port == "443") || (u.Scheme == "http" && port == "80") {
port = ""
}
}
u.Host = host
if strings.Contains(host, ":") {
u.Host = "[" + host + "]"
}
if port != "" {
u.Host = net.JoinHostPort(host, port)
}
u.Path = ""
return u.String(), nil
}
type remoteSelection struct {
URL string
Server string
}
func (s *remoteSelection) flags(fs *flag.FlagSet) {
fs.StringVar(&s.URL, "url", "", "Explicit backend origin, for example https://api.example.com")
fs.StringVar(&s.Server, "server", "", "Named isolated profile registered by login --server NAME --url URL")
}
func (s remoteSelection) resolve(store *remoteStore) (string, string, error) {
if s.URL == "" && s.Server == "" {
return "", "", remoteFailure(2, "server_required", "Select a backend explicitly with --url or --server. No default server or environment credential is used.")
}
profile := s.Server
if profile == "" {
profile = "default"
}
if !remoteProfileName.MatchString(profile) {
return "", "", remoteFailure(2, "invalid_profile", "Profile names use 1 to 64 letters, digits, underscores or hyphens.")
}
base := s.URL
if base == "" {
base = store.state.Servers[profile]
if base == "" {
return "", "", remoteFailure(2, "unknown_server", "Register this profile with login --server NAME --url URL first.")
}
}
base, err := normalizeRemoteURL(base)
if err != nil {
return "", "", err
}
if registered := store.state.Servers[profile]; s.Server != "" && registered != "" && registered != base {
return "", "", remoteFailure(2, "server_mismatch", "This profile belongs to another backend. Select a different profile name; credentials never move between instances.")
}
return base, profile, nil
}
func newRemoteFlagSet(name string) *flag.FlagSet {
fs := flag.NewFlagSet(name, flag.ContinueOnError)
fs.SetOutput(io.Discard)
fs.Usage = func() {
fmt.Fprintf(os.Stderr, "Usage: warmblyctl %s [flags]\n", name)
fs.SetOutput(os.Stderr)
fs.PrintDefaults()
fs.SetOutput(io.Discard)
}
return fs
}
func parseRemoteFlags(fs *flag.FlagSet, args []string) error {
if err := fs.Parse(args); err != nil {
if errors.Is(err, flag.ErrHelp) {
return err
}
return remoteFailure(2, "invalid_arguments", "Invalid flags. Use --help; do not pass passwords or tokens on the command line.")
}
if fs.NArg() != 0 {
return remoteFailure(2, "invalid_arguments", "Unexpected positional arguments. Values must use the documented flags.")
}
return nil
}
func remoteHTTPClient() *http.Client {
return &http.Client{Timeout: 30 * time.Second, CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
return http.ErrUseLastResponse
}}
}
type remoteClient struct {
base string
profile string
key string
credential remoteCredential
store *remoteStore
http *http.Client
now func() time.Time
}
func newRemoteClient(ctx context.Context, sel remoteSelection) (*remoteClient, error) {
path, err := remoteStorePath()
if err != nil {
return nil, err
}
store, err := openRemoteStore(ctx, path)
if err != nil {
return nil, err
}
base, profile, err := sel.resolve(store)
if err != nil {
store.close()
return nil, err
}
key := remoteCredentialKey(base, profile)
cred, ok := store.state.Credentials[key]
if !ok || cred.URL != base {
store.close()
return nil, remoteFailure(4, "login_required", "No session for this instance and profile. Run login with the same selection.")
}
return &remoteClient{base: base, profile: profile, key: key, credential: cred, store: store, http: remoteHTTPClient(), now: time.Now}, nil
}
func remoteRequest(ctx context.Context, client *http.Client, base, method, path, token string, body json.RawMessage) (json.RawMessage, error) {
// Callers supply only fixed auth paths or validated catalog paths.
if !strings.HasPrefix(path, "/") || strings.HasPrefix(path, "//") || strings.ContainsAny(path, "\r\n#\\") {
return nil, remoteFailure(2, "invalid_path", "Use an allowed relative API path.")
}
req, err := http.NewRequestWithContext(ctx, method, base+path, bytes.NewReader(body))
if err != nil {
return nil, remoteFailure(2, "invalid_request", "Could not construct the request.")
}
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", "warmblyctl")
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := client.Do(req)
if err != nil {
if ctx.Err() != nil {
return nil, remoteFailure(7, "cancelled", "Operation cancelled. A write may have reached the server; inspect its state before another action.")
}
return nil, remoteFailure(5, "transport_failed", "Could not complete the request. A write may have reached the server; inspect its state before another action.")
}
defer resp.Body.Close()
payload, err := io.ReadAll(io.LimitReader(resp.Body, (8<<20)+1))
if err != nil || len(payload) > 8<<20 {
return nil, remoteFailure(5, "invalid_response", "Response unreadable or exceeds the 8 MiB limit. No raw response was printed.")
}
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
if resp.StatusCode == http.StatusNoContent && len(bytes.TrimSpace(payload)) == 0 {
return json.RawMessage(`{}`), nil
}
if !json.Valid(payload) {
return nil, remoteFailure(5, "invalid_response", "The endpoint did not return JSON. No raw response was printed.")
}
return payload, nil
}
e := remoteFailure(5, "http_error", "The server refused the request. Inspect the code and request ID; no response message or headers are printed.")
e.Status = resp.StatusCode
var envelope apiError
if json.Unmarshal(payload, &envelope) == nil {
if remoteSafeCode.MatchString(envelope.Code) {
e.Code = envelope.Code
}
if remoteSafeID.MatchString(envelope.RequestID) {
e.RequestID = envelope.RequestID
}
}
switch resp.StatusCode {
case http.StatusBadRequest:
e.Exit = 2
case http.StatusUnauthorized:
e.Exit = 4
e.Message = "Session rejected. Run login again for this profile. No password or MFA bypass is available."
case http.StatusForbidden:
e.Exit = 3
e.Message = "Permission, MFA or fresh authentication required. Refresh does not renew proof; use a new explicit browser-approved login for reauth_required."
case http.StatusTooManyRequests:
e.Exit = 6
if n, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil && n > 0 && n <= 3600 {
e.Retry = n
}
}
return nil, e
}
func (c *remoteClient) forget() error {
delete(c.store.state.Credentials, c.key)
c.credential.Token = remoteToken{}
return c.store.save()
}
func (c *remoteClient) refresh(ctx context.Context) error {
old := c.credential.Token
if old.RefreshToken == "" || !old.RefreshTokenExpiresAt.After(c.now()) {
if err := c.forget(); err != nil {
return err
}
return remoteFailure(4, "login_required", "Refresh session expired. Run login again.")
}
body, _ := json.Marshal(map[string]string{"refresh_token": old.RefreshToken})
payload, err := remoteRequest(ctx, c.http, c.base, http.MethodPost, "/v1/auth/refresh", "", body)
if err != nil {
// Rotation may have committed even when its response was lost. Never reuse it.
if ferr := c.forget(); ferr != nil {
return ferr
}
return err
}
var next remoteToken
if json.Unmarshal(payload, &next) != nil || !next.valid(c.now()) {
if err := c.forget(); err != nil {
return err
}
return remoteFailure(4, "refresh_unusable", "Rotated credential could not be validated. Run login again; refresh is not replayed.")
}
c.credential.Token = next
c.store.state.Credentials[c.key] = c.credential
if err := c.store.save(); err != nil {
_, _ = remoteRequest(ctx, c.http, c.base, http.MethodPost, "/v1/auth/logout", next.AccessToken, nil)
return err
}
return nil
}
func (c *remoteClient) do(ctx context.Context, method, path string, body json.RawMessage) (json.RawMessage, error) {
refreshed := false
if c.credential.Token.AccessToken == "" || !c.credential.Token.AccessTokenExpiresAt.After(c.now().Add(30*time.Second)) {
if err := c.refresh(ctx); err != nil {
return nil, err
}
refreshed = true
}
payload, err := remoteRequest(ctx, c.http, c.base, method, path, c.credential.Token.AccessToken, body)
var e *remoteError
if !errors.As(err, &e) || e.Status != http.StatusUnauthorized {
return payload, err
}
if method != http.MethodGet || refreshed {
if ferr := c.forget(); ferr != nil {
return nil, ferr
}
return nil, err // Never automatically repeat writes.
}
if err := c.refresh(ctx); err != nil {
return nil, err
}
payload, err = remoteRequest(ctx, c.http, c.base, method, path, c.credential.Token.AccessToken, body)
if errors.As(err, &e) && e.Status == http.StatusUnauthorized {
if ferr := c.forget(); ferr != nil {
return nil, ferr
}
}
return payload, err
}
func writeRemoteJSON(w io.Writer, value any) error {
enc := json.NewEncoder(w)
enc.SetEscapeHTML(true)
if err := enc.Encode(value); err != nil {
return remoteFailure(5, "output_failed", "Could not write JSON output.")
}
return nil
}