bifrost-agent 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.
- bifrost_agent-0.1.0/.github/workflows/publish.yml +24 -0
- bifrost_agent-0.1.0/.gitignore +10 -0
- bifrost_agent-0.1.0/PKG-INFO +65 -0
- bifrost_agent-0.1.0/README.md +50 -0
- bifrost_agent-0.1.0/pyproject.toml +25 -0
- bifrost_agent-0.1.0/src/bifrost_agent/__init__.py +3 -0
- bifrost_agent-0.1.0/src/bifrost_agent/__main__.py +4 -0
- bifrost_agent-0.1.0/src/bifrost_agent/agent.py +207 -0
- bifrost_agent-0.1.0/src/bifrost_agent/cli.py +99 -0
- bifrost_agent-0.1.0/src/bifrost_agent/config.py +52 -0
- bifrost_agent-0.1.0/src/bifrost_agent/install_token.py +43 -0
- bifrost_agent-0.1.0/src/bifrost_agent/launchd.py +84 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
name: Publish
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- "v*"
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
publish:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
permissions:
|
|
12
|
+
id-token: write
|
|
13
|
+
contents: read
|
|
14
|
+
steps:
|
|
15
|
+
- uses: actions/checkout@v4
|
|
16
|
+
- uses: actions/setup-python@v5
|
|
17
|
+
with:
|
|
18
|
+
python-version: "3.12"
|
|
19
|
+
- name: Build
|
|
20
|
+
run: |
|
|
21
|
+
python -m pip install --upgrade pip build
|
|
22
|
+
python -m build
|
|
23
|
+
- name: Publish to PyPI
|
|
24
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: bifrost-agent
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Bifrost path-tunnel agent — connect a local Mac app to Bifrost
|
|
5
|
+
Project-URL: Homepage, https://github.com/eskeon/bifrost-agent
|
|
6
|
+
Project-URL: Repository, https://github.com/eskeon/bifrost-agent
|
|
7
|
+
Author: Bifrost
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
Keywords: bifrost,tunnel,websocket
|
|
10
|
+
Requires-Python: >=3.11
|
|
11
|
+
Requires-Dist: click>=8.1
|
|
12
|
+
Requires-Dist: httpx>=0.27
|
|
13
|
+
Requires-Dist: websockets>=12
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# bifrost-agent
|
|
17
|
+
|
|
18
|
+
Mac path-tunnel agent for [Bifrost](https://github.com/eskeon/go-bifrost). Connects outbound over WebSocket and exposes a local HTTP app under `/services/{slug}`.
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pip install bifrost-agent
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Requires Python 3.11+.
|
|
27
|
+
|
|
28
|
+
## Connect
|
|
29
|
+
|
|
30
|
+
1. In Bifrost Console → **Tunnels**, create a tunnel and attach a path route (set **Local URL** there, e.g. `http://127.0.0.1:3000`).
|
|
31
|
+
2. Copy the one-time install command and run:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
sudo bifrost tunnel install <token>
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
The token embeds the Bifrost WebSocket URL and auth secret. Local upstream URLs are **not** passed on the CLI — they come from the console on each request.
|
|
38
|
+
|
|
39
|
+
## Commands
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
sudo bifrost tunnel install <token> # LaunchDaemon + system config
|
|
43
|
+
bifrost tunnel run # foreground (uses saved config)
|
|
44
|
+
bifrost tunnel run <token> # one-shot / save user config + run
|
|
45
|
+
sudo bifrost tunnel uninstall
|
|
46
|
+
bifrost version
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
System config: `/Library/Application Support/bifrost/config.json`
|
|
50
|
+
Logs: `/Library/Logs/bifrost-tunnel.log`
|
|
51
|
+
|
|
52
|
+
## Develop
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
python -m venv .venv
|
|
56
|
+
source .venv/bin/activate
|
|
57
|
+
pip install -e .
|
|
58
|
+
bifrost version
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Publish
|
|
62
|
+
|
|
63
|
+
Repo: https://github.com/eskeon/bifrost-agent
|
|
64
|
+
|
|
65
|
+
Tag a release (`v*`); GitHub Actions builds and publishes to PyPI as `bifrost-agent`.
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# bifrost-agent
|
|
2
|
+
|
|
3
|
+
Mac path-tunnel agent for [Bifrost](https://github.com/eskeon/go-bifrost). Connects outbound over WebSocket and exposes a local HTTP app under `/services/{slug}`.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install bifrost-agent
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Requires Python 3.11+.
|
|
12
|
+
|
|
13
|
+
## Connect
|
|
14
|
+
|
|
15
|
+
1. In Bifrost Console → **Tunnels**, create a tunnel and attach a path route (set **Local URL** there, e.g. `http://127.0.0.1:3000`).
|
|
16
|
+
2. Copy the one-time install command and run:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
sudo bifrost tunnel install <token>
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
The token embeds the Bifrost WebSocket URL and auth secret. Local upstream URLs are **not** passed on the CLI — they come from the console on each request.
|
|
23
|
+
|
|
24
|
+
## Commands
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
sudo bifrost tunnel install <token> # LaunchDaemon + system config
|
|
28
|
+
bifrost tunnel run # foreground (uses saved config)
|
|
29
|
+
bifrost tunnel run <token> # one-shot / save user config + run
|
|
30
|
+
sudo bifrost tunnel uninstall
|
|
31
|
+
bifrost version
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
System config: `/Library/Application Support/bifrost/config.json`
|
|
35
|
+
Logs: `/Library/Logs/bifrost-tunnel.log`
|
|
36
|
+
|
|
37
|
+
## Develop
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
python -m venv .venv
|
|
41
|
+
source .venv/bin/activate
|
|
42
|
+
pip install -e .
|
|
43
|
+
bifrost version
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Publish
|
|
47
|
+
|
|
48
|
+
Repo: https://github.com/eskeon/bifrost-agent
|
|
49
|
+
|
|
50
|
+
Tag a release (`v*`); GitHub Actions builds and publishes to PyPI as `bifrost-agent`.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "bifrost-agent"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Bifrost path-tunnel agent — connect a local Mac app to Bifrost"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [{ name = "Bifrost" }]
|
|
13
|
+
keywords = ["bifrost", "tunnel", "websocket"]
|
|
14
|
+
dependencies = [
|
|
15
|
+
"click>=8.1",
|
|
16
|
+
"httpx>=0.27",
|
|
17
|
+
"websockets>=12",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[project.urls]
|
|
21
|
+
Homepage = "https://github.com/eskeon/bifrost-agent"
|
|
22
|
+
Repository = "https://github.com/eskeon/bifrost-agent"
|
|
23
|
+
|
|
24
|
+
[project.scripts]
|
|
25
|
+
bifrost = "bifrost_agent.cli:main"
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""WebSocket tunnel agent — forwards Bifrost http_req frames to local_url."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import base64
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
from typing import Any
|
|
10
|
+
from urllib.parse import urljoin, urlparse
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
import websockets
|
|
14
|
+
|
|
15
|
+
from bifrost_agent import __version__
|
|
16
|
+
from bifrost_agent.config import Config
|
|
17
|
+
|
|
18
|
+
log = logging.getLogger("bifrost.agent")
|
|
19
|
+
|
|
20
|
+
MAX_BODY = 8 << 20
|
|
21
|
+
SUBPROTOCOL = "bifrost-tunnel"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _ws_url(url: str) -> str:
|
|
25
|
+
if url.startswith("https://"):
|
|
26
|
+
return "wss://" + url[len("https://") :]
|
|
27
|
+
if url.startswith("http://"):
|
|
28
|
+
return "ws://" + url[len("http://") :]
|
|
29
|
+
return url
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _join_local(local_url: str, path: str, query: str) -> str:
|
|
33
|
+
base = local_url.rstrip("/") + "/"
|
|
34
|
+
rel = (path or "/").lstrip("/")
|
|
35
|
+
target = urljoin(base, rel)
|
|
36
|
+
if query:
|
|
37
|
+
sep = "&" if "?" in target else "?"
|
|
38
|
+
target = f"{target}{sep}{query}"
|
|
39
|
+
return target
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
async def _forward(client: httpx.AsyncClient, msg: dict[str, Any]) -> dict[str, Any]:
|
|
43
|
+
req_id = str(msg.get("id") or "")
|
|
44
|
+
local_url = str(msg.get("local_url") or "").strip()
|
|
45
|
+
if not local_url:
|
|
46
|
+
return {
|
|
47
|
+
"type": "http_res",
|
|
48
|
+
"id": req_id,
|
|
49
|
+
"status": 502,
|
|
50
|
+
"error": "missing local_url",
|
|
51
|
+
}
|
|
52
|
+
parsed = urlparse(local_url)
|
|
53
|
+
if parsed.scheme not in ("http", "https") or not parsed.netloc:
|
|
54
|
+
return {
|
|
55
|
+
"type": "http_res",
|
|
56
|
+
"id": req_id,
|
|
57
|
+
"status": 502,
|
|
58
|
+
"error": "invalid local_url",
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
method = str(msg.get("method") or "GET").upper()
|
|
62
|
+
path = str(msg.get("path") or "/")
|
|
63
|
+
query = str(msg.get("query") or "")
|
|
64
|
+
headers_in = msg.get("headers") or {}
|
|
65
|
+
headers: dict[str, str] = {}
|
|
66
|
+
if isinstance(headers_in, dict):
|
|
67
|
+
for k, vals in headers_in.items():
|
|
68
|
+
if str(k).lower() in {
|
|
69
|
+
"host",
|
|
70
|
+
"content-length",
|
|
71
|
+
"connection",
|
|
72
|
+
"transfer-encoding",
|
|
73
|
+
"keep-alive",
|
|
74
|
+
"upgrade",
|
|
75
|
+
}:
|
|
76
|
+
continue
|
|
77
|
+
if isinstance(vals, list) and vals:
|
|
78
|
+
headers[str(k)] = str(vals[0])
|
|
79
|
+
elif isinstance(vals, str):
|
|
80
|
+
headers[str(k)] = vals
|
|
81
|
+
|
|
82
|
+
body = b""
|
|
83
|
+
if msg.get("body"):
|
|
84
|
+
try:
|
|
85
|
+
body = base64.b64decode(msg["body"])
|
|
86
|
+
except Exception: # noqa: BLE001
|
|
87
|
+
return {
|
|
88
|
+
"type": "http_res",
|
|
89
|
+
"id": req_id,
|
|
90
|
+
"status": 502,
|
|
91
|
+
"error": "invalid body encoding",
|
|
92
|
+
}
|
|
93
|
+
if len(body) > MAX_BODY:
|
|
94
|
+
return {
|
|
95
|
+
"type": "http_res",
|
|
96
|
+
"id": req_id,
|
|
97
|
+
"status": 413,
|
|
98
|
+
"error": "body too large",
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
url = _join_local(local_url, path, query)
|
|
102
|
+
try:
|
|
103
|
+
resp = await client.request(method, url, headers=headers, content=body)
|
|
104
|
+
except Exception as exc: # noqa: BLE001
|
|
105
|
+
return {
|
|
106
|
+
"type": "http_res",
|
|
107
|
+
"id": req_id,
|
|
108
|
+
"status": 502,
|
|
109
|
+
"error": str(exc),
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
out_headers: dict[str, list[str]] = {}
|
|
113
|
+
for k, v in resp.headers.multi_items():
|
|
114
|
+
if k.lower() in {"transfer-encoding", "connection", "content-encoding"}:
|
|
115
|
+
continue
|
|
116
|
+
out_headers.setdefault(k, []).append(v)
|
|
117
|
+
|
|
118
|
+
resp_body = resp.content
|
|
119
|
+
if len(resp_body) > MAX_BODY:
|
|
120
|
+
return {
|
|
121
|
+
"type": "http_res",
|
|
122
|
+
"id": req_id,
|
|
123
|
+
"status": 502,
|
|
124
|
+
"error": "response too large",
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
"type": "http_res",
|
|
129
|
+
"id": req_id,
|
|
130
|
+
"status": resp.status_code,
|
|
131
|
+
"headers": out_headers,
|
|
132
|
+
"body": base64.b64encode(resp_body).decode("ascii") if resp_body else "",
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
async def _session(cfg: Config) -> None:
|
|
137
|
+
url = _ws_url(cfg.url)
|
|
138
|
+
async with websockets.connect(
|
|
139
|
+
url,
|
|
140
|
+
subprotocols=[SUBPROTOCOL],
|
|
141
|
+
max_size=MAX_BODY + (1 << 20),
|
|
142
|
+
ping_interval=None,
|
|
143
|
+
) as ws:
|
|
144
|
+
await ws.send(
|
|
145
|
+
json.dumps(
|
|
146
|
+
{
|
|
147
|
+
"type": "hello",
|
|
148
|
+
"token": cfg.token,
|
|
149
|
+
"version": __version__,
|
|
150
|
+
}
|
|
151
|
+
)
|
|
152
|
+
)
|
|
153
|
+
welcome_raw = await ws.recv()
|
|
154
|
+
welcome = json.loads(welcome_raw)
|
|
155
|
+
if welcome.get("type") != "welcome":
|
|
156
|
+
raise RuntimeError(f"expected welcome, got {welcome.get('type')!r}")
|
|
157
|
+
log.info(
|
|
158
|
+
"connected tunnel_id=%s name=%r",
|
|
159
|
+
welcome.get("tunnel_id"),
|
|
160
|
+
welcome.get("name"),
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
async with httpx.AsyncClient(timeout=60.0, follow_redirects=False) as client:
|
|
164
|
+
async for raw in ws:
|
|
165
|
+
if isinstance(raw, bytes):
|
|
166
|
+
raw = raw.decode("utf-8", errors="replace")
|
|
167
|
+
try:
|
|
168
|
+
msg = json.loads(raw)
|
|
169
|
+
except json.JSONDecodeError:
|
|
170
|
+
log.warning("bad frame")
|
|
171
|
+
continue
|
|
172
|
+
typ = msg.get("type")
|
|
173
|
+
if typ == "ping":
|
|
174
|
+
await ws.send(json.dumps({"type": "pong"}))
|
|
175
|
+
elif typ == "http_req":
|
|
176
|
+
res = await _forward(client, msg)
|
|
177
|
+
await ws.send(json.dumps(res))
|
|
178
|
+
elif typ == "error":
|
|
179
|
+
log.error("server error: %s", msg.get("message"))
|
|
180
|
+
else:
|
|
181
|
+
log.debug("ignore type=%s", typ)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
async def run(cfg: Config) -> None:
|
|
185
|
+
cfg.validate()
|
|
186
|
+
backoff = 1.0
|
|
187
|
+
while True:
|
|
188
|
+
try:
|
|
189
|
+
await _session(cfg)
|
|
190
|
+
backoff = 1.0
|
|
191
|
+
except asyncio.CancelledError:
|
|
192
|
+
raise
|
|
193
|
+
except Exception as exc: # noqa: BLE001
|
|
194
|
+
log.warning("disconnected: %s; reconnecting in %.0fs", exc, backoff)
|
|
195
|
+
await asyncio.sleep(backoff)
|
|
196
|
+
backoff = min(backoff * 2, 30.0)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def run_forever(cfg: Config) -> None:
|
|
200
|
+
logging.basicConfig(
|
|
201
|
+
level=logging.INFO,
|
|
202
|
+
format="%(asctime)s %(levelname)s %(message)s",
|
|
203
|
+
)
|
|
204
|
+
try:
|
|
205
|
+
asyncio.run(run(cfg))
|
|
206
|
+
except KeyboardInterrupt:
|
|
207
|
+
pass
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""CLI: bifrost tunnel install|run|uninstall, bifrost version."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
|
|
9
|
+
from bifrost_agent import __version__
|
|
10
|
+
from bifrost_agent.agent import run_forever
|
|
11
|
+
from bifrost_agent.config import Config, default_config_path, load, save, user_config_path
|
|
12
|
+
from bifrost_agent.install_token import parse_install_token
|
|
13
|
+
from bifrost_agent.launchd import install as launchd_install
|
|
14
|
+
from bifrost_agent.launchd import uninstall as launchd_uninstall
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@click.group()
|
|
18
|
+
@click.version_option(__version__, prog_name="bifrost")
|
|
19
|
+
def main() -> None:
|
|
20
|
+
"""Bifrost tunnel agent."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@main.group()
|
|
24
|
+
def tunnel() -> None:
|
|
25
|
+
"""Manage the path tunnel agent."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@tunnel.command("install")
|
|
29
|
+
@click.argument("token")
|
|
30
|
+
def tunnel_install(token: str) -> None:
|
|
31
|
+
"""Install LaunchDaemon from a console install token (requires sudo)."""
|
|
32
|
+
try:
|
|
33
|
+
parsed = parse_install_token(token)
|
|
34
|
+
cfg = Config(url=parsed.url, token=parsed.token)
|
|
35
|
+
launchd_install(cfg)
|
|
36
|
+
except PermissionError as exc:
|
|
37
|
+
click.echo(f"error: {exc}", err=True)
|
|
38
|
+
sys.exit(1)
|
|
39
|
+
except Exception as exc: # noqa: BLE001
|
|
40
|
+
click.echo(f"error: {exc}", err=True)
|
|
41
|
+
sys.exit(1)
|
|
42
|
+
click.echo("installed LaunchDaemon com.bifrost.tunnel")
|
|
43
|
+
click.echo("config: /Library/Application Support/bifrost/config.json")
|
|
44
|
+
click.echo("logs: /Library/Logs/bifrost-tunnel.log")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@tunnel.command("uninstall")
|
|
48
|
+
def tunnel_uninstall() -> None:
|
|
49
|
+
"""Remove LaunchDaemon and system config (requires sudo)."""
|
|
50
|
+
try:
|
|
51
|
+
launchd_uninstall()
|
|
52
|
+
except PermissionError as exc:
|
|
53
|
+
click.echo(f"error: {exc}", err=True)
|
|
54
|
+
sys.exit(1)
|
|
55
|
+
except Exception as exc: # noqa: BLE001
|
|
56
|
+
click.echo(f"error: {exc}", err=True)
|
|
57
|
+
sys.exit(1)
|
|
58
|
+
click.echo("uninstalled com.bifrost.tunnel")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@tunnel.command("run")
|
|
62
|
+
@click.argument("token", required=False)
|
|
63
|
+
@click.option("--config", "config_path", type=click.Path(), default=None, help="Config file path")
|
|
64
|
+
def tunnel_run(token: str | None, config_path: str | None) -> None:
|
|
65
|
+
"""Run the agent in the foreground."""
|
|
66
|
+
try:
|
|
67
|
+
if token:
|
|
68
|
+
parsed = parse_install_token(token)
|
|
69
|
+
cfg = Config(url=parsed.url, token=parsed.token)
|
|
70
|
+
# Convenience: persist to user config when not root.
|
|
71
|
+
if config_path:
|
|
72
|
+
from pathlib import Path
|
|
73
|
+
|
|
74
|
+
save(cfg, Path(config_path))
|
|
75
|
+
else:
|
|
76
|
+
try:
|
|
77
|
+
save(cfg, user_config_path())
|
|
78
|
+
except OSError:
|
|
79
|
+
pass
|
|
80
|
+
elif config_path:
|
|
81
|
+
from pathlib import Path
|
|
82
|
+
|
|
83
|
+
cfg = load(Path(config_path))
|
|
84
|
+
else:
|
|
85
|
+
cfg = load(default_config_path())
|
|
86
|
+
except Exception as exc: # noqa: BLE001
|
|
87
|
+
click.echo(f"error: {exc}", err=True)
|
|
88
|
+
sys.exit(1)
|
|
89
|
+
run_forever(cfg)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@main.command("version")
|
|
93
|
+
def version_cmd() -> None:
|
|
94
|
+
"""Print version."""
|
|
95
|
+
click.echo(__version__)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
if __name__ == "__main__":
|
|
99
|
+
main()
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Agent config (url + auth token only — local upstreams come from Bifrost)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from dataclasses import asdict, dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
SYSTEM_CONFIG = Path("/Library/Application Support/bifrost/config.json")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class Config:
|
|
16
|
+
url: str
|
|
17
|
+
token: str
|
|
18
|
+
|
|
19
|
+
def validate(self) -> None:
|
|
20
|
+
if not (self.url or "").strip():
|
|
21
|
+
raise ValueError("url is required")
|
|
22
|
+
if not (self.token or "").strip():
|
|
23
|
+
raise ValueError("token is required")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def user_config_path() -> Path:
|
|
27
|
+
home = Path.home()
|
|
28
|
+
return home / "Library" / "Application Support" / "bifrost" / "config.json"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def default_config_path() -> Path:
|
|
32
|
+
"""Prefer system config when readable (LaunchDaemon), else user config."""
|
|
33
|
+
if SYSTEM_CONFIG.is_file() and os.access(SYSTEM_CONFIG, os.R_OK):
|
|
34
|
+
return SYSTEM_CONFIG
|
|
35
|
+
return user_config_path()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def load(path: Path | None = None) -> Config:
|
|
39
|
+
p = path or default_config_path()
|
|
40
|
+
if not p.is_file():
|
|
41
|
+
raise FileNotFoundError(f"config not found: {p}")
|
|
42
|
+
data = json.loads(p.read_text(encoding="utf-8"))
|
|
43
|
+
cfg = Config(url=str(data.get("url") or ""), token=str(data.get("token") or ""))
|
|
44
|
+
cfg.validate()
|
|
45
|
+
return cfg
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def save(cfg: Config, path: Path) -> None:
|
|
49
|
+
cfg.validate()
|
|
50
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
51
|
+
path.write_text(json.dumps(asdict(cfg), indent=2) + "\n", encoding="utf-8")
|
|
52
|
+
os.chmod(path, 0o600)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Parse Cloudflare-style opaque install tokens from the Bifrost console."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import json
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class InstallToken:
|
|
12
|
+
url: str
|
|
13
|
+
token: str
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def parse_install_token(raw: str) -> InstallToken:
|
|
17
|
+
raw = (raw or "").strip()
|
|
18
|
+
if not raw:
|
|
19
|
+
raise ValueError("install token is required")
|
|
20
|
+
|
|
21
|
+
# Allow pasting the full command by accident.
|
|
22
|
+
if raw.startswith("sudo "):
|
|
23
|
+
parts = raw.split()
|
|
24
|
+
raw = parts[-1] if parts else raw
|
|
25
|
+
|
|
26
|
+
pad = "=" * (-len(raw) % 4)
|
|
27
|
+
try:
|
|
28
|
+
data = base64.urlsafe_b64decode(raw + pad)
|
|
29
|
+
except Exception as exc: # noqa: BLE001
|
|
30
|
+
raise ValueError("invalid install token encoding") from exc
|
|
31
|
+
|
|
32
|
+
try:
|
|
33
|
+
obj = json.loads(data.decode("utf-8"))
|
|
34
|
+
except Exception as exc: # noqa: BLE001
|
|
35
|
+
raise ValueError("invalid install token json") from exc
|
|
36
|
+
|
|
37
|
+
if not isinstance(obj, dict):
|
|
38
|
+
raise ValueError("invalid install token")
|
|
39
|
+
url = str(obj.get("url") or "").strip()
|
|
40
|
+
token = str(obj.get("token") or "").strip()
|
|
41
|
+
if not url or not token:
|
|
42
|
+
raise ValueError("install token missing url or token")
|
|
43
|
+
return InstallToken(url=url, token=token)
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""macOS LaunchDaemon install/uninstall for the tunnel agent."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import plistlib
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from bifrost_agent.config import SYSTEM_CONFIG, Config, save
|
|
13
|
+
|
|
14
|
+
LABEL = "com.bifrost.tunnel"
|
|
15
|
+
PLIST_PATH = Path("/Library/LaunchDaemons") / f"{LABEL}.plist"
|
|
16
|
+
LOG_PATH = Path("/Library/Logs/bifrost-tunnel.log")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _require_root() -> None:
|
|
20
|
+
if os.geteuid() != 0:
|
|
21
|
+
raise PermissionError("this command requires root (use sudo)")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _bifrost_bin() -> str:
|
|
25
|
+
# Prefer the invoking executable (works under `sudo bifrost …`).
|
|
26
|
+
argv0 = Path(sys.argv[0]).resolve()
|
|
27
|
+
if argv0.is_file() and os.access(argv0, os.X_OK):
|
|
28
|
+
return str(argv0)
|
|
29
|
+
found = shutil.which("bifrost")
|
|
30
|
+
if found:
|
|
31
|
+
return found
|
|
32
|
+
return sys.executable
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def install(cfg: Config) -> None:
|
|
36
|
+
_require_root()
|
|
37
|
+
save(cfg, SYSTEM_CONFIG)
|
|
38
|
+
|
|
39
|
+
bin_path = _bifrost_bin()
|
|
40
|
+
# If invoked as `python -m bifrost_agent`, run via module.
|
|
41
|
+
if Path(bin_path).name.startswith("python"):
|
|
42
|
+
program = [bin_path, "-m", "bifrost_agent", "tunnel", "run"]
|
|
43
|
+
else:
|
|
44
|
+
program = [bin_path, "tunnel", "run"]
|
|
45
|
+
|
|
46
|
+
LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
47
|
+
plist = {
|
|
48
|
+
"Label": LABEL,
|
|
49
|
+
"ProgramArguments": program,
|
|
50
|
+
"RunAtLoad": True,
|
|
51
|
+
"KeepAlive": True,
|
|
52
|
+
"StandardOutPath": str(LOG_PATH),
|
|
53
|
+
"StandardErrorPath": str(LOG_PATH),
|
|
54
|
+
}
|
|
55
|
+
PLIST_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
56
|
+
with PLIST_PATH.open("wb") as f:
|
|
57
|
+
plistlib.dump(plist, f)
|
|
58
|
+
os.chmod(PLIST_PATH, 0o644)
|
|
59
|
+
|
|
60
|
+
# bootout if already loaded, then bootstrap
|
|
61
|
+
subprocess.run(
|
|
62
|
+
["launchctl", "bootout", f"system/{LABEL}"],
|
|
63
|
+
check=False,
|
|
64
|
+
capture_output=True,
|
|
65
|
+
)
|
|
66
|
+
subprocess.run(
|
|
67
|
+
["launchctl", "bootstrap", "system", str(PLIST_PATH)],
|
|
68
|
+
check=True,
|
|
69
|
+
)
|
|
70
|
+
subprocess.run(["launchctl", "enable", f"system/{LABEL}"], check=False)
|
|
71
|
+
subprocess.run(["launchctl", "kickstart", "-k", f"system/{LABEL}"], check=False)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def uninstall() -> None:
|
|
75
|
+
_require_root()
|
|
76
|
+
subprocess.run(
|
|
77
|
+
["launchctl", "bootout", f"system/{LABEL}"],
|
|
78
|
+
check=False,
|
|
79
|
+
capture_output=True,
|
|
80
|
+
)
|
|
81
|
+
if PLIST_PATH.exists():
|
|
82
|
+
PLIST_PATH.unlink()
|
|
83
|
+
if SYSTEM_CONFIG.exists():
|
|
84
|
+
SYSTEM_CONFIG.unlink()
|