crabbox/internal/cli/auth.go
Jonathan Moss 00725544c7
feat(azure): support linux and native windows leases
Add Azure as a managed provider for direct and brokered Crabbox leases.

- provision Azure Linux VMs with cloud-init, spot fallback, shared network adoption, and per-lease cleanup
- provision native Azure Windows VMs with VM Agent bootstrap and SSH/sync/run support
- add Azure broker support in the Cloudflare Worker, provider config, docs, and tests
- fix async Azure delete handling so successful 202 delete LROs do not refetch deleted resources
- keep Go core coverage above the CI threshold

Verified with CI plus live Azure Linux and native Windows leases.

Co-authored-by: Jonathan Moss <2729151+jwmoss@users.noreply.github.com>
2026-05-08 08:23:38 +01:00

335 lines
8.6 KiB
Go

package cli
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/url"
"os"
"os/exec"
"runtime"
"strings"
"time"
)
const defaultCoordinatorURL = "https://crabbox.openclaw.ai"
func (a App) login(ctx context.Context, args []string) error {
fs := newFlagSet("login", a.Stderr)
brokerURL := fs.String("url", "", "broker URL")
provider := fs.String("provider", "", "default provider: hetzner, aws, or azure")
tokenStdin := fs.Bool("token-stdin", false, "read broker token from stdin")
noBrowser := fs.Bool("no-browser", false, "print GitHub login URL instead of opening a browser")
jsonOut := fs.Bool("json", false, "print JSON")
if err := parseFlags(fs, args); err != nil {
return err
}
if *brokerURL == "" {
if cfg, err := loadConfig(); err == nil {
*brokerURL = cfg.Coordinator
if *provider == "" {
*provider = cfg.Provider
}
}
}
if *brokerURL == "" {
*brokerURL = defaultCoordinatorURL
}
if *tokenStdin {
return a.loginWithToken(ctx, *brokerURL, *provider, *jsonOut)
}
return a.loginWithGitHub(ctx, *brokerURL, *provider, *noBrowser, *jsonOut)
}
func (a App) loginWithToken(ctx context.Context, brokerURL, provider string, jsonOut bool) error {
data, err := io.ReadAll(os.Stdin)
if err != nil {
return exit(2, "read broker token: %v", err)
}
token := strings.TrimSpace(string(data))
if token == "" {
return exit(2, "broker token from stdin is empty")
}
path, cfg, err := writeBrokerLogin(brokerURL, token, provider)
if err != nil {
return err
}
coord, ok, err := newCoordinatorClient(cfg)
if err != nil {
return err
}
if !ok {
return exit(2, "login wrote config but broker is not configured")
}
return a.finishLogin(ctx, coord, path, cfg, jsonOut)
}
func (a App) loginWithGitHub(ctx context.Context, brokerURL, provider string, noBrowser, jsonOut bool) error {
pollSecret, err := randomHex(32)
if err != nil {
return err
}
pollSecretHash := sha256Hex(pollSecret)
client, err := coordinatorClientForLogin(brokerURL)
if err != nil {
return err
}
start, err := client.StartGitHubLogin(ctx, pollSecretHash, provider)
if err != nil {
return err
}
if canonicalBrokerURL, ok := canonicalBrokerURLFromLoginURL(start.URL); ok && !sameBrokerURL(brokerURL, canonicalBrokerURL) {
brokerURL = canonicalBrokerURL
client, err = coordinatorClientForLogin(brokerURL)
if err != nil {
return err
}
start, err = client.StartGitHubLogin(ctx, pollSecretHash, provider)
if err != nil {
return err
}
}
if noBrowser {
fmt.Fprintf(a.Stderr, "open this GitHub login URL:\n%s\n", start.URL)
} else if err := openBrowser(start.URL); err != nil {
fmt.Fprintf(a.Stderr, "could not open browser: %v\nopen this GitHub login URL:\n%s\n", err, start.URL)
} else {
fmt.Fprintln(a.Stderr, "opened GitHub login in your browser")
}
deadline := time.Now().Add(10 * time.Minute)
if start.ExpiresAt != "" {
if parsed, err := time.Parse(time.RFC3339, start.ExpiresAt); err == nil {
deadline = parsed
}
}
for {
if time.Now().After(deadline) {
return exit(3, "GitHub login expired")
}
poll, err := client.PollGitHubLogin(ctx, start.LoginID, pollSecret)
if err != nil {
return err
}
switch poll.Status {
case "pending":
case "complete":
if poll.Token == "" {
return exit(3, "GitHub login completed without a broker token")
}
if provider == "" {
provider = poll.Provider
}
path, cfg, err := writeBrokerLogin(brokerURL, poll.Token, provider)
if err != nil {
return err
}
coord, ok, err := newCoordinatorClient(cfg)
if err != nil {
return err
}
if !ok {
return exit(2, "login wrote config but broker is not configured")
}
return a.finishLogin(ctx, coord, path, cfg, jsonOut)
case "expired":
return exit(3, "GitHub login expired")
case "failed":
return exit(3, "GitHub login failed: %s", blank(poll.Error, "unknown error"))
default:
return exit(3, "GitHub login returned unexpected status %q", poll.Status)
}
timer := time.NewTimer(2 * time.Second)
select {
case <-ctx.Done():
timer.Stop()
return ctx.Err()
case <-timer.C:
}
}
}
func (a App) finishLogin(ctx context.Context, coord *CoordinatorClient, path string, cfg Config, jsonOut bool) error {
who, err := coord.Whoami(ctx)
if err != nil {
return err
}
if jsonOut {
return json.NewEncoder(a.Stdout).Encode(map[string]any{
"config": path,
"broker": cfg.Coordinator,
"provider": cfg.Provider,
"identity": who,
})
}
fmt.Fprintf(a.Stdout, "logged in broker=%s provider=%s user=%s org=%s config=%s\n", cfg.Coordinator, cfg.Provider, who.Owner, who.Org, path)
return nil
}
func coordinatorClientForLogin(brokerURL string) (*CoordinatorClient, error) {
cfg, err := loadConfig()
if err != nil {
return nil, err
}
cfg.Coordinator = brokerURL
cfg.CoordToken = ""
coord, ok, err := newCoordinatorClient(cfg)
if err != nil {
return nil, err
}
if !ok {
return nil, exit(2, "login requires a broker URL")
}
return coord, nil
}
func canonicalBrokerURLFromLoginURL(loginURL string) (string, bool) {
u, err := url.Parse(loginURL)
if err != nil {
return "", false
}
redirect := u.Query().Get("redirect_uri")
if redirect == "" {
return "", false
}
redirectURL, err := url.Parse(redirect)
if err != nil || redirectURL.Scheme == "" || redirectURL.Host == "" {
return "", false
}
const callbackPath = "/v1/auth/github/callback"
cleanPath := strings.TrimRight(redirectURL.Path, "/")
if !strings.HasSuffix(cleanPath, callbackPath) {
return "", false
}
redirectURL.Path = strings.TrimRight(strings.TrimSuffix(cleanPath, callbackPath), "/")
redirectURL.RawPath = ""
redirectURL.RawQuery = ""
redirectURL.Fragment = ""
return strings.TrimRight(redirectURL.String(), "/"), true
}
func sameBrokerURL(left, right string) bool {
return normalizedBrokerURL(left) == normalizedBrokerURL(right)
}
func normalizedBrokerURL(value string) string {
u, err := url.Parse(value)
if err != nil {
return strings.TrimRight(value, "/")
}
u.Path = strings.TrimRight(u.Path, "/")
u.RawQuery = ""
u.Fragment = ""
return strings.TrimRight(u.String(), "/")
}
func openBrowser(target string) error {
switch runtime.GOOS {
case "darwin":
return exec.Command("open", target).Start()
case "windows":
return exec.Command("rundll32", "url.dll,FileProtocolHandler", target).Start()
default:
return exec.Command("xdg-open", target).Start()
}
}
func randomHex(size int) (string, error) {
data := make([]byte, size)
if _, err := rand.Read(data); err != nil {
return "", err
}
return hex.EncodeToString(data), nil
}
func sha256Hex(value string) string {
sum := sha256.Sum256([]byte(value))
return hex.EncodeToString(sum[:])
}
func (a App) logout(_ context.Context, args []string) error {
fs := newFlagSet("logout", a.Stderr)
jsonOut := fs.Bool("json", false, "print JSON")
if err := parseFlags(fs, args); err != nil {
return err
}
path := writableConfigPath()
if path == "" {
return exit(2, "user config directory is unavailable")
}
file, err := readFileConfig(path)
if err != nil {
return err
}
if file.Broker != nil {
file.Broker.Token = ""
}
file.CoordinatorToken = ""
written, err := writeUserFileConfig(file)
if err != nil {
return err
}
if *jsonOut {
return json.NewEncoder(a.Stdout).Encode(map[string]any{"config": written, "brokerAuth": "missing"})
}
fmt.Fprintf(a.Stdout, "logged out config=%s broker_auth=missing\n", written)
return nil
}
func (a App) whoami(ctx context.Context, args []string) error {
fs := newFlagSet("whoami", a.Stderr)
jsonOut := fs.Bool("json", false, "print JSON")
if err := parseFlags(fs, args); err != nil {
return err
}
cfg, err := loadConfig()
if err != nil {
return err
}
coord, ok, err := newCoordinatorClient(cfg)
if err != nil {
return err
}
if !ok {
return exit(2, "whoami requires a configured coordinator")
}
who, err := coord.Whoami(ctx)
if err != nil {
return err
}
if *jsonOut {
return json.NewEncoder(a.Stdout).Encode(who)
}
fmt.Fprintf(a.Stdout, "user=%s org=%s auth=%s broker=%s\n", who.Owner, who.Org, who.Auth, cfg.Coordinator)
return nil
}
func writeBrokerLogin(brokerURL, token, provider string) (string, Config, error) {
path := writableConfigPath()
if path == "" {
return "", Config{}, exit(2, "user config directory is unavailable")
}
file, err := readFileConfig(path)
if err != nil {
return "", Config{}, err
}
if file.Broker == nil {
file.Broker = &fileBrokerConfig{}
}
file.Broker.URL = brokerURL
file.Broker.Token = token
if provider != "" {
file.Broker.Provider = provider
file.Provider = provider
}
written, err := writeUserFileConfig(file)
if err != nil {
return "", Config{}, err
}
cfg, err := loadConfig()
return written, cfg, err
}