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
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,10 @@ jobs:
GOCACHE: ${{ runner.temp }}/go-build
run: nox -s ${{ matrix.nox_session }}

- name: Verify native local-control rejection across OS users
if: ${{ startsWith(matrix.platform_id, 'linux-') || startsWith(matrix.platform_id, 'macos-') }}
run: sudo .nox/unit/bin/python -m pytest server/tests/unit/test_local_control.py::test_real_different_os_user_is_rejected -q

website:
name: Website
runs-on: ubuntu-latest
Expand Down
7 changes: 7 additions & 0 deletions server/news/+posix-local-control.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
On Linux and macOS, authority initialization and status can reach the running
server through a Unix socket inside the private authority directory, authenticated by
kernel peer credentials in both directions. Temporary contention without a live
endpoint retains the busy diagnostic; unsafe endpoints still fail explicitly. Existing commands retain stopped-instance operation. Control messages,
connections and work are bounded, and shutdown keeps the instance lock until
actual administrative work finishes. Run commands as the server's OS account;
loopback HTTP and socket-path knowledge do not grant administrative authority.
86 changes: 51 additions & 35 deletions server/src/arbiter_server/authority.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ class AuthorityError(ValueError):
"""An offline authority operation could not complete safely."""


class AuthorityBusy(AuthorityError):
"""The running instance must handle this administrative operation."""


@dataclass(frozen=True)
class AuthorityRecord:
owner_id: str
Expand Down Expand Up @@ -233,7 +237,7 @@ def unlock() -> None:
try:
lock()
except OSError as exc:
raise AuthorityError("authority state is busy; try again later") from exc
raise AuthorityBusy("authority state is busy; try again later") from exc
try:
yield
finally:
Expand Down Expand Up @@ -311,31 +315,33 @@ def initialize_authority(
"""Create one private set of IDs, refusing to replace any existing database."""
root = _root(state_dir, create=True)
with _locked(root):
from . import generations
return _initialize_locked(root, recovery_file, reuse_recovery_file)

if generations.reconcile(root) is not None:
raise AuthorityError(
"authority database already exists; inspect authority status"
)
if recovery_file is None:
raise AuthorityError(
"select an external private --recovery-file destination"
)
from .recovery import register_and_publish, setup_file

with setup_file(recovery_file, root, reuse=reuse_recovery_file) as (
path,
material,
):
record = AuthorityRecord(
material.owner_id, str(uuid4()), datetime.now(timezone.utc).isoformat()
)
with generations.staging(root) as staged:
_write_database(staged / _PENDING, record)
register_and_publish(path, record, material, staged / _PENDING)
_publish(staged / _PENDING, staged / DATABASE)
generations.activate(root, staged)
return record
def _initialize_locked(
root: Path, recovery_file: Path | None, reuse_recovery_file: bool
) -> AuthorityRecord:
"""Run only while this process owns the persistent instance lock."""
from . import generations

if generations.reconcile(root) is not None:
raise AuthorityError(
"authority database already exists; inspect authority status"
)
if recovery_file is None:
raise AuthorityError("select an external private --recovery-file destination")
from .recovery import register_and_publish, setup_file

with setup_file(recovery_file, root, reuse=reuse_recovery_file) as (path, material):
record = AuthorityRecord(
material.owner_id, str(uuid4()), datetime.now(timezone.utc).isoformat()
)
with generations.staging(root) as staged:
_write_database(staged / _PENDING, record)
register_and_publish(path, record, material, staged / _PENDING)
_publish(staged / _PENDING, staged / DATABASE)
generations.activate(root, staged)
return record


def authority_status(state_dir: Path) -> AuthorityRecord | None:
Expand Down Expand Up @@ -422,25 +428,35 @@ def authority_cli(
import json

try:
record = (
initialize_authority(
state_dir,
recovery_file=recovery_file,
reuse_recovery_file=reuse_recovery_file,
try:
record = (
initialize_authority(
state_dir,
recovery_file=recovery_file,
reuse_recovery_file=reuse_recovery_file,
)
if action == "init"
else authority_status(state_dir)
)
if action == "init"
else authority_status(state_dir)
)
payload = record.to_dict() if record is not None else {"initialized": False}
except AuthorityBusy as busy:
if os.name == "nt":
raise
from .local_control import request

try:
payload = request(state_dir, action, recovery_file, reuse_recovery_file)
except (FileNotFoundError, ConnectionRefusedError) as exc:
raise busy from exc
except (OSError, ValueError, sqlite3.Error) as exc:
print_cli_error(str(exc), area="authority")
return 1
payload = record.to_dict() if record is not None else {"initialized": False}
if as_json:
print(json.dumps(payload, sort_keys=True))
elif record is None:
elif not payload["initialized"]:
print("authority: not initialized")
else:
print(
f"authority: initialized\nowner: {record.owner_id}\nserver: {record.server_id}"
f"authority: initialized\nowner: {payload['owner_id']}\nserver: {payload['server_id']}"
)
return 0
Loading
Loading