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
|
&& dnf clean all
|
||||||
|
|
||||||
COPY resources/scripts/napseer_mcp_server.py /opt/napseer/napseer_mcp_server.py
|
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/napseer_spake2.py /opt/napseer/napseer_spake2.py
|
||||||
COPY resources/scripts/terminal/ /opt/napseer/terminal/
|
COPY resources/scripts/terminal/ /opt/napseer/terminal/
|
||||||
|
|
||||||
RUN python3 -m py_compile \
|
RUN python3 -m py_compile \
|
||||||
/opt/napseer/napseer_mcp_server.py \
|
/opt/napseer/napseer_mcp_server.py \
|
||||||
|
/opt/napseer/napseer_credentials.py \
|
||||||
/opt/napseer/napseer_spake2.py \
|
/opt/napseer/napseer_spake2.py \
|
||||||
/opt/napseer/terminal/__init__.py \
|
/opt/napseer/terminal/__init__.py \
|
||||||
/opt/napseer/terminal/protocol.py \
|
/opt/napseer/terminal/protocol.py \
|
||||||
@@ -27,6 +29,7 @@ ENV NAPSEER_GATEWAY_PORT=0
|
|||||||
ENV NAPSEER_GATEWAY_PTY_TERMINAL=1
|
ENV NAPSEER_GATEWAY_PTY_TERMINAL=1
|
||||||
ENV NAPSEER_SERVICE_ACTIVATION_TIMEOUT_SECONDS=900
|
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"]
|
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.
|
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`.
|
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 datetime import datetime, timedelta, timezone
|
||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
|
||||||
|
from napseer_credentials import WorkspacePaths, credential_lock
|
||||||
|
|
||||||
from napseer_spake2 import (
|
from napseer_spake2 import (
|
||||||
relay_aad,
|
relay_aad,
|
||||||
relay_context_hash,
|
relay_context_hash,
|
||||||
@@ -71,31 +73,17 @@ class RelaySocketClosed(RuntimeError):
|
|||||||
|
|
||||||
|
|
||||||
def local_state_dir():
|
def local_state_dir():
|
||||||
preferred = pathlib.Path.cwd() / ".napseer"
|
return WorkspacePaths().state
|
||||||
legacy = pathlib.Path.cwd() / "napseer"
|
|
||||||
if preferred.exists() or not legacy.exists():
|
|
||||||
return preferred
|
|
||||||
return legacy
|
|
||||||
|
|
||||||
|
|
||||||
def state_dir_status_payload():
|
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 {
|
return {
|
||||||
"active_state_dir": str(active),
|
"active_state_dir": str(AUTH_DIR),
|
||||||
"preferred_state_dir": str(preferred),
|
"state_dir": str(AUTH_DIR),
|
||||||
"legacy_state_dir": str(legacy),
|
"state_dir_exists": AUTH_DIR.exists(),
|
||||||
"legacy_state_dir_active": active == legacy,
|
"project_root": str(WORKSPACE.root),
|
||||||
"preferred_state_dir_exists": preferred.exists(),
|
"state_dir_message": "Private gateway state is stored in user data.",
|
||||||
"legacy_state_dir_exists": legacy.exists(),
|
"migration_required": bool(WORKSPACE.migration_sources()),
|
||||||
"state_dir_message": message,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -206,7 +194,11 @@ def send_telemetry_event_async(event, component, outcome="success", **fields):
|
|||||||
).start()
|
).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"
|
AUTH_PATH = AUTH_DIR / "auth.json"
|
||||||
VAULT_PATH = AUTH_DIR / "vault.json"
|
VAULT_PATH = AUTH_DIR / "vault.json"
|
||||||
GATEWAY_RELAY_SECRET_PATH = AUTH_DIR / "gateway-relay-secret.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"
|
TELEMETRY_PATH = AUTH_DIR / "telemetry.json"
|
||||||
INDEX_PATH = AUTH_DIR / "index.sqlite"
|
INDEX_PATH = AUTH_DIR / "index.sqlite"
|
||||||
INDEX_LOCK_PATH = AUTH_DIR / "index.lock"
|
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_PATH = pathlib.Path(__file__).resolve()
|
||||||
SCRIPT_NAME = SCRIPT_PATH.name
|
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"
|
CONTRACT_VERSION = "napseer.mcp.contract.v1"
|
||||||
TELEMETRY_DISABLED_VALUES = {"0", "false", "no", "off", "disabled"}
|
TELEMETRY_DISABLED_VALUES = {"0", "false", "no", "off", "disabled"}
|
||||||
TELEMETRY_ALLOWED_FIELDS = {
|
TELEMETRY_ALLOWED_FIELDS = {
|
||||||
@@ -687,7 +679,7 @@ def write_vault(passphrase, payload):
|
|||||||
key = _derive_vault_key(passphrase, salt, VAULT_KDF_ITERATIONS)
|
key = _derive_vault_key(passphrase, salt, VAULT_KDF_ITERATIONS)
|
||||||
plaintext = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
plaintext = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||||
nonce, ciphertext, tag = _encrypt_vault_payload(key, plaintext, VAULT_SCHEMA.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)
|
chmod_best_effort(AUTH_DIR, 0o700)
|
||||||
VAULT_PATH.write_text(
|
VAULT_PATH.write_text(
|
||||||
json.dumps(
|
json.dumps(
|
||||||
@@ -746,7 +738,7 @@ def derive_gateway_relay_secret(passphrase):
|
|||||||
|
|
||||||
|
|
||||||
def write_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)
|
chmod_best_effort(AUTH_DIR, 0o700)
|
||||||
GATEWAY_RELAY_SECRET_PATH.write_text(
|
GATEWAY_RELAY_SECRET_PATH.write_text(
|
||||||
json.dumps(
|
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.update({key: value for key, value in updates.items() if value is not None})
|
||||||
current["schema"] = GATEWAY_RELAY_STATE_SCHEMA
|
current["schema"] = GATEWAY_RELAY_STATE_SCHEMA
|
||||||
current["updated_at"] = iso_now()
|
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)
|
chmod_best_effort(AUTH_DIR, 0o700)
|
||||||
GATEWAY_RELAY_STATE_PATH.write_text(json.dumps(current, indent=2) + "\n", encoding="utf-8")
|
GATEWAY_RELAY_STATE_PATH.write_text(json.dumps(current, indent=2) + "\n", encoding="utf-8")
|
||||||
chmod_best_effort(GATEWAY_RELAY_STATE_PATH, 0o600)
|
chmod_best_effort(GATEWAY_RELAY_STATE_PATH, 0o600)
|
||||||
@@ -931,7 +923,7 @@ def ensure_container_uuid():
|
|||||||
"container_uuid": value,
|
"container_uuid": value,
|
||||||
"created_at": iso_now(),
|
"created_at": iso_now(),
|
||||||
}
|
}
|
||||||
AUTH_DIR.mkdir(exist_ok=True)
|
AUTH_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
chmod_best_effort(AUTH_DIR, 0o700)
|
chmod_best_effort(AUTH_DIR, 0o700)
|
||||||
CONTAINER_IDENTITY_PATH.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
CONTAINER_IDENTITY_PATH.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||||
chmod_best_effort(CONTAINER_IDENTITY_PATH, 0o600)
|
chmod_best_effort(CONTAINER_IDENTITY_PATH, 0o600)
|
||||||
@@ -1164,7 +1156,7 @@ def clean_terminal_name(value, fallback="terminal"):
|
|||||||
|
|
||||||
def default_gateway_session_name():
|
def default_gateway_session_name():
|
||||||
source = AUTH.get("project_slug") or AUTH.get("project_name") or pathlib.Path.cwd().name or "project"
|
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):
|
def normalize_gateway_command(value=None):
|
||||||
@@ -1727,7 +1719,7 @@ def write_gateway_schedules_state(state):
|
|||||||
state["schema"] = GATEWAY_SCHEDULES_SCHEMA
|
state["schema"] = GATEWAY_SCHEDULES_SCHEMA
|
||||||
state["version"] = 1
|
state["version"] = 1
|
||||||
state["updated_at"] = iso_now()
|
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)
|
chmod_best_effort(AUTH_DIR, 0o700)
|
||||||
GATEWAY_SCHEDULES_PATH.write_text(json.dumps(state, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
GATEWAY_SCHEDULES_PATH.write_text(json.dumps(state, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||||
chmod_best_effort(GATEWAY_SCHEDULES_PATH, 0o600)
|
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():
|
def index_file_lock():
|
||||||
AUTH_DIR.mkdir(exist_ok=True)
|
AUTH_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
chmod_best_effort(AUTH_DIR, 0o700)
|
chmod_best_effort(AUTH_DIR, 0o700)
|
||||||
handle = INDEX_LOCK_PATH.open("a+b")
|
handle = INDEX_LOCK_PATH.open("a+b")
|
||||||
chmod_best_effort(INDEX_LOCK_PATH, 0o600)
|
chmod_best_effort(INDEX_LOCK_PATH, 0o600)
|
||||||
@@ -5153,7 +5145,7 @@ def release_index_file_lock(handle):
|
|||||||
|
|
||||||
|
|
||||||
def index_connect():
|
def index_connect():
|
||||||
AUTH_DIR.mkdir(exist_ok=True)
|
AUTH_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
chmod_best_effort(AUTH_DIR, 0o700)
|
chmod_best_effort(AUTH_DIR, 0o700)
|
||||||
conn = sqlite3.connect(str(INDEX_PATH), timeout=30)
|
conn = sqlite3.connect(str(INDEX_PATH), timeout=30)
|
||||||
conn.row_factory = sqlite3.Row
|
conn.row_factory = sqlite3.Row
|
||||||
@@ -6032,15 +6024,16 @@ def save_auth(updates):
|
|||||||
|
|
||||||
|
|
||||||
def ensure_local_files():
|
def ensure_local_files():
|
||||||
AUTH_DIR.mkdir(exist_ok=True)
|
AUTH_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
chmod_best_effort(AUTH_DIR, 0o700)
|
chmod_best_effort(AUTH_DIR, 0o700)
|
||||||
gitignore = AUTH_DIR / ".gitignore"
|
gitignore = AUTH_DIR / ".gitignore"
|
||||||
if not gitignore.exists():
|
if not gitignore.exists():
|
||||||
gitignore.write_text("*\n!.gitignore\n", encoding="utf-8")
|
gitignore.write_text("*\n!.gitignore\n", encoding="utf-8")
|
||||||
key_path = pathlib.Path(AUTH["ssh_key_path"] or DEFAULT_KEY_PATH).expanduser()
|
key_path = pathlib.Path(AUTH["ssh_key_path"] or DEFAULT_KEY_PATH).expanduser()
|
||||||
public_key_path = pathlib.Path(str(key_path) + ".pub")
|
public_key_path = pathlib.Path(str(key_path) + ".pub")
|
||||||
|
with credential_lock(key_path):
|
||||||
if not key_path.exists():
|
if not key_path.exists():
|
||||||
key_path.parent.mkdir(exist_ok=True)
|
key_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
chmod_best_effort(key_path.parent, 0o700)
|
chmod_best_effort(key_path.parent, 0o700)
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-f", str(key_path)],
|
["ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-f", str(key_path)],
|
||||||
@@ -9816,6 +9809,11 @@ def handle(message):
|
|||||||
|
|
||||||
def cli_main(argv):
|
def cli_main(argv):
|
||||||
command = argv[1] if len(argv) > 1 else ""
|
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"}:
|
if command in {"configure", "config"}:
|
||||||
print(json.dumps(cli_configure_status(), indent=2))
|
print(json.dumps(cli_configure_status(), indent=2))
|
||||||
return
|
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