Unverified Commit 9c466329 authored by Minseong Choi's avatar Minseong Choi 💬
Browse files

feat(cli): add felis setup first-run console with reclaim protection and cfsetup idempotency

- Add `felis setup` TUI for initial Owner provisioning and optional Cloudflare edge
- Refactor breakGlass to share console TUI model (runConsoleTUI) with setup mode
- Session auth respects configured [auth].admin_hostname; fallback to op.console.<root>
- Protect linked Yggdrasil admins from Mojang-priority reclaim (spec §B3)
- cfsetup: idempotent Access app/policy creation, better 401/403 errors, GET + lookup
- Bootstrap: auto-install cloudflared, symlink /etc/felis/felis.toml
- Add sequence diagrams for ping-to-join, claim, and link flows
parent 94a3b7b5
Loading
Loading
Loading
Loading
+1 −0
Changes for cmd/felis/api.go: 1 added line, 0 removed lines.
Original line number Diff line number Diff line
@@ -150,6 +150,7 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int {
			Repo:          repo,
			Delegate:      api.AccessVerifier{Audience: cfg.Auth.AccessJWTAud},
			RootDomain:    cfg.Server.RootDomain,
			AdminHostname: cfg.Auth.AdminHostname,
		},
		RootDomain:   cfg.Server.RootDomain,
		WakeCooldown: 30 * time.Second,
+210 −51

File changed.

Preview size limit exceeded, changes collapsed.

+36 −31

File changed.

Preview size limit exceeded, changes collapsed.

+3 −2
Changes for cmd/felis/run.go: 3 added lines, 2 removed lines.
Original line number Diff line number Diff line
@@ -18,6 +18,7 @@ Commands:
  restore           Extract a world archive into a world volume (internal Job entrypoint)
  manifests         Render the control-plane RBAC + NetworkPolicy install bundle as YAML
  apply             Create a MinecraftServer CRD (direct K8s write; use -f server.json)
  setup             Open the first-run setup console (TUI; requires root/sudo)
  breakGlass        Open the local break-glass emergency console (TUI; requires root/sudo)

Run "felis <command> -h" for command-specific flags.
@@ -46,6 +47,8 @@ func run(args []string, stdout, stderr io.Writer) int {
		return cmdManifests(rest, stdout, stderr)
	case "apply":
		return cmdApply(rest, stdout, stderr)
	case "setup":
		return cmdSetup(rest, stdout, stderr)
	case "breakGlass":
		return cmdBreakGlass(rest, stdout, stderr)
	case "-h", "--help", "help":
@@ -56,5 +59,3 @@ func run(args []string, stdout, stderr io.Writer) int {
		return 2
	}
}

+42 −3
Changes for cmd/felis/run_test.go: 42 added lines, 3 removed lines.
Original line number Diff line number Diff line
@@ -2,6 +2,7 @@ package main

import (
	"bytes"
	"os"
	"strings"
	"testing"
)
@@ -52,10 +53,13 @@ func TestRunApplyRequiresFileFlag(t *testing.T) {

func TestRunApplyRejectsInvalidJSON(t *testing.T) {
	// Sending garbage via a temp file must exit 1 (input error), not panic or hang.
	empty := t.TempDir() + "/empty.json"
	if err := os.WriteFile(empty, nil, 0o644); err != nil {
		t.Fatal(err)
	}
	var out, errBuf bytes.Buffer
	code := run([]string{"apply", "-f", "/dev/null"}, &out, &errBuf)
	// /dev/null is empty → JSON parse fails or validation rejects the zero values;
	// either way it must exit 1, not panic.
	code := run([]string{"apply", "-f", empty}, &out, &errBuf)
	// The empty file must fail JSON parsing or validation; either way it exits 1.
	if code != 1 {
		t.Errorf("exit code = %d, want 1", code)
	}
@@ -64,6 +68,41 @@ func TestRunApplyRejectsInvalidJSON(t *testing.T) {
	}
}

func TestRunSetupAndBreakGlassCommands(t *testing.T) {
	t.Run("setup help", func(t *testing.T) {
		var out, errBuf bytes.Buffer
		if code := run([]string{"setup", "-h"}, &out, &errBuf); code != 0 {
			t.Errorf("exit code = %d, want 0", code)
		}
		if !strings.Contains(errBuf.String(), "Usage of setup") {
			t.Errorf("expected setup help, got stderr=%q stdout=%q", errBuf.String(), out.String())
		}
		if strings.Contains(errBuf.String(), "Usage of breakGlass") {
			t.Errorf("setup must not route to breakGlass help, got %q", errBuf.String())
		}
	})

	t.Run("breakGlass help", func(t *testing.T) {
		var out, errBuf bytes.Buffer
		if code := run([]string{"breakGlass", "-h"}, &out, &errBuf); code != 0 {
			t.Errorf("exit code = %d, want 0", code)
		}
		if !strings.Contains(errBuf.String(), "Usage of breakGlass") {
			t.Errorf("expected breakGlass help, got stderr=%q stdout=%q", errBuf.String(), out.String())
		}
	})

	t.Run("lowercase breakglass is intentionally rejected", func(t *testing.T) {
		var out, errBuf bytes.Buffer
		if code := run([]string{"breakglass", "-h"}, &out, &errBuf); code != 2 {
			t.Errorf("exit code = %d, want 2", code)
		}
		if !strings.Contains(errBuf.String(), "unknown command") {
			t.Errorf("expected lowercase alias rejection, got stderr=%q stdout=%q", errBuf.String(), out.String())
		}
	})
}

func TestRunReaperValidatesConfigBeforeDialing(t *testing.T) {
	var out, errBuf bytes.Buffer
	// Like api, reaper must fail fast (exit 1) at config load, before any
Loading