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.
@@ -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,10 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ dist/
6
+ build/
7
+ .pytest_cache/
8
+ .mypy_cache/
9
+ .ruff_cache/
10
+ .DS_Store
@@ -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,3 @@
1
+ """Bifrost path-tunnel agent."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,4 @@
1
+ from bifrost_agent.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ 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()