Persist gateway secrets and worktree state in user data
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user