Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions .github/workflows/privileged-macos-integration.yml
Original file line number Diff line number Diff line change
@@ -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
Comment on lines +39 to +40

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Build the same binary configuration used for releases

This step uses the runner's default CGO setting and leaves the development version/stripping flags in place, whereas .goreleaser.yaml:9-19 builds with CGO_ENABLED=0 and release ldflags. Consequently, lifecycle failures specific to the actual shipped binary configuration can pass this supposedly release-equivalent privileged workflow; build with the GoReleaser settings or exercise a GoReleaser-produced artifact.

Useful? React with 👍 / 👎.

- 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Consume all security output before testing the hash

When the CA hash is found before security finishes writing the system keychain listing, grep -q exits early and security can receive SIGPIPE; because this script enables pipefail, the pipeline is then nonzero and the if incorrectly treats the certificate as absent. This can let the workflow pass even when uninstall leaves the CA trusted, so avoid early-exit grep or capture the complete output before checking it.

AGENTS.md reference: AGENTS.md:L45-L46

Useful? React with 👍 / 👎.

echo "Portless CA remains trusted after uninstall" >&2
exit 1
fi
121 changes: 121 additions & 0 deletions cmd/portless/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
"os/exec"
"os/user"
"path/filepath"
"runtime"
"strconv"
"strings"
"syscall"
Expand All @@ -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"
Expand Down Expand Up @@ -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")
Expand Down
23 changes: 19 additions & 4 deletions docs/pki-service.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
33 changes: 33 additions & 0 deletions internal/pki/authority_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
52 changes: 49 additions & 3 deletions internal/service/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ package service

import (
"bytes"
"context"
"errors"
"fmt"
"net"
"os"
"os/exec"
Expand Down Expand Up @@ -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)
}
}

Expand Down