Persist gateway secrets and worktree state in user data
This commit is contained in:
+4
-1
@@ -10,11 +10,13 @@ RUN dnf -y install ca-certificates git openssh-clients python3 tmux util-linux \
|
||||
&& dnf clean all
|
||||
|
||||
COPY resources/scripts/napseer_mcp_server.py /opt/napseer/napseer_mcp_server.py
|
||||
COPY resources/scripts/napseer_credentials.py /opt/napseer/napseer_credentials.py
|
||||
COPY resources/scripts/napseer_spake2.py /opt/napseer/napseer_spake2.py
|
||||
COPY resources/scripts/terminal/ /opt/napseer/terminal/
|
||||
|
||||
RUN python3 -m py_compile \
|
||||
/opt/napseer/napseer_mcp_server.py \
|
||||
/opt/napseer/napseer_credentials.py \
|
||||
/opt/napseer/napseer_spake2.py \
|
||||
/opt/napseer/terminal/__init__.py \
|
||||
/opt/napseer/terminal/protocol.py \
|
||||
@@ -27,6 +29,7 @@ ENV NAPSEER_GATEWAY_PORT=0
|
||||
ENV NAPSEER_GATEWAY_PTY_TERMINAL=1
|
||||
ENV NAPSEER_SERVICE_ACTIVATION_TIMEOUT_SECONDS=900
|
||||
|
||||
VOLUME ["/workspace/.napseer"]
|
||||
ENV NAPSEER_USER_DATA_DIR=/var/lib/napseer
|
||||
VOLUME ["/var/lib/napseer"]
|
||||
|
||||
CMD ["python3", "/opt/napseer/napseer_mcp_server.py", "gateway", "service", "run"]
|
||||
|
||||
@@ -20,3 +20,22 @@ Current state:
|
||||
image. It performs no live Napseer API download during the build.
|
||||
|
||||
The backend remains the protocol and compatibility source of truth through `/v1/gateway-protocol`.
|
||||
|
||||
Private gateway state is stored outside the checkout using the same
|
||||
`WorkspacePaths` contract as the CLI. The image sets
|
||||
`NAPSEER_USER_DATA_DIR=/var/lib/napseer`; persist that directory in a dedicated
|
||||
volume and mount source code separately at `/workspace`. Vaults, worker grants,
|
||||
logs, indexes, and process state belong to a worktree; repository signing keys
|
||||
are shared across related Git worktrees.
|
||||
|
||||
To migrate an existing gateway, stop its old process and retain the old volume.
|
||||
Mount that volume at `/workspace/.napseer` and the new persistent volume at
|
||||
`/var/lib/napseer`, then run the new image with
|
||||
`python3 /opt/napseer/napseer_mcp_server.py auth migrate` as its command.
|
||||
Start the service after migration succeeds. Conflicting state is preserved for
|
||||
explicit reconciliation. Do not run old and new gateway versions against the
|
||||
same credentials during migration.
|
||||
|
||||
`resources/scripts/napseer_credentials.py` vendors the CLI's private-state
|
||||
contract so the gateway image requires no runtime download. Keep its path and
|
||||
migration behavior aligned with the CLI when changing storage layout.
|
||||
|
||||
@@ -0,0 +1,433 @@
|
||||
"""User-owned credentials, separate from repository identity and gateway state.
|
||||
|
||||
No network, enrollment, or credential output belongs in this module.
|
||||
"""
|
||||
from contextlib import contextmanager
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
|
||||
|
||||
class CredentialError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
CREDENTIAL_KEYS = {
|
||||
"base_url", "account_id", "token", "access_token", "refresh_token",
|
||||
"token_expires_at", "refresh_expires_at", "account_mode", "oauth_client_id",
|
||||
"oauth_scope", "logged_out", "credential_kind",
|
||||
}
|
||||
SELECTION_KEYS = {
|
||||
"project_id", "project_slug", "project_name", "project_encryption_state",
|
||||
"project_signing_key_fingerprint", "credential_profile",
|
||||
}
|
||||
_registry_lock = threading.Lock()
|
||||
_thread_locks = {}
|
||||
_held = threading.local()
|
||||
|
||||
|
||||
def user_data_dir():
|
||||
if os.environ.get("NAPSEER_USER_DATA_DIR"):
|
||||
path = Path(os.environ["NAPSEER_USER_DATA_DIR"]).expanduser()
|
||||
if not path.is_absolute():
|
||||
raise CredentialError("NAPSEER_USER_DATA_DIR must be an absolute path outside the checkout.")
|
||||
return path.resolve()
|
||||
if sys.platform == "win32":
|
||||
return Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local")) / "napseer"
|
||||
if sys.platform == "darwin":
|
||||
return Path.home() / "Library" / "Application Support" / "napseer"
|
||||
xdg = Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share"))
|
||||
return (xdg if xdg.is_absolute() else Path.home() / ".local" / "share") / "napseer"
|
||||
|
||||
|
||||
class WorkspacePaths:
|
||||
"""Pure path resolution: Git identity survives worktree moves, not copies.
|
||||
|
||||
The public locator stays in Git. Repository credentials/signing keys are
|
||||
shared by related worktrees; daemon/vault/cache state belongs to one worktree.
|
||||
No credentials are read and no directory is created during path discovery.
|
||||
"""
|
||||
def __init__(self, root=None):
|
||||
start = Path(root or os.environ.get("NAPSEER_PROJECT_ROOT") or Path.cwd()).expanduser().resolve()
|
||||
self.root = start
|
||||
git_dir = common = None
|
||||
environment = {k:v for k,v in os.environ.items() if k not in
|
||||
{"GIT_DIR", "GIT_WORK_TREE", "GIT_COMMON_DIR", "GIT_INDEX_FILE"}}
|
||||
try:
|
||||
result = subprocess.run(["git", "-C", str(start), "rev-parse", "--path-format=absolute",
|
||||
"--show-toplevel", "--git-dir", "--git-common-dir"],
|
||||
capture_output=True, text=True, timeout=3, env=environment)
|
||||
if result.returncode == 0:
|
||||
top, git_dir, common = result.stdout.strip().splitlines()
|
||||
self.root = Path(top).resolve()
|
||||
except (OSError, ValueError, subprocess.TimeoutExpired):
|
||||
pass
|
||||
# A nested project's explicit locator is a separate namespace within Git.
|
||||
for candidate in (start, *start.parents):
|
||||
if (candidate / ".napseer/project.json").is_file():
|
||||
self.root = candidate
|
||||
break
|
||||
if git_dir and candidate == self.root:
|
||||
break
|
||||
suffix = str(self.root.relative_to(Path(top).resolve())) if git_dir else ""
|
||||
repo_identity = str(Path(common).resolve()) + ":" + suffix if common else str(self.root)
|
||||
worktree_identity = str(Path(git_dir).resolve()) + ":" + suffix if git_dir else str(self.root)
|
||||
self.repository_id = hashlib.sha256(repo_identity.encode()).hexdigest()
|
||||
self.workspace_id = hashlib.sha256(worktree_identity.encode()).hexdigest()
|
||||
self.data = user_data_dir().expanduser().resolve()
|
||||
if self.data.is_relative_to(self.root) and self.root != Path.home().resolve():
|
||||
raise CredentialError("Napseer user data must be outside the project checkout.")
|
||||
self.legacy = self.root / ".napseer"
|
||||
self.locator = self.legacy / "project.json"
|
||||
self.repository = self.data / "repositories" / self.repository_id
|
||||
self.state = self.data / "workspaces" / self.workspace_id
|
||||
self.signing_key = self.repository / "id_ed25519"
|
||||
self.project_credentials = self.data / "credentials/projects" / (self.repository_id + ".json")
|
||||
old_id = hashlib.sha256(str(self.root).encode()).hexdigest()
|
||||
self.old_credentials = self.data / "credentials/projects" / (old_id + ".json")
|
||||
|
||||
def migration_sources(self):
|
||||
if self.legacy.is_symlink():
|
||||
raise CredentialError("Legacy Napseer state must not be a symbolic link.")
|
||||
sources = []
|
||||
if self.legacy.exists():
|
||||
sources = [p for p in self.legacy.iterdir() if p.name not in {"project.json", ".gitignore"}]
|
||||
if self.old_credentials != self.project_credentials and self.old_credentials.exists():
|
||||
sources.append(self.old_credentials)
|
||||
return sources
|
||||
|
||||
def require_migrated(self):
|
||||
if self.migration_sources():
|
||||
raise CredentialError("Repository-local Napseer state needs migration. Stop older MCP clients and local gateways, then run `nap auth migrate`.")
|
||||
|
||||
def migrate(self, *, gateway=False):
|
||||
"""Copy/verify/publish before removing originals; never overwrite conflicts.
|
||||
|
||||
Older processes must be stopped before this explicit operation. Existing
|
||||
daemon PIDs are checked. A partial copy can be retried safely; all source
|
||||
bytes remain until every destination has been verified.
|
||||
"""
|
||||
with credential_lock(self.data / "migration"):
|
||||
sources = self.migration_sources()
|
||||
if not sources:
|
||||
return {"status":"already_migrated", "state_dir":str(self.state)}
|
||||
for name in ("gateway.json", "ui.json"):
|
||||
state = read_json(self.legacy / name)
|
||||
pid = state.get("pid")
|
||||
if isinstance(pid, int) and pid > 0:
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
except ProcessLookupError:
|
||||
continue
|
||||
except OSError:
|
||||
pass
|
||||
raise CredentialError("Stop the running local gateway/UI before `nap auth migrate`.")
|
||||
entries = []
|
||||
for source in sources:
|
||||
if source.is_symlink() or not source.is_file():
|
||||
raise CredentialError("Migration requires regular files; original state was preserved.")
|
||||
destination = self.state / source.name
|
||||
if source.name in {"id_ed25519", "id_ed25519.pub"}:
|
||||
destination = self.repository / source.name
|
||||
if source.name == "auth.json" or source == self.old_credentials:
|
||||
destination = (self.state / "auth.json") if gateway and source.name == "auth.json" else self.project_credentials
|
||||
original = file_fingerprint(source)
|
||||
copied = None
|
||||
if source.name in {"auth.json", "gateway-auth.json", "state.json"} or source == self.old_credentials:
|
||||
data = read_json(source)
|
||||
for field, old, new in (
|
||||
("ssh_key_path", self.legacy / "id_ed25519", self.signing_key),
|
||||
("vault_path", self.legacy / "vault.json", self.state / "vault.json"),
|
||||
):
|
||||
value = data.get(field)
|
||||
if isinstance(value, str):
|
||||
candidate = Path(value).expanduser()
|
||||
candidate = candidate if candidate.is_absolute() else self.root / candidate
|
||||
if candidate.resolve() == old.resolve(): data[field] = str(new)
|
||||
copied = (json.dumps(data, indent=2) + "\n").encode()
|
||||
if destination.is_symlink():
|
||||
raise CredentialError("Migration destination must not be a symbolic link.")
|
||||
expected = (len(copied), hashlib.sha256(copied).hexdigest()) if copied is not None else original
|
||||
if destination.exists() and file_fingerprint(destination) != expected:
|
||||
raise CredentialError("Migration conflicts with existing private state; nothing was overwritten. Reconcile the two states before retrying.")
|
||||
if any(dst == destination and digest != expected for _,dst,_,_,digest in entries):
|
||||
raise CredentialError("Legacy credentials conflict; original state was preserved.")
|
||||
entries.append((source, destination, original, copied, expected))
|
||||
# Publish credentials last, after their keys and other state exist.
|
||||
entries.sort(key=lambda e: e[1] in {self.project_credentials, self.state / "auth.json"})
|
||||
for source, destination, original, copied, expected in entries:
|
||||
private_directory(destination.parent, self.data)
|
||||
if not destination.exists():
|
||||
fd, temporary = tempfile.mkstemp(prefix=".migrate-", dir=destination.parent)
|
||||
try:
|
||||
with os.fdopen(fd, "wb") as stream:
|
||||
os.fchmod(stream.fileno(), 0o600)
|
||||
if copied is not None:
|
||||
stream.write(copied)
|
||||
else:
|
||||
with source.open("rb") as original_stream:
|
||||
while chunk := original_stream.read(1024 * 1024):
|
||||
stream.write(chunk)
|
||||
stream.flush(); os.fsync(stream.fileno())
|
||||
# Publish without replacing another login or migration's file.
|
||||
try:
|
||||
os.link(temporary, destination)
|
||||
except FileExistsError:
|
||||
if file_fingerprint(destination) != expected:
|
||||
raise CredentialError("Migration destination changed; original state was preserved.") from None
|
||||
finally:
|
||||
if os.path.exists(temporary): os.unlink(temporary)
|
||||
if file_fingerprint(destination) != expected or file_fingerprint(source) != original:
|
||||
raise CredentialError("State changed during migration; original files were preserved. Stop older clients and retry.")
|
||||
for source, destination, original, copied, expected in entries:
|
||||
if file_fingerprint(source) != original or file_fingerprint(destination) != expected:
|
||||
raise CredentialError("Migration verification failed; remaining originals were preserved.")
|
||||
# Make published directory entries durable before removing originals.
|
||||
# Windows does not support opening directories for fsync this way.
|
||||
if os.name != "nt":
|
||||
parents = set()
|
||||
for _, destination, _, _, _ in entries:
|
||||
for parent in (destination.parent, *destination.parent.parents):
|
||||
parents.add(parent)
|
||||
if parent == self.data: break
|
||||
for parent in sorted(parents, key=lambda p: len(p.parts), reverse=True):
|
||||
fd = os.open(parent, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
|
||||
try: os.fsync(fd)
|
||||
finally: os.close(fd)
|
||||
for source, _, _, _, _ in entries:
|
||||
source.unlink()
|
||||
return {"status":"migrated", "state_dir":str(self.state), "files":len(entries)}
|
||||
|
||||
|
||||
def private_directory(path, boundary):
|
||||
path, boundary = Path(path), Path(boundary)
|
||||
if not path.is_relative_to(boundary):
|
||||
raise CredentialError("Private state directory is outside its user data boundary.")
|
||||
for parent in (path, *path.parents):
|
||||
if parent.is_symlink():
|
||||
raise CredentialError("Private Napseer state must not use symlink directories.")
|
||||
if parent == boundary: break
|
||||
path.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
for parent in (path, *path.parents):
|
||||
parent.chmod(0o700)
|
||||
if parent == boundary: break
|
||||
|
||||
|
||||
def file_fingerprint(path):
|
||||
digest = hashlib.sha256()
|
||||
fd = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
|
||||
with os.fdopen(fd, "rb") as stream:
|
||||
if not stat.S_ISREG(os.fstat(stream.fileno()).st_mode):
|
||||
raise CredentialError("Migration requires regular files.")
|
||||
size = 0
|
||||
while chunk := stream.read(1024 * 1024):
|
||||
digest.update(chunk); size += len(chunk)
|
||||
return size, digest.hexdigest()
|
||||
|
||||
|
||||
def read_json(path):
|
||||
try:
|
||||
fd = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
|
||||
except FileNotFoundError:
|
||||
return {}
|
||||
except OSError:
|
||||
raise CredentialError("Credential state cannot be opened safely.") from None
|
||||
try:
|
||||
if not stat.S_ISREG(os.fstat(fd).st_mode):
|
||||
raise CredentialError("Credential state must be a regular file.")
|
||||
with os.fdopen(fd, "r", encoding="utf-8") as stream:
|
||||
fd = None
|
||||
raw = stream.read(65537)
|
||||
if len(raw) > 65536:
|
||||
raise CredentialError("Credential state exceeds the size limit.")
|
||||
result = json.loads(raw)
|
||||
if not isinstance(result, dict):
|
||||
raise ValueError()
|
||||
return result
|
||||
except (ValueError, UnicodeError, OSError):
|
||||
raise CredentialError("Credential state is unreadable; run nap auth login to repair it.") from None
|
||||
finally:
|
||||
if fd is not None:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
def atomic_json(path, data):
|
||||
path = Path(path)
|
||||
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
if path.is_symlink():
|
||||
raise CredentialError("Credential state must not be a symbolic link.")
|
||||
fd, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as stream:
|
||||
os.fchmod(stream.fileno(), 0o600)
|
||||
json.dump(data, stream, indent=2)
|
||||
stream.write("\n")
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
os.replace(temporary, path)
|
||||
finally:
|
||||
if os.path.exists(temporary):
|
||||
os.unlink(temporary)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def credential_lock(path, timeout=30):
|
||||
"""Bounded process lock; nested calls in the same thread reuse the lock."""
|
||||
path = Path(path).absolute()
|
||||
with _registry_lock:
|
||||
thread_lock = _thread_locks.setdefault(str(path), threading.RLock())
|
||||
if not thread_lock.acquire(timeout=timeout):
|
||||
raise CredentialError("Credential update is busy; retry after the other login or refresh finishes.")
|
||||
try:
|
||||
held = getattr(_held, "paths", {})
|
||||
if str(path) in held:
|
||||
yield
|
||||
return
|
||||
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
try:
|
||||
fd = os.open(str(path) + ".lock", os.O_RDWR | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0), 0o600)
|
||||
except OSError:
|
||||
raise CredentialError("Credential lock cannot be opened safely.") from None
|
||||
try:
|
||||
if not stat.S_ISREG(os.fstat(fd).st_mode):
|
||||
raise CredentialError("Credential lock must be a regular file.")
|
||||
deadline = time.monotonic() + timeout
|
||||
while True:
|
||||
try:
|
||||
if os.name == "nt":
|
||||
import msvcrt
|
||||
os.lseek(fd, 0, os.SEEK_SET)
|
||||
msvcrt.locking(fd, msvcrt.LK_NBLCK, 1)
|
||||
else:
|
||||
import fcntl
|
||||
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
break
|
||||
except (BlockingIOError, OSError):
|
||||
if time.monotonic() >= deadline:
|
||||
raise CredentialError("Credential update is busy; retry after the other login or refresh finishes.") from None
|
||||
time.sleep(0.025)
|
||||
held[str(path)] = fd
|
||||
_held.paths = held
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
held.pop(str(path), None)
|
||||
finally:
|
||||
os.close(fd)
|
||||
finally:
|
||||
thread_lock.release()
|
||||
|
||||
|
||||
class CredentialStore:
|
||||
def __init__(self, state_dir, legacy_path=None):
|
||||
self.workspace = WorkspacePaths(Path(state_dir).parent)
|
||||
self.state_dir = self.workspace.state
|
||||
self.legacy = Path(legacy_path or self.workspace.legacy / "auth.json").expanduser().absolute()
|
||||
self.user = user_data_dir() / "credentials" / "default.json"
|
||||
self.project = self.workspace.project_credentials
|
||||
self.selection = self.state_dir / "state.json"
|
||||
|
||||
def selected(self):
|
||||
bound = getattr(_held,"bindings",{}).get(str(self.state_dir.absolute()))
|
||||
if bound: return bound
|
||||
explicit = os.environ.get("NAPSEER_AUTH_FILE")
|
||||
if explicit:
|
||||
path = Path(explicit).expanduser().absolute()
|
||||
if path.is_symlink():
|
||||
raise CredentialError("Explicit credentials must not be a symbolic link.")
|
||||
if path.resolve().is_relative_to(self.workspace.root) and self.workspace.root != Path.home().resolve():
|
||||
raise CredentialError("NAPSEER_AUTH_FILE must be outside the checkout; migrate private state first.")
|
||||
return path, "explicit"
|
||||
if self.legacy != self.workspace.legacy / "auth.json":
|
||||
if self.legacy.resolve().is_relative_to(self.workspace.root) and self.workspace.root != Path.home().resolve():
|
||||
raise CredentialError("Explicit credentials must be outside the checkout; migrate private state first.")
|
||||
return self.legacy, "explicit"
|
||||
self.workspace.require_migrated()
|
||||
if read_json(self.selection).get("credential_profile") == "user":
|
||||
return self.user, "user"
|
||||
if self.project.exists() or self.project.is_symlink():
|
||||
return self.project, "project"
|
||||
return self.user, "user"
|
||||
|
||||
@contextmanager
|
||||
def transaction(self):
|
||||
"""Pin one credential source through read/refresh/write despite new overrides."""
|
||||
identity = str(self.state_dir.absolute())
|
||||
chosen = self.selected()
|
||||
with credential_lock(chosen[0]):
|
||||
bindings = getattr(_held,"bindings",{})
|
||||
previous = bindings.get(identity)
|
||||
bindings[identity] = chosen; _held.bindings = bindings
|
||||
try: yield
|
||||
finally:
|
||||
if previous: bindings[identity] = previous
|
||||
else: bindings.pop(identity,None)
|
||||
|
||||
def use_user(self):
|
||||
current = read_json(self.user)
|
||||
if not current.get("token") or current.get("logged_out"):
|
||||
raise CredentialError("Run nap auth login before selecting the general user login.")
|
||||
with credential_lock(self.selection):
|
||||
selection = read_json(self.selection)
|
||||
selection["credential_profile"] = "user"
|
||||
atomic_json(self.selection, selection)
|
||||
return self.user
|
||||
|
||||
def migrate(self):
|
||||
self.workspace.migrate()
|
||||
return self.project
|
||||
|
||||
def read(self):
|
||||
path, source = self.selected()
|
||||
credentials = read_json(path)
|
||||
selection = {k: v for k, v in read_json(self.selection).items() if k in SELECTION_KEYS}
|
||||
# Project-bound grants retain their issued project. General grants only
|
||||
# consume repository selection; selection cannot confer authority.
|
||||
return {**selection, **credentials}
|
||||
|
||||
def write(self, data):
|
||||
path, source = self.selected()
|
||||
with credential_lock(path):
|
||||
if source == "explicit":
|
||||
atomic_json(path, data)
|
||||
return
|
||||
credentials = {k: v for k, v in data.items() if k in CREDENTIAL_KEYS}
|
||||
if source == "project":
|
||||
# Preserve project-bound worker/recovery/key metadata during
|
||||
# refresh after legacy migration. Profile selection stays local
|
||||
# to the worktree and must not change sibling worktrees.
|
||||
credentials = {k: v for k, v in data.items() if k != "credential_profile"}
|
||||
current = read_json(path)
|
||||
# Reading project metadata must not churn or overwrite shared
|
||||
# credentials, including after another process logged out.
|
||||
if credentials != current:
|
||||
atomic_json(path, credentials)
|
||||
selection = {**read_json(self.selection), **{k: v for k, v in data.items() if k in SELECTION_KEYS}}
|
||||
if selection != read_json(self.selection):
|
||||
atomic_json(self.selection, selection)
|
||||
|
||||
def login(self, credentials, project=False):
|
||||
target = self.project if project else self.user
|
||||
with credential_lock(target):
|
||||
atomic_json(target, credentials)
|
||||
if project:
|
||||
with credential_lock(self.selection):
|
||||
selection = read_json(self.selection); selection["credential_profile"] = "project"
|
||||
atomic_json(self.selection, selection)
|
||||
return target
|
||||
|
||||
def logout(self, project=False):
|
||||
target = self.selected()[0] if project else self.user
|
||||
with credential_lock(target):
|
||||
previous = read_json(target)
|
||||
# Keep a tombstone: removing an override would silently restore the
|
||||
# more privileged user session on the next request.
|
||||
atomic_json(target, {"base_url": previous.get("base_url"), "logged_out": True})
|
||||
return target
|
||||
@@ -49,6 +49,8 @@ import webbrowser
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
from napseer_credentials import WorkspacePaths, credential_lock
|
||||
|
||||
from napseer_spake2 import (
|
||||
relay_aad,
|
||||
relay_context_hash,
|
||||
@@ -71,31 +73,17 @@ class RelaySocketClosed(RuntimeError):
|
||||
|
||||
|
||||
def local_state_dir():
|
||||
preferred = pathlib.Path.cwd() / ".napseer"
|
||||
legacy = pathlib.Path.cwd() / "napseer"
|
||||
if preferred.exists() or not legacy.exists():
|
||||
return preferred
|
||||
return legacy
|
||||
return WorkspacePaths().state
|
||||
|
||||
|
||||
def state_dir_status_payload():
|
||||
preferred = pathlib.Path.cwd() / ".napseer"
|
||||
legacy = pathlib.Path.cwd() / "napseer"
|
||||
active = AUTH_DIR
|
||||
if preferred.exists() and legacy.exists():
|
||||
message = "Both .napseer and legacy napseer directories exist; .napseer is active."
|
||||
elif active == legacy:
|
||||
message = "Legacy napseer directory is active because .napseer does not exist."
|
||||
else:
|
||||
message = ".napseer directory is active."
|
||||
return {
|
||||
"active_state_dir": str(active),
|
||||
"preferred_state_dir": str(preferred),
|
||||
"legacy_state_dir": str(legacy),
|
||||
"legacy_state_dir_active": active == legacy,
|
||||
"preferred_state_dir_exists": preferred.exists(),
|
||||
"legacy_state_dir_exists": legacy.exists(),
|
||||
"state_dir_message": message,
|
||||
"active_state_dir": str(AUTH_DIR),
|
||||
"state_dir": str(AUTH_DIR),
|
||||
"state_dir_exists": AUTH_DIR.exists(),
|
||||
"project_root": str(WORKSPACE.root),
|
||||
"state_dir_message": "Private gateway state is stored in user data.",
|
||||
"migration_required": bool(WORKSPACE.migration_sources()),
|
||||
}
|
||||
|
||||
|
||||
@@ -206,7 +194,11 @@ def send_telemetry_event_async(event, component, outcome="success", **fields):
|
||||
).start()
|
||||
|
||||
|
||||
AUTH_DIR = local_state_dir()
|
||||
WORKSPACE = WorkspacePaths()
|
||||
# The pre-dot-directory gateway layout is migration input only.
|
||||
if not WORKSPACE.legacy.exists() and any((WORKSPACE.root / "napseer" / name).is_file() for name in ("auth.json", "vault.json")):
|
||||
WORKSPACE.legacy = WORKSPACE.root / "napseer"
|
||||
AUTH_DIR = WORKSPACE.state
|
||||
AUTH_PATH = AUTH_DIR / "auth.json"
|
||||
VAULT_PATH = AUTH_DIR / "vault.json"
|
||||
GATEWAY_RELAY_SECRET_PATH = AUTH_DIR / "gateway-relay-secret.json"
|
||||
@@ -216,10 +208,10 @@ CONTAINER_IDENTITY_PATH = AUTH_DIR / "container-identity.json"
|
||||
TELEMETRY_PATH = AUTH_DIR / "telemetry.json"
|
||||
INDEX_PATH = AUTH_DIR / "index.sqlite"
|
||||
INDEX_LOCK_PATH = AUTH_DIR / "index.lock"
|
||||
DEFAULT_KEY_PATH = AUTH_DIR / "id_ed25519"
|
||||
DEFAULT_KEY_PATH = WORKSPACE.signing_key
|
||||
SCRIPT_PATH = pathlib.Path(__file__).resolve()
|
||||
SCRIPT_NAME = SCRIPT_PATH.name
|
||||
SCRIPT_DEPENDENCY_NAMES = ("napseer_spake2.py",)
|
||||
SCRIPT_DEPENDENCY_NAMES = ("napseer_spake2.py", "napseer_credentials.py")
|
||||
CONTRACT_VERSION = "napseer.mcp.contract.v1"
|
||||
TELEMETRY_DISABLED_VALUES = {"0", "false", "no", "off", "disabled"}
|
||||
TELEMETRY_ALLOWED_FIELDS = {
|
||||
@@ -687,7 +679,7 @@ def write_vault(passphrase, payload):
|
||||
key = _derive_vault_key(passphrase, salt, VAULT_KDF_ITERATIONS)
|
||||
plaintext = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||
nonce, ciphertext, tag = _encrypt_vault_payload(key, plaintext, VAULT_SCHEMA.encode("utf-8"))
|
||||
AUTH_DIR.mkdir(exist_ok=True)
|
||||
AUTH_DIR.mkdir(parents=True, exist_ok=True)
|
||||
chmod_best_effort(AUTH_DIR, 0o700)
|
||||
VAULT_PATH.write_text(
|
||||
json.dumps(
|
||||
@@ -746,7 +738,7 @@ def derive_gateway_relay_secret(passphrase):
|
||||
|
||||
|
||||
def write_gateway_relay_secret(passphrase):
|
||||
AUTH_DIR.mkdir(exist_ok=True)
|
||||
AUTH_DIR.mkdir(parents=True, exist_ok=True)
|
||||
chmod_best_effort(AUTH_DIR, 0o700)
|
||||
GATEWAY_RELAY_SECRET_PATH.write_text(
|
||||
json.dumps(
|
||||
@@ -802,7 +794,7 @@ def write_gateway_relay_state(**updates):
|
||||
current.update({key: value for key, value in updates.items() if value is not None})
|
||||
current["schema"] = GATEWAY_RELAY_STATE_SCHEMA
|
||||
current["updated_at"] = iso_now()
|
||||
AUTH_DIR.mkdir(exist_ok=True)
|
||||
AUTH_DIR.mkdir(parents=True, exist_ok=True)
|
||||
chmod_best_effort(AUTH_DIR, 0o700)
|
||||
GATEWAY_RELAY_STATE_PATH.write_text(json.dumps(current, indent=2) + "\n", encoding="utf-8")
|
||||
chmod_best_effort(GATEWAY_RELAY_STATE_PATH, 0o600)
|
||||
@@ -931,7 +923,7 @@ def ensure_container_uuid():
|
||||
"container_uuid": value,
|
||||
"created_at": iso_now(),
|
||||
}
|
||||
AUTH_DIR.mkdir(exist_ok=True)
|
||||
AUTH_DIR.mkdir(parents=True, exist_ok=True)
|
||||
chmod_best_effort(AUTH_DIR, 0o700)
|
||||
CONTAINER_IDENTITY_PATH.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
chmod_best_effort(CONTAINER_IDENTITY_PATH, 0o600)
|
||||
@@ -1164,7 +1156,7 @@ def clean_terminal_name(value, fallback="terminal"):
|
||||
|
||||
def default_gateway_session_name():
|
||||
source = AUTH.get("project_slug") or AUTH.get("project_name") or pathlib.Path.cwd().name or "project"
|
||||
return f"napseer-{clean_tmux_name(source, 'project')}"
|
||||
return f"napseer-{clean_tmux_name(source, 'project')}-{WORKSPACE.workspace_id[:10]}"
|
||||
|
||||
|
||||
def normalize_gateway_command(value=None):
|
||||
@@ -1727,7 +1719,7 @@ def write_gateway_schedules_state(state):
|
||||
state["schema"] = GATEWAY_SCHEDULES_SCHEMA
|
||||
state["version"] = 1
|
||||
state["updated_at"] = iso_now()
|
||||
AUTH_DIR.mkdir(exist_ok=True)
|
||||
AUTH_DIR.mkdir(parents=True, exist_ok=True)
|
||||
chmod_best_effort(AUTH_DIR, 0o700)
|
||||
GATEWAY_SCHEDULES_PATH.write_text(json.dumps(state, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
chmod_best_effort(GATEWAY_SCHEDULES_PATH, 0o600)
|
||||
@@ -5135,7 +5127,7 @@ def request_project_write(method, path, payload, project_id, purpose, scope_type
|
||||
|
||||
|
||||
def index_file_lock():
|
||||
AUTH_DIR.mkdir(exist_ok=True)
|
||||
AUTH_DIR.mkdir(parents=True, exist_ok=True)
|
||||
chmod_best_effort(AUTH_DIR, 0o700)
|
||||
handle = INDEX_LOCK_PATH.open("a+b")
|
||||
chmod_best_effort(INDEX_LOCK_PATH, 0o600)
|
||||
@@ -5153,7 +5145,7 @@ def release_index_file_lock(handle):
|
||||
|
||||
|
||||
def index_connect():
|
||||
AUTH_DIR.mkdir(exist_ok=True)
|
||||
AUTH_DIR.mkdir(parents=True, exist_ok=True)
|
||||
chmod_best_effort(AUTH_DIR, 0o700)
|
||||
conn = sqlite3.connect(str(INDEX_PATH), timeout=30)
|
||||
conn.row_factory = sqlite3.Row
|
||||
@@ -6032,20 +6024,21 @@ def save_auth(updates):
|
||||
|
||||
|
||||
def ensure_local_files():
|
||||
AUTH_DIR.mkdir(exist_ok=True)
|
||||
AUTH_DIR.mkdir(parents=True, exist_ok=True)
|
||||
chmod_best_effort(AUTH_DIR, 0o700)
|
||||
gitignore = AUTH_DIR / ".gitignore"
|
||||
if not gitignore.exists():
|
||||
gitignore.write_text("*\n!.gitignore\n", encoding="utf-8")
|
||||
key_path = pathlib.Path(AUTH["ssh_key_path"] or DEFAULT_KEY_PATH).expanduser()
|
||||
public_key_path = pathlib.Path(str(key_path) + ".pub")
|
||||
if not key_path.exists():
|
||||
key_path.parent.mkdir(exist_ok=True)
|
||||
chmod_best_effort(key_path.parent, 0o700)
|
||||
subprocess.run(
|
||||
["ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-f", str(key_path)],
|
||||
check=True,
|
||||
)
|
||||
with credential_lock(key_path):
|
||||
if not key_path.exists():
|
||||
key_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
chmod_best_effort(key_path.parent, 0o700)
|
||||
subprocess.run(
|
||||
["ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-f", str(key_path)],
|
||||
check=True,
|
||||
)
|
||||
chmod_best_effort(key_path, 0o600)
|
||||
if public_key_path.exists():
|
||||
chmod_best_effort(public_key_path, 0o644)
|
||||
@@ -9816,6 +9809,11 @@ def handle(message):
|
||||
|
||||
def cli_main(argv):
|
||||
command = argv[1] if len(argv) > 1 else ""
|
||||
if argv[1:] == ["auth", "migrate"]:
|
||||
print(json.dumps(WORKSPACE.migrate(gateway=True), indent=2))
|
||||
return
|
||||
if command not in {"help", "-h", "--help"}:
|
||||
WORKSPACE.require_migrated()
|
||||
if command in {"configure", "config"}:
|
||||
print(json.dumps(cli_configure_status(), indent=2))
|
||||
return
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Exercise gateway imports and its migration command using disposable state."""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
SCRIPT = Path(__file__).resolve().parents[1]/'resources/scripts/napseer_mcp_server.py'
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix='gateway-private-state-') as directory:
|
||||
root=Path(directory)/'workspace'; root.mkdir()
|
||||
data=Path(directory)/'data'
|
||||
env=dict(os.environ,NAPSEER_USER_DATA_DIR=str(data),NAPSEER_PROJECT_ROOT=str(root),NAPSEER_TELEMETRY='0')
|
||||
for name in ('NAPSEER_TOKEN','NAPSEER_REFRESH_TOKEN','NAPSEER_API_KEY','NAPSEER_AUTH_FILE'):
|
||||
env.pop(name,None)
|
||||
code="import sys; sys.path.insert(0,sys.argv[1]); import napseer_mcp_server as m; import json; print(json.dumps({'state':str(m.AUTH_DIR),'auth':str(m.AUTH_PATH),'key':str(m.DEFAULT_KEY_PATH)}))"
|
||||
def paths():
|
||||
result=subprocess.run([sys.executable,'-c',code,str(SCRIPT.parent)],cwd=root,env=env,capture_output=True,text=True,timeout=10)
|
||||
assert result.returncode==0,'gateway must load the private-state dependency'
|
||||
return {k:Path(v) for k,v in json.loads(result.stdout).items()}
|
||||
resolved=paths()
|
||||
assert all(path.is_relative_to(data) and not path.is_relative_to(root) for path in resolved.values())
|
||||
legacy=root/'.napseer';legacy.mkdir()
|
||||
(legacy/'auth.json').write_text(json.dumps({'token':'synthetic-worker-token','worker_id':'test-worker'}))
|
||||
(legacy/'id_ed25519').write_bytes(b'synthetic-signing-key')
|
||||
result=subprocess.run([sys.executable,str(SCRIPT),'auth','migrate'],cwd=root,env=env,capture_output=True,text=True,timeout=10)
|
||||
assert result.returncode==0,'gateway migration must complete without enrollment'
|
||||
assert json.loads(result.stdout)['status']=='migrated'
|
||||
assert 'synthetic-worker-token' not in result.stdout+result.stderr
|
||||
assert json.loads(resolved['auth'].read_text())['worker_id']=='test-worker'
|
||||
assert resolved['key'].read_bytes()==b'synthetic-signing-key'
|
||||
assert not list(legacy.iterdir())
|
||||
assert paths()==resolved
|
||||
|
||||
print('ok: gateway private state and migration stay outside the checkout')
|
||||
Reference in New Issue
Block a user