push-to-outpost 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,31 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - 'v*'
7
+
8
+ jobs:
9
+ publish:
10
+ runs-on: ubuntu-latest
11
+ permissions:
12
+ contents: read
13
+ id-token: write
14
+ steps:
15
+ - uses: actions/checkout@v4
16
+
17
+ - name: Set version from release tag
18
+ run: |
19
+ VERSION="${{ github.ref_name }}"
20
+ VERSION="${VERSION#v}"
21
+ sed -i "s/^version = .*/version = \"$VERSION\"/" pyproject.toml
22
+ echo "Set version to $VERSION"
23
+
24
+ - name: Install uv
25
+ uses: astral-sh/setup-uv@v5
26
+
27
+ - name: Build package
28
+ run: uv build
29
+
30
+ - name: Publish to PyPI
31
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,10 @@
1
+ local-agent/.env
2
+ __pycache__/
3
+ node_modules/
4
+ .wrangler/
5
+ .dev.vars
6
+ .cf.env
7
+ .venv/
8
+ worker-configuration.d.ts
9
+ web/*.js
10
+ dist/
@@ -0,0 +1,6 @@
1
+ Metadata-Version: 2.4
2
+ Name: push-to-outpost
3
+ Version: 0.1.0
4
+ Summary: Push local tmux session snapshots to the outpost web viewer
5
+ Requires-Python: >=3.11
6
+ Requires-Dist: cryptography>=42
@@ -0,0 +1,119 @@
1
+ # outpost
2
+
3
+ Read-only web viewer for your local tmux sessions and pushed docs. No inbound
4
+ connections to your machine — a local Python CLI pushes snapshots and docs
5
+ out over HTTPS, and a Cloudflare Worker (with static assets, backed by D1)
6
+ reads them back. The site itself is gated by Cloudflare Access (your login),
7
+ so there's no app-level login code. Push API keys are minted and revoked
8
+ from the website itself.
9
+
10
+ ## How it fits together
11
+
12
+ ```
13
+ tmux sessions --(capture-pane)--> outpost CLI --(HTTPS POST, outbound)--> /api/push (key-gated, checked against D1)
14
+ |
15
+ Cloudflare D1
16
+ |
17
+ /api/sessions, /api/keys (worker/index.ts) <-- gated by Cloudflare Access
18
+ |
19
+ web/ (static assets, served by the Worker)
20
+ ```
21
+
22
+ ## Setup
23
+
24
+ 1. **Log in to Cloudflare**: `npx wrangler login`, or set `CLOUDFLARE_API_TOKEN`
25
+ in `.cf.env` (gitignored) if the browser OAuth flow doesn't work for your
26
+ setup (e.g. WSL2 localhost-forwarding issues).
27
+ 2. **Create the D1 database**: `npm run db:create`. Copy the `database_id`
28
+ it prints into `wrangler.toml` (replacing the placeholder).
29
+ 2b. **Enable R2** (Dashboard → R2 — one-time acceptance, needed before the
30
+ bucket create call will work) and create the docs bucket:
31
+ `npx wrangler r2 bucket create outpost-docs`. Used for zip doc content
32
+ (`push-doc bundle.zip`) — markdown/html docs stay in D1.
33
+ 3. **Apply the schema locally and remotely**:
34
+ ```
35
+ npm run db:migrate
36
+ npm run db:migrate:remote
37
+ ```
38
+ 4. **Deploy**: `npm run deploy`. First run creates the Worker and gives you a
39
+ `*.workers.dev` URL.
40
+ 5. **Gate the site with Cloudflare Access** (Zero Trust → Access →
41
+ Applications — if Zero Trust won't enable itself from the dashboard, that
42
+ was a real bug for us and needed a support ticket):
43
+ - Application for `outpost.<subdomain>.workers.dev`, path `/*`,
44
+ policy: allow your own email only.
45
+ - A **second** application scoped to path `/api/push` with a **Bypass**
46
+ policy — Access matches the most specific path, so this keeps the push
47
+ endpoint open (it's key-gated instead, since the local agent can't do
48
+ an interactive login). `outpost push-doc` reuses this same endpoint
49
+ (as `op: "push-doc"`), so no separate Access application is needed for it.
50
+ - Make sure at least one identity provider (e.g. "One-time PIN") exists
51
+ under Settings → Authentication, or login will fail with "no login
52
+ methods available".
53
+ 6. **Install the CLI and log in**:
54
+ ```
55
+ uv sync
56
+ uv run outpost login
57
+ ```
58
+ This opens the site in your browser — sign in, click **API keys**, generate
59
+ one, and paste the secret back into the terminal prompt (input hidden). No
60
+ OAuth/localhost-callback flow involved, just copy-paste, so it works even
61
+ where browser-to-WSL localhost forwarding doesn't. The key is verified
62
+ against the server before being saved.
63
+ 7. **Run it**:
64
+ ```
65
+ uv run outpost push # one-off push
66
+ uv run outpost run # loop forever, pushing every 15s
67
+ ```
68
+ Keep `outpost run` running (its own tmux session, or a systemd/launchd
69
+ service).
70
+
71
+ Alongside tmux panes, each cycle also scans `~/.claude/projects/*/*.jsonl`
72
+ for Claude Code session transcripts modified in the last hour (`SESSION_MAX_AGE_MINUTES`,
73
+ default 60) and pushes any that changed as markdown docs — full
74
+ conversation history straight from Claude Code's own transcript, not a
75
+ scrape of whatever the terminal happened to redraw. This catches every
76
+ active session on the machine, not just ones visible in a tracked tmux
77
+ pane. Docs are keyed by session id, so re-pushes update in place, and (like
78
+ all docs) they expire 24h after the last push if the session goes idle
79
+ past `SESSION_MAX_AGE_MINUTES`.
80
+ 8. **Push a doc** (optional): separately from tmux panes, you can push a
81
+ markdown, HTML, or zip file to render on its own in the site:
82
+ ```
83
+ uv run outpost push-doc notes.md
84
+ uv run outpost push-doc report.html --title "Weekly report"
85
+ uv run outpost push-doc bundle.zip
86
+ ```
87
+ It shows up under **Docs** in the sidebar, and expires automatically 24h
88
+ after its last push (same expiry applies to tmux pane snapshots — re-push
89
+ to keep something alive). Markdown/HTML render inside a
90
+ sandboxed `<iframe>` (no `allow-same-origin`, so injected content can't
91
+ reach the page's localStorage or the cached decryption key even if a
92
+ pushed doc turns out to be hostile); zip files are decrypted locally and
93
+ browsable file-by-file (parsed client-side, not extracted server-side —
94
+ the server only ever sees the encrypted blob, stored in R2), with a
95
+ whole-archive download always available too. Re-running `push-doc` on the
96
+ same file (same slugified filename, or `--id`) updates the existing doc
97
+ rather than creating a new one.
98
+ Note: the window actually running `outpost run` is automatically
99
+ excluded from what it pushes — it recognizes its own startup banner in
100
+ its own scrollback, so its noisy per-cycle log output never counts as a
101
+ "changed" window. No configuration needed.
102
+
103
+ ## Notes
104
+
105
+ - **Requires [wincred](https://github.com/vivainio/wincred)** (`wincred.exe`
106
+ on `PATH`) — this CLI only supports WSL2 + Windows Credential Manager for
107
+ storing config. There's no env-file fallback: `outpost login` writes
108
+ `TOWER_URL` and `PUSH_SECRET` as one JSON credential under
109
+ `outpost:config`, and `push`/`run` read it back from there. `PUSH_INTERVAL`
110
+ / `CAPTURE_LINES` can still be set as plain env vars if you want to override
111
+ the defaults (15s / 2000 lines).
112
+ - API keys are stored in D1 as SHA-256 hashes only; the plaintext secret is
113
+ shown once at creation and never persisted. Revoke a key anytime from the
114
+ **API keys** panel on the site.
115
+ - The frontend has no login code — Cloudflare Access rejects unauthenticated
116
+ requests before they reach any Pages Function.
117
+ - If a Cloudflare-fronted request gets a mysterious `403` with body
118
+ `error code: 1010`, that's bot protection blocking the client's User-Agent
119
+ — the CLI already sets a custom one (`outpost-agent/1.0`).
File without changes
@@ -0,0 +1,4 @@
1
+ from outpost.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -0,0 +1,191 @@
1
+ import base64
2
+ import hashlib
3
+ import json
4
+ import subprocess
5
+ import urllib.error
6
+ import urllib.request
7
+ from typing import TypedDict
8
+
9
+ from outpost import crypto
10
+ from outpost.config import Config
11
+
12
+
13
+ class WindowInfo(TypedDict):
14
+ session_name: str
15
+ window_index: int
16
+ window_name: str
17
+ window_active: bool
18
+ session_attached: bool
19
+
20
+
21
+ def list_windows() -> list[WindowInfo]:
22
+ result = subprocess.run(
23
+ ["tmux", "list-windows", "-a", "-F",
24
+ "#{session_name}\t#{window_index}\t#{window_name}\t#{window_active}\t#{session_attached}"],
25
+ capture_output=True, text=True, timeout=5,
26
+ )
27
+ if result.returncode != 0:
28
+ return []
29
+ windows: list[WindowInfo] = []
30
+ for line in result.stdout.strip().splitlines():
31
+ session_name, window_index, window_name, window_active, session_attached = line.split("\t")
32
+ windows.append({
33
+ "session_name": session_name,
34
+ "window_index": int(window_index),
35
+ "window_name": window_name,
36
+ "window_active": window_active == "1",
37
+ "session_attached": session_attached == "1",
38
+ })
39
+ return windows
40
+
41
+
42
+ def capture(session_name: str, window_index: int, lines: int) -> str:
43
+ target = f"{session_name}:{window_index}"
44
+ result = subprocess.run(
45
+ ["tmux", "capture-pane", "-e", "-p", "-t", target, "-S", f"-{lines}"],
46
+ capture_output=True, text=True, timeout=5,
47
+ )
48
+ return result.stdout if result.returncode == 0 else ""
49
+
50
+
51
+ def _row_hash(window_name: str, window_active: bool, session_attached: bool, content: str) -> str:
52
+ return hashlib.sha256(f"{window_name}\t{window_active}\t{session_attached}\t{content}".encode()).hexdigest()
53
+
54
+
55
+ # Tracks the last-pushed hash per pane so `run`'s loop can skip panes whose
56
+ # content hasn't changed since the previous cycle. Only meaningful within a
57
+ # single long-running process — each `push` invocation starts fresh.
58
+ _last_hashes: dict[str, str] = {}
59
+
60
+
61
+ def push_once(config: Config) -> int:
62
+ if not config.encryption_key:
63
+ raise SystemExit(
64
+ "No encryption password set. Run `outpost set-password` before pushing "
65
+ "(pane content must be encrypted before it leaves this machine)."
66
+ )
67
+
68
+ windows = list_windows()
69
+
70
+ # `outpost run` prints this exact banner once at startup, so any pane
71
+ # currently running it (i.e. still showing the banner in its scrollback
72
+ # window) can be recognized from its own captured content — no manual
73
+ # tagging needed to keep a push agent's own noisy log out of the tower.
74
+ self_signature = f"pushing to {config.tower_url} every "
75
+
76
+ key = base64.b64decode(config.encryption_key)
77
+
78
+ live = []
79
+ current_hashes: dict[str, str] = {}
80
+ changes = []
81
+ for w in windows:
82
+ pane_id = f"{w['session_name']}:{w['window_index']}"
83
+ content = capture(w["session_name"], w["window_index"], config.capture_lines)
84
+ if self_signature in content:
85
+ continue
86
+ live.append(pane_id)
87
+ # Hash the plaintext so change detection isn't defeated by encryption's
88
+ # random per-push IV producing a different ciphertext each time.
89
+ row_hash = _row_hash(w["window_name"], w["window_active"], w["session_attached"], content)
90
+ current_hashes[pane_id] = row_hash
91
+ if _last_hashes.get(pane_id) != row_hash:
92
+ changes.append({
93
+ "pane_id": pane_id,
94
+ "session_name": w["session_name"],
95
+ "window_index": w["window_index"],
96
+ "window_name": w["window_name"],
97
+ "window_active": w["window_active"],
98
+ "session_attached": w["session_attached"],
99
+ "content": crypto.encrypt(content, key),
100
+ "encrypted": True,
101
+ })
102
+
103
+ if not changes and set(current_hashes) == set(_last_hashes):
104
+ return 0 # nothing changed, nothing closed — skip the network call
105
+
106
+ req = urllib.request.Request(
107
+ f"{config.tower_url}/api/push",
108
+ data=json.dumps({"op": "push-tmux", "live": live, "changes": changes}).encode("utf-8"),
109
+ headers={
110
+ "Authorization": f"Bearer {config.push_secret}",
111
+ "Content-Type": "application/json",
112
+ "User-Agent": "outpost-agent/1.0",
113
+ },
114
+ method="POST",
115
+ )
116
+ with urllib.request.urlopen(req, timeout=10) as resp:
117
+ resp.read()
118
+
119
+ _last_hashes.clear()
120
+ _last_hashes.update(current_hashes)
121
+ return len(changes)
122
+
123
+
124
+ def verify_key(tower_url: str, push_secret: str) -> bool:
125
+ req = urllib.request.Request(
126
+ f"{tower_url.rstrip('/')}/api/push",
127
+ data=json.dumps({"op": "push-tmux", "live": [], "changes": []}).encode("utf-8"),
128
+ headers={
129
+ "Authorization": f"Bearer {push_secret}",
130
+ "Content-Type": "application/json",
131
+ "User-Agent": "outpost-agent/1.0",
132
+ },
133
+ method="POST",
134
+ )
135
+ try:
136
+ with urllib.request.urlopen(req, timeout=10) as resp:
137
+ return resp.status == 200
138
+ except urllib.error.HTTPError:
139
+ return False
140
+
141
+
142
+ def push_doc(config: Config, doc_id: str, title: str, doc_format: str, content: str) -> None:
143
+ # Rides /api/push with op="push-doc" (rather than a separate endpoint)
144
+ # so doc pushes reuse the same Cloudflare Access Bypass policy as pane
145
+ # pushes (op="push-tmux").
146
+ if not config.encryption_key:
147
+ raise SystemExit(
148
+ "No encryption password set. Run `outpost set-password` before pushing "
149
+ "(doc content must be encrypted before it leaves this machine)."
150
+ )
151
+
152
+ key = base64.b64decode(config.encryption_key)
153
+ body = {
154
+ "op": "push-doc",
155
+ "doc_id": doc_id,
156
+ "title": title,
157
+ "format": doc_format,
158
+ "content": crypto.encrypt(content, key),
159
+ "encrypted": True,
160
+ }
161
+ req = urllib.request.Request(
162
+ f"{config.tower_url}/api/push",
163
+ data=json.dumps(body).encode("utf-8"),
164
+ headers={
165
+ "Authorization": f"Bearer {config.push_secret}",
166
+ "Content-Type": "application/json",
167
+ "User-Agent": "outpost-agent/1.0",
168
+ },
169
+ method="POST",
170
+ )
171
+ with urllib.request.urlopen(req, timeout=10) as resp:
172
+ resp.read()
173
+
174
+
175
+ def fetch_encryption_salt(tower_url: str, push_secret: str) -> tuple[str, int]:
176
+ # Rides /api/push with op="get-salt" (rather than GET /api/encryption-salt)
177
+ # so this reaches the server through the same key-gated Access Bypass as
178
+ # every other agent call, instead of needing its own Access exemption.
179
+ req = urllib.request.Request(
180
+ f"{tower_url.rstrip('/')}/api/push",
181
+ data=json.dumps({"op": "get-salt"}).encode("utf-8"),
182
+ headers={
183
+ "Authorization": f"Bearer {push_secret}",
184
+ "Content-Type": "application/json",
185
+ "User-Agent": "outpost-agent/1.0",
186
+ },
187
+ method="POST",
188
+ )
189
+ with urllib.request.urlopen(req, timeout=10) as resp:
190
+ data = json.loads(resp.read())
191
+ return data["salt"], data["kdf_iterations"]
@@ -0,0 +1,165 @@
1
+ import argparse
2
+ import base64
3
+ import re
4
+ import sys
5
+ import time
6
+ import urllib.error
7
+ import webbrowser
8
+ from pathlib import Path
9
+
10
+ from outpost.agent import fetch_encryption_salt, push_doc, push_once, verify_key
11
+ from outpost.config import Config, save_credentials
12
+ from outpost.crypto import derive_key
13
+ from outpost.sessions import push_sessions
14
+
15
+ DEFAULT_TOWER_URL = "https://outpost.vivainio.workers.dev"
16
+
17
+ FORMAT_BY_SUFFIX = {
18
+ ".md": "markdown",
19
+ ".markdown": "markdown",
20
+ ".html": "html",
21
+ ".htm": "html",
22
+ ".zip": "zip",
23
+ }
24
+
25
+
26
+ def _slugify(text: str) -> str:
27
+ slug = re.sub(r"[^a-zA-Z0-9]+", "-", text).strip("-").lower()
28
+ return slug or "doc"
29
+
30
+
31
+ def cmd_login(args: argparse.Namespace) -> None:
32
+ tower_url = args.tower_url.rstrip("/")
33
+ print(f"Opening {tower_url} — sign in, click \"API keys\", then generate one.")
34
+ webbrowser.open(tower_url)
35
+ while True:
36
+ secret = input("Paste the API key here: ").strip()
37
+ if not secret:
38
+ print("No key entered, try again (ctrl-c to abort).")
39
+ continue
40
+ if not re.fullmatch(r"[0-9a-fA-F]+", secret):
41
+ print("That doesn't look like a valid key (unexpected characters) — check your paste and try again.")
42
+ continue
43
+ print("Verifying key...")
44
+ try:
45
+ valid = verify_key(tower_url, secret)
46
+ except (urllib.error.URLError, OSError) as exc:
47
+ print(f"Couldn't reach the server ({exc}) — try again.")
48
+ continue
49
+ if not valid:
50
+ print("That key was rejected by the server. Check it and try again.")
51
+ continue
52
+ break
53
+ location = save_credentials(tower_url, secret)
54
+ print(f"Saved to {location}. You can now run: outpost run")
55
+
56
+
57
+ def cmd_set_password(args: argparse.Namespace) -> None:
58
+ config = Config.from_env()
59
+ print("Fetching encryption salt...")
60
+ try:
61
+ salt, iterations = fetch_encryption_salt(config.tower_url, config.push_secret)
62
+ except (urllib.error.URLError, OSError) as exc:
63
+ raise SystemExit(f"Couldn't reach the server ({exc}). Try again.")
64
+
65
+ while True:
66
+ password = input("Password (same one you'll enter on the website): ").strip()
67
+ if password:
68
+ break
69
+ print("No password entered, try again (ctrl-c to abort).")
70
+
71
+ key = derive_key(password, salt, iterations)
72
+ encryption_key = base64.b64encode(key).decode()
73
+ location = save_credentials(config.tower_url, config.push_secret, encryption_key=encryption_key)
74
+ print(f"Saved to {location}. Pane content will now be encrypted before it's pushed.")
75
+
76
+
77
+ def cmd_push(args: argparse.Namespace) -> None:
78
+ config = Config.from_env()
79
+ count = push_once(config)
80
+ session_count = push_sessions(config)
81
+ print(f"pushed {count} window(s) and {session_count} session(s) to {config.tower_url}")
82
+
83
+
84
+ def cmd_push_doc(args: argparse.Namespace) -> None:
85
+ path = Path(args.path)
86
+ if not path.is_file():
87
+ raise SystemExit(f"No such file: {path}")
88
+
89
+ doc_format = args.format or FORMAT_BY_SUFFIX.get(path.suffix.lower())
90
+ if not doc_format:
91
+ raise SystemExit(
92
+ f"Can't infer format from {path.suffix!r} — pass --format {{markdown,html,zip}} explicitly."
93
+ )
94
+
95
+ title = args.title or path.name
96
+ doc_id = args.id or _slugify(path.stem)
97
+
98
+ if doc_format == "zip":
99
+ content = base64.b64encode(path.read_bytes()).decode()
100
+ else:
101
+ content = path.read_text(encoding="utf-8")
102
+
103
+ config = Config.from_env()
104
+ push_doc(config, doc_id, title, doc_format, content)
105
+ print(f"pushed doc {doc_id!r} ({doc_format}) to {config.tower_url}")
106
+
107
+
108
+ def cmd_run(args: argparse.Namespace) -> None:
109
+ config = Config.from_env()
110
+ print(f"pushing to {config.tower_url} every {config.push_interval}s (ctrl-c to stop)")
111
+ while True:
112
+ start = time.monotonic()
113
+ try:
114
+ count = push_once(config)
115
+ session_count = push_sessions(config)
116
+ if count or session_count:
117
+ print(f"pushed {count} changed window(s), {session_count} changed session(s)")
118
+ else:
119
+ print("no changes, skipped")
120
+ except urllib.error.URLError as exc:
121
+ print(f"push failed: {exc}", file=sys.stderr)
122
+ elapsed = time.monotonic() - start
123
+ time.sleep(max(0.0, config.push_interval - elapsed))
124
+
125
+
126
+ def main() -> None:
127
+ parser = argparse.ArgumentParser(prog="outpost", description=__doc__)
128
+ sub = parser.add_subparsers(dest="command", required=True)
129
+
130
+ login_parser = sub.add_parser(
131
+ "login", help="Open the site, generate an API key, and save it via wincred"
132
+ )
133
+ login_parser.add_argument("--tower-url", default=DEFAULT_TOWER_URL, help="Site URL")
134
+ login_parser.set_defaults(func=cmd_login)
135
+
136
+ set_password_parser = sub.add_parser(
137
+ "set-password", help="Set the shared encryption password (enter the same one on the website)"
138
+ )
139
+ set_password_parser.set_defaults(func=cmd_set_password)
140
+
141
+ push_parser = sub.add_parser("push", help="Push a single snapshot and exit")
142
+ push_parser.set_defaults(func=cmd_push)
143
+
144
+ run_parser = sub.add_parser("run", help="Push snapshots on a loop until stopped")
145
+ run_parser.set_defaults(func=cmd_run)
146
+
147
+ push_doc_parser = sub.add_parser(
148
+ "push-doc", help="Push a markdown/html/zip file, rendered separately from tmux sessions"
149
+ )
150
+ push_doc_parser.add_argument("path", help="Path to a .md, .html, or .zip file")
151
+ push_doc_parser.add_argument("--title", help="Display title (default: filename)")
152
+ push_doc_parser.add_argument(
153
+ "--format", choices=["markdown", "html", "zip"], help="Override format inferred from the file extension"
154
+ )
155
+ push_doc_parser.add_argument(
156
+ "--id", help="Stable doc id to upsert on re-push (default: slugified filename)"
157
+ )
158
+ push_doc_parser.set_defaults(func=cmd_push_doc)
159
+
160
+ args = parser.parse_args()
161
+ args.func(args)
162
+
163
+
164
+ if __name__ == "__main__":
165
+ main()
@@ -0,0 +1,84 @@
1
+ import json
2
+ import os
3
+ import shutil
4
+ import subprocess
5
+ from dataclasses import dataclass
6
+
7
+ WINCRED_TARGET = "outpost:config"
8
+
9
+
10
+ def _wincred_available() -> bool:
11
+ return shutil.which("wincred.exe") is not None
12
+
13
+
14
+ def _wincred_get(target: str) -> str | None:
15
+ result = subprocess.run(
16
+ ["wincred.exe", "get", target], capture_output=True, text=True, stdin=subprocess.DEVNULL
17
+ )
18
+ if result.returncode != 0:
19
+ return None
20
+ return result.stdout.strip()
21
+
22
+
23
+ def _wincred_set(target: str, secret: str) -> None:
24
+ subprocess.run(["wincred.exe", "set", target], input=secret, text=True, check=True)
25
+
26
+
27
+ def _require_wincred() -> None:
28
+ if not _wincred_available():
29
+ raise SystemExit(
30
+ "outpost requires wincred.exe on PATH (WSL2 + Windows Credential "
31
+ "Manager — see https://github.com/vivainio/wincred)."
32
+ )
33
+
34
+
35
+ def save_credentials(tower_url: str, push_secret: str, encryption_key: str | None = None) -> str:
36
+ _require_wincred()
37
+ payload = {"tower_url": tower_url, "push_secret": push_secret}
38
+ if encryption_key is None:
39
+ # Preserve an existing encryption key across e.g. a re-login,
40
+ # unless the caller explicitly wants to set/clear one.
41
+ existing = _load_credentials()
42
+ if existing and existing.get("encryption_key"):
43
+ encryption_key = existing["encryption_key"]
44
+ if encryption_key:
45
+ payload["encryption_key"] = encryption_key
46
+ _wincred_set(WINCRED_TARGET, json.dumps(payload))
47
+ return f"Windows Credential Manager ({WINCRED_TARGET})"
48
+
49
+
50
+ def _load_credentials() -> dict[str, str] | None:
51
+ if not _wincred_available():
52
+ return None
53
+ raw = _wincred_get(WINCRED_TARGET)
54
+ if not raw:
55
+ return None
56
+ try:
57
+ return json.loads(raw)
58
+ except json.JSONDecodeError:
59
+ return None
60
+
61
+
62
+ @dataclass
63
+ class Config:
64
+ tower_url: str
65
+ push_secret: str
66
+ push_interval: float
67
+ capture_lines: int
68
+ session_max_age: float
69
+ encryption_key: str | None = None
70
+
71
+ @classmethod
72
+ def from_env(cls) -> "Config":
73
+ _require_wincred()
74
+ creds = _load_credentials()
75
+ if not creds:
76
+ raise SystemExit("No credentials found. Run `outpost login` first.")
77
+ return cls(
78
+ tower_url=creds["tower_url"].rstrip("/"),
79
+ push_secret=creds["push_secret"],
80
+ push_interval=float(os.environ.get("PUSH_INTERVAL", "15")),
81
+ capture_lines=int(os.environ.get("CAPTURE_LINES", "2000")),
82
+ session_max_age=float(os.environ.get("SESSION_MAX_AGE_MINUTES", "60")) * 60,
83
+ encryption_key=creds.get("encryption_key"),
84
+ )
@@ -0,0 +1,19 @@
1
+ import base64
2
+ import hashlib
3
+ import os
4
+
5
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
6
+
7
+ KEY_LENGTH = 32 # AES-256
8
+ IV_LENGTH = 12 # standard GCM nonce size
9
+
10
+
11
+ def derive_key(password: str, salt_hex: str, iterations: int) -> bytes:
12
+ salt = bytes.fromhex(salt_hex)
13
+ return hashlib.pbkdf2_hmac("sha256", password.encode(), salt, iterations, dklen=KEY_LENGTH)
14
+
15
+
16
+ def encrypt(content: str, key: bytes) -> str:
17
+ iv = os.urandom(IV_LENGTH)
18
+ ciphertext = AESGCM(key).encrypt(iv, content.encode(), None)
19
+ return f"{base64.b64encode(iv).decode()}:{base64.b64encode(ciphertext).decode()}"