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.
- push_to_outpost-0.1.0/.github/workflows/publish.yml +31 -0
- push_to_outpost-0.1.0/.gitignore +10 -0
- push_to_outpost-0.1.0/PKG-INFO +6 -0
- push_to_outpost-0.1.0/README.md +119 -0
- push_to_outpost-0.1.0/outpost/__init__.py +0 -0
- push_to_outpost-0.1.0/outpost/__main__.py +4 -0
- push_to_outpost-0.1.0/outpost/agent.py +191 -0
- push_to_outpost-0.1.0/outpost/cli.py +165 -0
- push_to_outpost-0.1.0/outpost/config.py +84 -0
- push_to_outpost-0.1.0/outpost/crypto.py +19 -0
- push_to_outpost-0.1.0/outpost/sessions.py +165 -0
- push_to_outpost-0.1.0/package-lock.json +1884 -0
- push_to_outpost-0.1.0/package.json +25 -0
- push_to_outpost-0.1.0/pyproject.toml +16 -0
- push_to_outpost-0.1.0/schema.sql +61 -0
- push_to_outpost-0.1.0/src/app.ts +708 -0
- push_to_outpost-0.1.0/src/viewer.ts +180 -0
- push_to_outpost-0.1.0/src/zip-browser.ts +121 -0
- push_to_outpost-0.1.0/tsconfig.json +15 -0
- push_to_outpost-0.1.0/tsconfig.worker.json +14 -0
- push_to_outpost-0.1.0/uv.lock +149 -0
- push_to_outpost-0.1.0/web/index.html +69 -0
- push_to_outpost-0.1.0/web/style.css +247 -0
- push_to_outpost-0.1.0/web/viewer.html +66 -0
- push_to_outpost-0.1.0/worker/index.ts +437 -0
- push_to_outpost-0.1.0/wrangler.toml +18 -0
|
@@ -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,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,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()}"
|