diff --git a/.github/workflows/privileged-macos-integration.yml b/.github/workflows/privileged-macos-integration.yml new file mode 100644 index 0000000..4d28e85 --- /dev/null +++ b/.github/workflows/privileged-macos-integration.yml @@ -0,0 +1,82 @@ +name: Privileged macOS integration + +on: + workflow_dispatch: + inputs: + confirm: + description: Type RUN-PRIVILEGED-PORTLESS to provision the ephemeral runner + required: true + type: string + +permissions: + contents: read + +jobs: + lifecycle: + name: Privileged lifecycle (ephemeral macOS) + if: inputs.confirm == 'RUN-PRIVILEGED-PORTLESS' + runs-on: macos-15 + environment: privileged-macos-integration + timeout-minutes: 15 + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Set up Go + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: go.mod + cache: true + - name: Verify runner is clean + run: | + set -euo pipefail + test ! -e /usr/local/libexec/portless + test ! -e /Library/LaunchDaemons/com.euforicio.portless.plist + test ! -e /usr/local/share/portless/ca.pem + if /bin/launchctl print system/com.euforicio.portless >/dev/null 2>&1; then + echo "Portless launchd job already exists" >&2 + exit 1 + fi + - name: Build release-equivalent binary + run: go build -trimpath -o "$RUNNER_TEMP/portless" ./cmd/portless + - name: Exercise privileged lifecycle + env: + PORTLESS_BIN: ${{ runner.temp }}/portless + run: | + set -euo pipefail + cleanup() { + sudo "$PORTLESS_BIN" uninstall >/dev/null 2>&1 || true + sudo rm -rf "/Library/Application Support/Portless" "/var/run/portless" + } + trap cleanup EXIT + + sudo -v + "$PORTLESS_BIN" init + "$PORTLESS_BIN" status + "$PORTLESS_BIN" trust status | grep -qx trusted + + python3 -m http.server 18080 --bind 127.0.0.1 --directory "$RUNNER_TEMP" & + upstream_pid=$! + trap 'kill "$upstream_pid" >/dev/null 2>&1 || true; cleanup' EXIT + "$PORTLESS_BIN" add lifecycle-integration --port 18080 --pid "$upstream_pid" + curl --fail --silent --show-error https://lifecycle-integration.localhost/ >/dev/null + + sudo "$PORTLESS_BIN" install + sudo "$PORTLESS_BIN" upgrade + sudo "$PORTLESS_BIN" upgrade + "$PORTLESS_BIN" doctor + sudo "$PORTLESS_BIN" uninstall + + if /bin/launchctl print system/com.euforicio.portless >/dev/null 2>&1; then + echo "Portless launchd job remains after uninstall" >&2 + exit 1 + fi + test ! -e /usr/local/libexec/portless + test ! -e /Library/LaunchDaemons/com.euforicio.portless.plist + test ! -e /var/run/portless/management.sock + test ! -e /usr/local/share/portless/ca.pem + test -e "/Library/Application Support/Portless/pki/ca.pem" + ca_hash="$(openssl x509 -in "/Library/Application Support/Portless/pki/ca.pem" -noout -fingerprint -sha256 | cut -d= -f2 | tr -d :)" + if /usr/bin/security find-certificate -a -Z /Library/Keychains/System.keychain | grep -Fq "$ca_hash"; then + echo "Portless CA remains trusted after uninstall" >&2 + exit 1 + fi diff --git a/cmd/portless/main_test.go b/cmd/portless/main_test.go index fb1a782..bfdc370 100644 --- a/cmd/portless/main_test.go +++ b/cmd/portless/main_test.go @@ -15,6 +15,7 @@ import ( "os/exec" "os/user" "path/filepath" + "runtime" "strconv" "strings" "syscall" @@ -23,6 +24,7 @@ import ( "github.com/euforicio/portless/internal/client" "github.com/euforicio/portless/internal/daemon" + "github.com/euforicio/portless/internal/hosts" "github.com/euforicio/portless/internal/lan" "github.com/euforicio/portless/internal/mdns" "github.com/euforicio/portless/internal/runner" @@ -88,6 +90,125 @@ func TestCommandSurfaceUsesRealManagementSocket(t *testing.T) { } +func TestPrivilegedLifecycleRejectsOrdinaryUserBeforeMutation(t *testing.T) { + if runtime.GOOS != "darwin" { + t.Skip("privileged lifecycle is supported only on macOS") + } + if os.Geteuid() == 0 { + t.Skip("ordinary-user lifecycle boundary requires a non-root test process") + } + for _, test := range []struct { + arguments []string + message string + }{ + {[]string{"install"}, "service installation requires root"}, + {[]string{"upgrade"}, "service installation requires root"}, + {[]string{"uninstall"}, "service removal requires root"}, + {[]string{"trust", "install"}, "trust mutation is privileged"}, + {[]string{"trust", "remove"}, "trust mutation is privileged"}, + } { + var stdout, stderr bytes.Buffer + if code := run(t.Context(), test.arguments, &stdout, &stderr); code != 1 || !strings.Contains(stderr.String(), test.message) { + t.Errorf("run(%v) = %d, stdout=%q stderr=%q; want %q", test.arguments, code, stdout.String(), stderr.String(), test.message) + } + } +} + +func TestInitStopsAtReadOnlyManagementGroupPreflight(t *testing.T) { + if runtime.GOOS != "darwin" { + t.Skip("init is supported only on macOS") + } + group := fmt.Sprintf("portless-test-missing-%d", os.Getpid()) + if _, err := user.LookupGroup(group); err == nil { + t.Fatalf("test management group unexpectedly exists: %s", group) + } + var stdout, stderr bytes.Buffer + code := run(t.Context(), []string{ + "init", "--management-group", group, + "--scheme", "http", "--listen", "127.0.0.1:8080", "--tld", ".test", + }, &stdout, &stderr) + if code != 1 || !strings.Contains(stderr.String(), "preflight management group") { + t.Fatalf("init = %d, stdout=%q stderr=%q", code, stdout.String(), stderr.String()) + } +} + +func TestReadOnlyHostsAndTrustCommandsPreserveSystemState(t *testing.T) { + if runtime.GOOS != "darwin" { + t.Skip("macOS system boundary integration") + } + if os.Geteuid() == 0 { + t.Skip("ordinary-user hosts boundary requires a non-root test process") + } + hostsBefore, err := os.ReadFile("/etc/hosts") + if err != nil { + t.Fatal(err) + } + hostsInfoBefore, err := os.Stat("/etc/hosts") + if err != nil { + t.Fatal(err) + } + publicCABefore, publicCAErr := os.ReadFile(publicCACertificatePath) + if publicCAErr != nil && !errors.Is(publicCAErr, os.ErrNotExist) { + t.Fatal(publicCAErr) + } + + socketPath := startRuntime(t, "") + t.Setenv("PORTLESS_SOCKET", socketPath) + name := fmt.Sprintf("privileged-lifecycle-%d", os.Getpid()) + for _, arguments := range [][]string{ + {"add", name, "--port", "65534", "--pid", strconv.Itoa(os.Getpid())}, + {"trust", "status"}, + } { + var stdout, stderr bytes.Buffer + if code := run(t.Context(), arguments, &stdout, &stderr); code != 0 { + t.Fatalf("run(%v) = %d, stdout=%q stderr=%q", arguments, code, stdout.String(), stderr.String()) + } + } + stat, ok := hostsInfoBefore.Sys().(*syscall.Stat_t) + if !ok { + t.Fatal("/etc/hosts stat does not expose ownership") + } + hostsConfig := hosts.Config{ + Path: "/etc/hosts", + LockPath: fmt.Sprintf("/etc/.portless-test-%d.lock", os.Getpid()), + UID: int(stat.Uid), + GID: int(stat.Gid), + Mode: hostsInfoBefore.Mode().Perm(), + } + plan, err := hostsConfig.SynchronizePlan([]string{name + ".localhost"}) + if err != nil { + t.Fatal(err) + } + if !plan.Changed { + t.Fatal("live hosts plan unexpectedly reports no change") + } + if _, err := hostsConfig.Apply(plan); !errors.Is(err, hosts.ErrPrivilegeRequired) { + t.Fatalf("live hosts apply error = %v, want ErrPrivilegeRequired", err) + } + if _, err := os.Lstat(hostsConfig.LockPath); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("rejected hosts apply created a lock: %v", err) + } + + hostsAfter, err := os.ReadFile("/etc/hosts") + if err != nil { + t.Fatal(err) + } + hostsInfoAfter, err := os.Stat("/etc/hosts") + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(hostsAfter, hostsBefore) || hostsInfoAfter.ModTime() != hostsInfoBefore.ModTime() || hostsInfoAfter.Mode() != hostsInfoBefore.Mode() { + t.Fatal("read-only hosts commands changed /etc/hosts") + } + publicCAAfter, err := os.ReadFile(publicCACertificatePath) + if err != nil && !errors.Is(err, os.ErrNotExist) { + t.Fatal(err) + } + if !bytes.Equal(publicCAAfter, publicCABefore) || errors.Is(err, os.ErrNotExist) != errors.Is(publicCAErr, os.ErrNotExist) { + t.Fatal("trust status changed public CA metadata") + } +} + func TestBuiltExecutableRunsDaemonAndOrdinaryCLI(t *testing.T) { directory := shortCommandTempDir(t) binary := filepath.Join(directory, "portless") diff --git a/docs/pki-service.md b/docs/pki-service.md index db37e3d..a87cd2c 100644 --- a/docs/pki-service.md +++ b/docs/pki-service.md @@ -132,7 +132,22 @@ unknown fields and trailing frames, and applies a total read/operation/write deadline. Shutdown closes accepted management connections before waiting for handler goroutines. -Automated tests use temporary files, real P-256 certificates and TLS -handshakes, real child executables and Unix sockets, read-only system-keychain -inspection, and native `plutil` validation. They do not load a system daemon or -change a live trust store. +Ordinary automated tests use temporary files, real P-256 certificates and TLS +handshakes, real child executables and Unix sockets, native `plutil`, read-only +`launchctl` and system-keychain inspection, and read-only live `/etc/hosts` +plans. They do not load a system daemon, bind privileged ports, change a live +trust store, or write `/etc/hosts`. + +The manual `Privileged macOS integration` workflow covers the provisioned +lifecycle that is unsafe on developer machines and ordinary CI. It requires the +exact dispatch confirmation `RUN-PRIVILEGED-PORTLESS`, uses the dedicated +`privileged-macos-integration` environment, runs only on a fresh GitHub-hosted +macOS runner, rejects pre-existing Portless artifacts, and exercises real +`init`, idempotent install/upgrade, launchd, system CA trust, HTTPS routing on +port 443 with both privileged listeners bound, and uninstall. Repository +administrators can add required +reviewers to that environment for a second-party approval gate. The workflow's +cleanup trap removes only the fixed Portless service and retained state paths. +Final assertions verify that the job, artifacts, socket, public CA, and exact +trusted certificate are absent after first observing uninstall's retained-state +contract. diff --git a/internal/pki/authority_test.go b/internal/pki/authority_test.go index 346549d..858dfd8 100644 --- a/internal/pki/authority_test.go +++ b/internal/pki/authority_test.go @@ -462,6 +462,39 @@ func TestSystemTrustInspectionUsesExactCertificate(t *testing.T) { } } +func TestApplyTrustRefusesOrdinaryUserWithoutMutation(t *testing.T) { + if runtime.GOOS != "darwin" { + t.Skip("system keychain inspection requires macOS") + } + if os.Geteuid() == 0 { + t.Skip("ordinary-user trust boundary requires a non-root test process") + } + authority, err := Open(filepath.Join(t.TempDir(), "pki"), Options{}) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) + defer cancel() + caPath := authority.RootCertificatePath() + before, err := SystemTrusted(ctx, caPath) + if err != nil { + t.Fatal(err) + } + if before { + t.Fatal("fresh temporary root unexpectedly exists in the system keychain") + } + if err := ApplyTrust(ctx, TrustInstall, caPath); err == nil || !strings.Contains(err.Error(), "requires root") { + t.Fatalf("ordinary-user trust install error = %v", err) + } + after, err := SystemTrusted(ctx, caPath) + if err != nil { + t.Fatal(err) + } + if after { + t.Fatal("rejected ordinary-user trust install changed the system keychain") + } +} + func assertMode(t *testing.T, path string, want os.FileMode) { t.Helper() info, err := os.Lstat(path) diff --git a/internal/service/service_test.go b/internal/service/service_test.go index 11d9b4a..d621af2 100644 --- a/internal/service/service_test.go +++ b/internal/service/service_test.go @@ -2,6 +2,9 @@ package service import ( "bytes" + "context" + "errors" + "fmt" "net" "os" "os/exec" @@ -129,12 +132,55 @@ func TestLifecycleCommandsAreAuditable(t *testing.T) { if !strings.Contains(install[1].String(), config.PlistPath) { t.Fatalf("command is not auditable: %s", install[1].String()) } + upgrade, err := config.Commands(ActionUpgrade) + if err != nil || !slices.EqualFunc(install, upgrade, func(left, right Command) bool { + return left.Path == right.Path && slices.Equal(left.Args, right.Args) && left.WhenLoaded == right.WhenLoaded + }) { + t.Fatalf("upgrade commands = %+v, %v; want install plan %+v", upgrade, err, install) + } + if _, err := config.Commands(Action("invalid")); err == nil { + t.Fatal("unknown lifecycle action was accepted") + } } func TestLifecycleExecutorRejectsNonLaunchctlCommands(t *testing.T) { - err := ApplyCommands(t.Context(), []Command{{Path: "/usr/bin/true", Args: []string{"unexpected"}}}) - if err == nil || !strings.Contains(err.Error(), "non-launchctl") { - t.Fatalf("ApplyCommands error = %v", err) + for _, command := range []Command{ + {Path: "/usr/bin/true", Args: []string{"unexpected"}}, + {Path: Launchctl}, + {Path: Launchctl, Args: []string{"bootout", "user/501/example"}, WhenLoaded: true}, + {Path: Launchctl, Args: []string{"print", "system/example"}, WhenLoaded: true}, + } { + if err := ApplyCommands(t.Context(), []Command{command}); err == nil { + t.Fatalf("ApplyCommands accepted invalid command: %+v", command) + } + } +} + +func TestRealLaunchdReadOnlyStatusAndAbsentUninstall(t *testing.T) { + if runtime.GOOS != "darwin" { + t.Skip("launchd integration requires macOS") + } + config := temporaryConfig(t) + config.Label = fmt.Sprintf("com.euforicio.portless.test.%d", os.Getpid()) + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) + defer cancel() + + loaded, err := config.Loaded(ctx) + if err != nil { + t.Fatal(err) + } + if loaded { + t.Fatalf("test launchd label unexpectedly exists: %s", config.Label) + } + commands, err := config.Commands(ActionUninstall) + if err != nil { + t.Fatal(err) + } + if err := ApplyCommands(ctx, commands); err != nil { + t.Fatalf("conditional uninstall for absent launchd job: %v", err) + } + if _, err := os.Lstat(config.PlistPath); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("read-only launchd lifecycle created a plist: %v", err) } }