Skip to content
Closed
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
Empty file added cred_proxy/__init__.py
Empty file.
10 changes: 10 additions & 0 deletions cred_proxy/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
"""Entry point for ``python -m cred_proxy``.

Used by daemon.start() to spawn the server as a background process.
PID and port files are written inside _run_server() after both sockets
are bound, ensuring callers only see the daemon as ready once it is live.
"""

from cred_proxy.daemon import _run_server

_run_server()
135 changes: 135 additions & 0 deletions cred_proxy/cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
"""CLI for the credential proxy daemon.

Standalone entry point (``hermes-cred-proxy``):
hermes-cred-proxy start
hermes-cred-proxy stop
hermes-cred-proxy status
hermes-cred-proxy add <name> (prompts for value, never echoes it)
hermes-cred-proxy list

Also callable from the main hermes CLI as ``hermes cred-proxy <subcommand>``.
Use dispatch(args) for that path where args.cred_proxy_command is set.
"""

import argparse
import getpass
import sys


# ---------------------------------------------------------------------------
# Individual command implementations
# ---------------------------------------------------------------------------

def cmd_start(args=None) -> None:
from cred_proxy.daemon import start
start()


def cmd_stop(args=None) -> None:
from cred_proxy.daemon import stop
stop()


def cmd_status(args=None) -> None:
from cred_proxy.daemon import status
info = status()
if info["running"]:
print(f"running (PID {info['pid']})")
else:
print("stopped")
print(f"socket: {info['socket']}")


def cmd_add(args) -> None:
name = args.name
try:
value = getpass.getpass(f"Value for {name!r}: ")
except (KeyboardInterrupt, EOFError):
print("\nCancelled.")
sys.exit(1)
if not value:
print("Error: empty value not allowed.")
sys.exit(1)
from cred_proxy.store import CredStore
store = CredStore()
store.set(name, value)
print(f"Stored credential {name!r}.")


def cmd_list(args=None) -> None:
from cred_proxy.store import CredStore
store = CredStore()
names = store.list()
if not names:
print("(no credentials stored)")
else:
for n in names:
print(n)


# ---------------------------------------------------------------------------
# Dispatcher (used by hermes cred-proxy subcommand in main.py)
# ---------------------------------------------------------------------------

def dispatch(args) -> None:
"""Route args.cred_proxy_command to the appropriate handler."""
cmd = getattr(args, "cred_proxy_command", None)
if cmd == "start":
cmd_start(args)
elif cmd == "stop":
cmd_stop(args)
elif cmd == "status":
cmd_status(args)
elif cmd == "add":
cmd_add(args)
elif cmd == "list":
cmd_list(args)
else:
# No subcommand: print help
print("Usage: hermes cred-proxy {start,stop,status,add,list}")
print(" hermes-cred-proxy {start,stop,status,add,list}")


# ---------------------------------------------------------------------------
# Standalone argparse CLI (hermes-cred-proxy entry point)
# ---------------------------------------------------------------------------

def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="hermes-cred-proxy",
description="Hermes credential proxy — store and inject secrets into tool subprocesses",
)
subs = parser.add_subparsers(dest="subcommand", help="Command")

subs.add_parser("start", help="Start the credential proxy daemon")
subs.add_parser("stop", help="Stop the credential proxy daemon")
subs.add_parser("status", help="Show daemon status")

add_p = subs.add_parser("add", help="Add or update a named credential")
add_p.add_argument("name", help="Credential name (used in hermes-proxy://<name>)")

subs.add_parser("list", help="List stored credential names")

return parser


def main() -> None:
parser = _build_parser()
args = parser.parse_args()

if args.subcommand == "start":
cmd_start(args)
elif args.subcommand == "stop":
cmd_stop(args)
elif args.subcommand == "status":
cmd_status(args)
elif args.subcommand == "add":
cmd_add(args)
elif args.subcommand == "list":
cmd_list(args)
else:
parser.print_help()


if __name__ == "__main__":
main()
213 changes: 213 additions & 0 deletions cred_proxy/daemon.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
"""Credential proxy daemon lifecycle management.

start() — spawn the proxy as a detached background process, wait for PID
stop() — SIGTERM the daemon, remove PID file
status() — return {running, pid, socket, port}
is_running()— quick bool check used by other components
"""

import asyncio
import logging
import os
import signal
import socket
import sys
from pathlib import Path

from hermes_constants import get_hermes_home

_STATE_DIR = get_hermes_home() / "state"
_PID_FILE = _STATE_DIR / "cred-proxy.pid"
_PORT_FILE = _STATE_DIR / "cred-proxy.port"
_SOCK_PATH = _STATE_DIR / "cred-proxy.sock" # kept for status() compatibility
_LOG_FILE = _STATE_DIR / "cred-proxy.log"


# ---------------------------------------------------------------------------
# PID file helpers
# ---------------------------------------------------------------------------

def _write_pid() -> None:
_STATE_DIR.mkdir(parents=True, exist_ok=True)
_PID_FILE.write_text(str(os.getpid()))


def _read_pid() -> int | None:
try:
return int(_PID_FILE.read_text().strip())
except (FileNotFoundError, ValueError, OSError):
return None


def _remove_pid() -> None:
try:
_PID_FILE.unlink()
except FileNotFoundError:
pass


# ---------------------------------------------------------------------------
# Port file helpers
# ---------------------------------------------------------------------------

def _write_port(port: int) -> None:
_STATE_DIR.mkdir(parents=True, exist_ok=True)
_PORT_FILE.write_text(str(port))


def _read_port() -> int | None:
try:
return int(_PORT_FILE.read_text().strip())
except (FileNotFoundError, ValueError, OSError):
return None


def _remove_port() -> None:
try:
_PORT_FILE.unlink()
except FileNotFoundError:
pass


# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------

def is_running() -> bool:
"""Return True if the credential proxy daemon is running.

Checks that both PID file and port file exist and that the process is
alive. Removes stale files if the process is dead.
"""
pid = _read_pid()
if pid is None:
return False
port = _read_port()
if port is None:
return False
try:
os.kill(pid, 0)
return True
except ProcessLookupError:
# Process is dead — clean up stale files
_remove_pid()
_remove_port()
return False
except PermissionError:
# Process exists but we can't send signals — still running
return True


def status() -> dict:
"""Return {running: bool, pid: int|None, socket: str, port: int|None}."""
running = is_running()
return {
"running": running,
"pid": _read_pid() if running else None,
"socket": str(_SOCK_PATH),
"port": _read_port() if running else None,
}


def stop() -> None:
"""Send SIGTERM to the daemon and remove the PID and port files."""
pid = _read_pid()
if pid is None:
print("Credential proxy is not running.")
return
try:
os.kill(pid, signal.SIGTERM)
print(f"Stopped credential proxy (PID {pid}).")
except ProcessLookupError:
print("Credential proxy process not found (already stopped?).")
except PermissionError:
print(f"Permission denied when signalling PID {pid}.")
finally:
_remove_pid()
_remove_port()


def start() -> None:
"""Start the credential proxy daemon as a detached background process.

Spawns ``python -m cred_proxy`` with start_new_session=True so it
survives the calling process exiting. Waits up to 3 s for the daemon
to write its PID and port files before returning.
"""
if is_running():
print("Credential proxy is already running.")
return

import subprocess
import time

cmd = [sys.executable, "-m", "cred_proxy"]
try:
subprocess.Popen(
cmd,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
stdin=subprocess.DEVNULL,
start_new_session=True,
)
except Exception as exc:
print(f"Failed to start credential proxy: {exc}")
return

# Wait up to 3 s for the daemon to write its PID and port files
for _ in range(30):
time.sleep(0.1)
if is_running():
pid = _read_pid()
print(f"Credential proxy started (PID {pid}).")
return

print("Warning: Could not confirm credential proxy started. Check logs at:")
print(f" {_LOG_FILE}")


# ---------------------------------------------------------------------------
# Internal: run the server (called from __main__.py)
# ---------------------------------------------------------------------------

def _run_server() -> None:
"""Configure logging and run the mitmproxy server (blocks forever).

Pre-selects a free TCP port, then starts mitmproxy via DumpMaster.
Writes the PID and port files inside the mitmproxy 'running' hook so
callers polling is_running() only see the daemon as ready once it is
fully bound and listening.
"""
from .server import run_proxy

logging.basicConfig(
filename=str(_LOG_FILE),
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)

def _on_sigterm(signum, frame):
_remove_pid()
_remove_port()
sys.exit(0)

try:
signal.signal(signal.SIGTERM, _on_sigterm)
except (OSError, ValueError):
pass # Windows or restricted environment

# Pick a free port before handing off to mitmproxy
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("127.0.0.1", 0))
port = s.getsockname()[1]

def _on_started() -> None:
"""Called by mitmproxy's running hook once the proxy is listening."""
_write_pid()
_write_port(port)

try:
asyncio.run(run_proxy(port, on_started=_on_started))
finally:
_remove_pid()
_remove_port()
Loading
Loading