mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-15 00:02:42 +00:00
318 lines
11 KiB
Go
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
|
|
}
|