dev-bubble 0.7.19__tar.gz → 0.7.21__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.
- {dev_bubble-0.7.19/dev_bubble.egg-info → dev_bubble-0.7.21}/PKG-INFO +1 -1
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/SPEC.md +1 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/__init__.py +1 -1
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/auth_proxy.py +158 -27
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/config.py +9 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/container_helpers.py +41 -2
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/github_token.py +241 -119
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/graphql_validator.py +5 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/images/scripts/tools/gh.sh +34 -9
- dev_bubble-0.7.21/bubble/images/scripts/tools/pi.sh +26 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/images/scripts/tools/pins.json +2 -1
- dev_bubble-0.7.21/bubble/incus_bridge.py +107 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/network.py +52 -15
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/runtime/colima.py +12 -3
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/runtime/incus.py +113 -30
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/tools.py +18 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21/dev_bubble.egg-info}/PKG-INFO +1 -1
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/dev_bubble.egg-info/SOURCES.txt +5 -0
- dev_bubble-0.7.21/scratch/forkproxy-repro.sh +66 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/tests/conftest.py +10 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/tests/test_auth_proxy.py +56 -1
- dev_bubble-0.7.21/tests/test_bridge_listener.py +322 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/tests/test_colima.py +26 -2
- dev_bubble-0.7.21/tests/test_get_info_remote.py +138 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/tests/test_token_no_argv_leak.py +6 -2
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/tests/test_tools.py +11 -1
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/.claude/CLAUDE.md +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/.github/workflows/ci.yml +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/.github/workflows/publish.yml +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/.gitignore +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/CHANGELOG.md +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/LICENSE +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/README.md +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/__main__.py +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/ai.py +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/automation.py +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/clean.py +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/cli.py +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/clone.py +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/cloud.py +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/cloud_types.py +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/commands/__init__.py +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/commands/cloud_cmd.py +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/commands/completion.py +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/commands/doctor.py +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/commands/images.py +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/commands/infrastructure.py +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/commands/internal.py +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/commands/lifecycle.py +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/commands/list_cmd.py +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/commands/relay_cmd.py +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/commands/remote_cmd.py +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/commands/security_cmd.py +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/commands/settings.py +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/commands/status_cmd.py +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/data/skill.md +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/default_repos.json +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/finalization.py +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/git_store.py +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/hooks/__init__.py +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/hooks/lean.py +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/hooks/python.py +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/image_management.py +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/images/__init__.py +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/images/builder.py +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/images/scripts/base.sh +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/images/scripts/cloud-init.sh +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/images/scripts/lean-toolchain.sh +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/images/scripts/lean.sh +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/images/scripts/python.sh +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/images/scripts/tools/claude.sh +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/images/scripts/tools/codex.sh +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/images/scripts/tools/elan.sh +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/images/scripts/tools/emacs.sh +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/images/scripts/tools/neovim.sh +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/images/scripts/tools/uv.sh +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/images/scripts/tools/vscode.sh +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/lean.py +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/lifecycle.py +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/naming.py +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/notices.py +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/output.py +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/provisioning.py +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/relay.py +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/remote.py +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/repo_registry.py +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/runtime/__init__.py +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/runtime/base.py +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/security.py +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/setup.py +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/skill.py +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/spinner.py +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/target.py +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/token_store.py +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/tunnel.py +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/bubble/vscode.py +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/config/com.bubble.git-update.plist +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/config/com.bubble.image-refresh.plist +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/config/com.bubble.relay-daemon.plist +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/conftest.py +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/dev_bubble.egg-info/dependency_links.txt +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/dev_bubble.egg-info/entry_points.txt +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/dev_bubble.egg-info/requires.txt +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/dev_bubble.egg-info/top_level.txt +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/pyproject.toml +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/setup.cfg +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/tests/test_ai.py +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/tests/test_authorized_keys.py +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/tests/test_branch_no_target.py +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/tests/test_build_lock.py +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/tests/test_claude_projects_symlink.py +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/tests/test_cloud.py +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/tests/test_completion.py +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/tests/test_config.py +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/tests/test_customize.py +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/tests/test_editor.py +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/tests/test_ephemeral.py +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/tests/test_git_store.py +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/tests/test_github_security_override.py +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/tests/test_github_token.py +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/tests/test_graphql_validator.py +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/tests/test_hooks.py +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/tests/test_integration.py +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/tests/test_internal.py +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/tests/test_lifecycle.py +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/tests/test_list_columns.py +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/tests/test_list_remote.py +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/tests/test_mounts.py +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/tests/test_multi_target.py +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/tests/test_naming.py +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/tests/test_network.py +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/tests/test_notices.py +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/tests/test_reattach_network.py +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/tests/test_relay.py +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/tests/test_remote.py +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/tests/test_repo_registry.py +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/tests/test_security.py +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/tests/test_skill.py +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/tests/test_spinner.py +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/tests/test_status.py +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/tests/test_systemd_path.py +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/tests/test_target.py +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/tests/test_tunnel.py +0 -0
- {dev_bubble-0.7.19 → dev_bubble-0.7.21}/tests/test_vscode.py +0 -0
|
@@ -677,6 +677,7 @@ Each tool has:
|
|
|
677
677
|
| `elan` | 10 | `elan` | — |
|
|
678
678
|
| `claude` | 50 | `claude` | `api.anthropic.com` |
|
|
679
679
|
| `codex` | 50 | `codex` | `api.openai.com` |
|
|
680
|
+
| `pi` | 50 | `pi` | `openrouter.ai` |
|
|
680
681
|
| `gh` | 50 | `gh` | — |
|
|
681
682
|
| `vscode` | 90 | `code` | VS Code marketplace domains |
|
|
682
683
|
| `emacs` | 90 | `emacs` | — |
|
|
@@ -28,8 +28,32 @@ Security model:
|
|
|
28
28
|
- Per-container token isolation via X-Bubble-Token or Authorization header
|
|
29
29
|
- Rate limited + logged (reuses relay patterns)
|
|
30
30
|
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
The security boundary is incus container isolation plus the secrecy of
|
|
32
|
+
the per-container token. The token is a bearer credential: it is
|
|
33
|
+
256-bit random, scoped to a single owner/repo, validated on every
|
|
34
|
+
request, and stored only in the issuing container's filesystem (mode
|
|
35
|
+
0600). The bridge listener is reachable by any container on the bridge
|
|
36
|
+
(required for git), but reachability is not access — only a valid token
|
|
37
|
+
grants the scoped GitHub operations. There is intentionally no
|
|
38
|
+
source-IP binding: it would only matter after a container-root
|
|
39
|
+
compromise had *also* stolen another bubble's token (which requires
|
|
40
|
+
breaking incus isolation), and enforcing it forced fragile,
|
|
41
|
+
platform-specific kernel/network preconditions for negligible gain.
|
|
42
|
+
|
|
43
|
+
Listeners:
|
|
44
|
+
- On Linux: TCP on the incus bridge gateway IP (typically 10.156.104.1),
|
|
45
|
+
restricted to ``incusbr0`` via ``SO_BINDTODEVICE``. Containers reach
|
|
46
|
+
the daemon directly through the bridge so no per-bubble incus proxy
|
|
47
|
+
device is needed.
|
|
48
|
+
- On macOS (Colima): TCP on the Colima bridge IP.
|
|
49
|
+
|
|
50
|
+
``gh`` reaches this same TCP listener through a tiny unix→TCP forwarder
|
|
51
|
+
that runs inside each container (see the gh tool wrapper) — gh wants a
|
|
52
|
+
Unix socket, the forwarder gives it one locally and relays to the
|
|
53
|
+
bridge. There is no host-side Unix socket and no incus ``proxy`` device.
|
|
54
|
+
|
|
55
|
+
The listener endpoint is written to ``~/.bubble/auth-proxy.endpoint``
|
|
56
|
+
(JSON). ``auth-proxy.port`` is also written for backwards compat.
|
|
33
57
|
"""
|
|
34
58
|
|
|
35
59
|
import base64
|
|
@@ -37,6 +61,7 @@ import json
|
|
|
37
61
|
import logging
|
|
38
62
|
import os
|
|
39
63
|
import re
|
|
64
|
+
import socket
|
|
40
65
|
import ssl
|
|
41
66
|
import threading
|
|
42
67
|
import time
|
|
@@ -51,13 +76,18 @@ from urllib.request import (
|
|
|
51
76
|
build_opener,
|
|
52
77
|
)
|
|
53
78
|
|
|
54
|
-
from .config import
|
|
79
|
+
from .config import AUTH_PROXY_DIR
|
|
55
80
|
from .token_store import RateLimiter as _RateLimiter
|
|
56
81
|
from .token_store import RateWindow, TokenStore, setup_file_logging
|
|
57
82
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
83
|
+
# The daemon is a host singleton that always uses ~/.bubble (see
|
|
84
|
+
# AUTH_PROXY_DIR in config.py). Both the daemon and callers running under a
|
|
85
|
+
# custom BUBBLE_HOME must resolve these files against that fixed location so
|
|
86
|
+
# they agree on where the endpoint and per-container tokens live.
|
|
87
|
+
AUTH_PROXY_PORT_FILE = AUTH_PROXY_DIR / "auth-proxy.port"
|
|
88
|
+
AUTH_PROXY_ENDPOINT_FILE = AUTH_PROXY_DIR / "auth-proxy.endpoint"
|
|
89
|
+
AUTH_PROXY_LOG = AUTH_PROXY_DIR / "auth-proxy.log"
|
|
90
|
+
AUTH_PROXY_TOKENS = AUTH_PROXY_DIR / "auth-tokens.json"
|
|
61
91
|
|
|
62
92
|
# Default port (configurable via config.toml)
|
|
63
93
|
DEFAULT_PORT = 7654
|
|
@@ -182,6 +212,12 @@ def generate_auth_token(
|
|
|
182
212
|
The token maps to (container_name, owner, repo, rest_api, graphql_read,
|
|
183
213
|
graphql_write) — the proxy uses this to validate requests and enforce
|
|
184
214
|
the access policy.
|
|
215
|
+
|
|
216
|
+
The token is a 256-bit bearer credential: possession grants the
|
|
217
|
+
scoped access. It lives only in the issuing container's filesystem
|
|
218
|
+
(mode 0600); cross-container isolation is incus's job. There is no
|
|
219
|
+
source-IP binding — see the security notes in the module docstring.
|
|
220
|
+
|
|
185
221
|
Uses file locking to prevent read-modify-write races.
|
|
186
222
|
"""
|
|
187
223
|
return TokenStore(AUTH_PROXY_TOKENS).generate(
|
|
@@ -690,9 +726,10 @@ class AuthProxyHandler(BaseHTTPRequestHandler):
|
|
|
690
726
|
self.send_header(header, value)
|
|
691
727
|
|
|
692
728
|
def _authenticate(self) -> dict | None:
|
|
693
|
-
"""Authenticate the request via
|
|
729
|
+
"""Authenticate the request via its bearer token.
|
|
694
730
|
|
|
695
|
-
Returns the token info dict or None (sends error response).
|
|
731
|
+
Returns the token info dict or None (sends error response). The
|
|
732
|
+
token is the capability; possession grants the scoped access.
|
|
696
733
|
"""
|
|
697
734
|
token = self._get_container_token()
|
|
698
735
|
if not token:
|
|
@@ -1379,6 +1416,40 @@ class ThreadedHTTPServer(HTTPServer):
|
|
|
1379
1416
|
self._handler_semaphore.release()
|
|
1380
1417
|
|
|
1381
1418
|
|
|
1419
|
+
class BridgeBoundHTTPServer(ThreadedHTTPServer):
|
|
1420
|
+
"""ThreadedHTTPServer that restricts the listening socket to one
|
|
1421
|
+
network interface via ``SO_BINDTODEVICE``.
|
|
1422
|
+
|
|
1423
|
+
The kernel rejects packets arriving on any other interface, even if
|
|
1424
|
+
they're addressed to the bind IP — so a misrouted packet from the
|
|
1425
|
+
LAN side, docker0, etc. cannot reach this listener. We also verify
|
|
1426
|
+
the option round-trips via ``getsockopt`` and fail closed if it
|
|
1427
|
+
didn't take effect (older kernels, namespaces, etc.).
|
|
1428
|
+
"""
|
|
1429
|
+
|
|
1430
|
+
def __init__(self, server_address, RequestHandlerClass, *, bind_device: str):
|
|
1431
|
+
self._bind_device = bind_device
|
|
1432
|
+
super().__init__(server_address, RequestHandlerClass)
|
|
1433
|
+
|
|
1434
|
+
def server_bind(self):
|
|
1435
|
+
if not hasattr(socket, "SO_BINDTODEVICE"):
|
|
1436
|
+
raise RuntimeError(
|
|
1437
|
+
"SO_BINDTODEVICE is unavailable on this platform; refusing to "
|
|
1438
|
+
"bind a bridge-restricted listener."
|
|
1439
|
+
)
|
|
1440
|
+
device_bytes = self._bind_device.encode("ascii") + b"\x00"
|
|
1441
|
+
self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_BINDTODEVICE, device_bytes)
|
|
1442
|
+
# Verify the option actually took effect — fail closed if not.
|
|
1443
|
+
bound = self.socket.getsockopt(socket.SOL_SOCKET, socket.SO_BINDTODEVICE, 16)
|
|
1444
|
+
if not bound.startswith(self._bind_device.encode("ascii")):
|
|
1445
|
+
raise RuntimeError(
|
|
1446
|
+
f"SO_BINDTODEVICE did not bind to {self._bind_device!r}; "
|
|
1447
|
+
f"getsockopt returned {bound!r}. Refusing to start."
|
|
1448
|
+
)
|
|
1449
|
+
# Now do the actual bind.
|
|
1450
|
+
super().server_bind()
|
|
1451
|
+
|
|
1452
|
+
|
|
1382
1453
|
# ---------------------------------------------------------------------------
|
|
1383
1454
|
# Daemon
|
|
1384
1455
|
# ---------------------------------------------------------------------------
|
|
@@ -1438,16 +1509,76 @@ def _get_github_token() -> str:
|
|
|
1438
1509
|
return token
|
|
1439
1510
|
|
|
1440
1511
|
|
|
1512
|
+
def _resolve_tcp_bind() -> tuple[str, str | None]:
|
|
1513
|
+
"""Pick the TCP bind address for this platform.
|
|
1514
|
+
|
|
1515
|
+
Returns ``(bind_addr, bind_device)``. ``bind_device`` is the network
|
|
1516
|
+
interface name to restrict via SO_BINDTODEVICE, or None if no
|
|
1517
|
+
restriction applies (macOS).
|
|
1518
|
+
|
|
1519
|
+
Raises if no usable bridge address can be found. We deliberately do
|
|
1520
|
+
NOT fall back to ``127.0.0.1``: containers can't reach the host's
|
|
1521
|
+
loopback, so a loopback bind would produce a listener that bubbles
|
|
1522
|
+
configure against but can never connect to. Failing here means the
|
|
1523
|
+
daemon doesn't start, no endpoint file is written, and local auth
|
|
1524
|
+
setup fails closed.
|
|
1525
|
+
"""
|
|
1526
|
+
import platform
|
|
1527
|
+
|
|
1528
|
+
if platform.system() == "Darwin":
|
|
1529
|
+
# On macOS, incus runs inside a Colima VM; bind to the VMNet
|
|
1530
|
+
# bridge IP so the VM can reach us. No SO_BINDTODEVICE — macOS
|
|
1531
|
+
# doesn't support it, and the VMNet IP is already only on the
|
|
1532
|
+
# bridge interface.
|
|
1533
|
+
from .runtime.colima import colima_bind_ip
|
|
1534
|
+
|
|
1535
|
+
addr = colima_bind_ip()
|
|
1536
|
+
if addr.startswith("127."):
|
|
1537
|
+
raise RuntimeError(
|
|
1538
|
+
"Could not determine the Colima bridge IP (got loopback). "
|
|
1539
|
+
"Is the bubble-colima VM running? Refusing to bind a "
|
|
1540
|
+
"container-unreachable loopback address."
|
|
1541
|
+
)
|
|
1542
|
+
return addr, None
|
|
1543
|
+
|
|
1544
|
+
# Linux: bind to the incus bridge gateway IP and restrict the
|
|
1545
|
+
# listener to incusbr0 so it's unreachable from any other interface.
|
|
1546
|
+
from .incus_bridge import BRIDGE_INTERFACE, bridge_gateway_ipv4
|
|
1547
|
+
|
|
1548
|
+
return bridge_gateway_ipv4(), BRIDGE_INTERFACE
|
|
1549
|
+
|
|
1550
|
+
|
|
1551
|
+
def _write_endpoint_file(tcp_host: str, tcp_port: int):
|
|
1552
|
+
"""Persist the TCP listener endpoint for bubble's auth-setup code."""
|
|
1553
|
+
payload = {
|
|
1554
|
+
"tcp": {"host": tcp_host, "port": tcp_port},
|
|
1555
|
+
"version": 3,
|
|
1556
|
+
}
|
|
1557
|
+
AUTH_PROXY_ENDPOINT_FILE.write_text(json.dumps(payload))
|
|
1558
|
+
os.chmod(str(AUTH_PROXY_ENDPOINT_FILE), 0o600)
|
|
1559
|
+
# Backwards-compat: keep auth-proxy.port readable as an int.
|
|
1560
|
+
AUTH_PROXY_PORT_FILE.write_text(str(tcp_port))
|
|
1561
|
+
os.chmod(str(AUTH_PROXY_PORT_FILE), 0o600)
|
|
1562
|
+
|
|
1563
|
+
|
|
1441
1564
|
def run_daemon(port: int = 0):
|
|
1442
|
-
"""Run the auth proxy daemon.
|
|
1565
|
+
"""Run the auth proxy daemon: a single TCP listener.
|
|
1443
1566
|
|
|
1444
|
-
|
|
1567
|
+
Containers reach it directly over the incus bridge. git talks to it
|
|
1568
|
+
via ``url.insteadOf``; gh talks to it via a small unix→TCP forwarder
|
|
1569
|
+
that runs inside each container (see the gh tool wrapper) — so there
|
|
1570
|
+
is no host-side Unix socket and no incus ``proxy`` device anywhere.
|
|
1571
|
+
|
|
1572
|
+
On Linux the listener is bound to the incus bridge gateway IP and
|
|
1573
|
+
restricted to ``incusbr0`` via ``SO_BINDTODEVICE`` (verified
|
|
1574
|
+
failure-closed via a ``getsockopt`` round-trip). On macOS it binds
|
|
1575
|
+
the Colima bridge IP.
|
|
1445
1576
|
|
|
1446
1577
|
Args:
|
|
1447
1578
|
port: Port to listen on. 0 means use config or default.
|
|
1448
1579
|
"""
|
|
1449
1580
|
_setup_logging()
|
|
1450
|
-
|
|
1581
|
+
AUTH_PROXY_DIR.mkdir(parents=True, exist_ok=True)
|
|
1451
1582
|
|
|
1452
1583
|
if not port:
|
|
1453
1584
|
from .config import load_config
|
|
@@ -1467,29 +1598,29 @@ def run_daemon(port: int = 0):
|
|
|
1467
1598
|
AuthProxyHandler.rate_limiter = rate_limiter
|
|
1468
1599
|
AuthProxyHandler.token_refresher = token_refresher
|
|
1469
1600
|
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
if platform.system() == "Darwin":
|
|
1476
|
-
from .runtime.colima import colima_bind_ip
|
|
1477
|
-
|
|
1478
|
-
bind_addr = colima_bind_ip()
|
|
1601
|
+
bind_addr, bind_device = _resolve_tcp_bind()
|
|
1602
|
+
if bind_device:
|
|
1603
|
+
tcp_server = BridgeBoundHTTPServer(
|
|
1604
|
+
(bind_addr, port), AuthProxyHandler, bind_device=bind_device
|
|
1605
|
+
)
|
|
1479
1606
|
else:
|
|
1480
|
-
|
|
1481
|
-
server = ThreadedHTTPServer((bind_addr, port), AuthProxyHandler)
|
|
1607
|
+
tcp_server = ThreadedHTTPServer((bind_addr, port), AuthProxyHandler)
|
|
1482
1608
|
|
|
1483
|
-
|
|
1484
|
-
os.chmod(str(AUTH_PROXY_PORT_FILE), 0o600)
|
|
1609
|
+
_write_endpoint_file(bind_addr, port)
|
|
1485
1610
|
|
|
1486
|
-
logger.info(
|
|
1611
|
+
logger.info(
|
|
1612
|
+
"Auth proxy daemon started: tcp=%s:%d (device=%s)",
|
|
1613
|
+
bind_addr,
|
|
1614
|
+
port,
|
|
1615
|
+
bind_device or "(unrestricted)",
|
|
1616
|
+
)
|
|
1487
1617
|
print(f"Auth proxy listening on {bind_addr}:{port}")
|
|
1488
1618
|
|
|
1489
1619
|
try:
|
|
1490
|
-
|
|
1620
|
+
tcp_server.serve_forever()
|
|
1491
1621
|
except KeyboardInterrupt:
|
|
1492
1622
|
logger.info("Auth proxy daemon stopped")
|
|
1493
1623
|
finally:
|
|
1494
|
-
|
|
1624
|
+
tcp_server.shutdown()
|
|
1495
1625
|
AUTH_PROXY_PORT_FILE.unlink(missing_ok=True)
|
|
1626
|
+
AUTH_PROXY_ENDPOINT_FILE.unlink(missing_ok=True)
|
|
@@ -20,6 +20,15 @@ import tomli_w
|
|
|
20
20
|
# Override with BUBBLE_HOME environment variable
|
|
21
21
|
DATA_DIR = Path(os.environ.get("BUBBLE_HOME", Path.home() / ".bubble"))
|
|
22
22
|
CONFIG_FILE = DATA_DIR / "config.toml"
|
|
23
|
+
|
|
24
|
+
# The auth-proxy daemon is a host singleton, installed via launchd/systemd
|
|
25
|
+
# with no BUBBLE_HOME in its environment, so it always runs against the
|
|
26
|
+
# default ~/.bubble and writes its endpoint/port/token/log files there.
|
|
27
|
+
# Callers (e.g. `bubble open`) may run under a custom BUBBLE_HOME, but must
|
|
28
|
+
# resolve those daemon files against the daemon's fixed location rather than
|
|
29
|
+
# their own DATA_DIR — otherwise they look for an endpoint the daemon never
|
|
30
|
+
# wrote and write tokens the daemon never reads. See issue #304.
|
|
31
|
+
AUTH_PROXY_DIR = Path.home() / ".bubble"
|
|
23
32
|
REGISTRY_FILE = DATA_DIR / "registry.json"
|
|
24
33
|
GIT_DIR = DATA_DIR / "git"
|
|
25
34
|
REPOS_FILE = DATA_DIR / "repos.json"
|
|
@@ -273,16 +273,55 @@ def apply_network(
|
|
|
273
273
|
gh_level = get_github_level(config)
|
|
274
274
|
if gh_level != "direct" and not (keep_github_domains and gh_level != "off"):
|
|
275
275
|
domains = filter_github_domains(domains)
|
|
276
|
-
|
|
276
|
+
# If the bridge-listener auth proxy is running, punch a hole for the
|
|
277
|
+
# bridge IP:port so the container can reach it directly. Bubbles
|
|
278
|
+
# using the legacy proxy-device flow don't need this (the proxy
|
|
279
|
+
# device delivers traffic on the container's own loopback).
|
|
280
|
+
auth_endpoint = _resolve_auth_proxy_endpoint_for_allowlist(gh_level)
|
|
281
|
+
if domains or auth_endpoint:
|
|
277
282
|
try:
|
|
278
283
|
from .network import apply_allowlist
|
|
279
284
|
|
|
280
|
-
apply_allowlist(runtime, name, domains)
|
|
285
|
+
apply_allowlist(runtime, name, domains, auth_proxy_endpoint=auth_endpoint)
|
|
281
286
|
detail("Network allowlist applied.")
|
|
282
287
|
except (RuntimeError, OSError, ValueError) as e:
|
|
283
288
|
raise click.ClickException(f"Failed to apply network allowlist: {e}")
|
|
284
289
|
|
|
285
290
|
|
|
291
|
+
def _resolve_auth_proxy_endpoint_for_allowlist(gh_level: str) -> tuple[str, int] | None:
|
|
292
|
+
"""Return the bridge auth-proxy endpoint if the bridge flow is active.
|
|
293
|
+
|
|
294
|
+
Returns None when github auth is disabled, when injecting the raw
|
|
295
|
+
token (no proxy needed), or when the daemon hasn't written the v2
|
|
296
|
+
endpoint file (legacy flow). When set, the container needs an
|
|
297
|
+
explicit iptables ACCEPT for the endpoint so it can reach the
|
|
298
|
+
proxy directly (bridge IP on Linux; the Colima bridge IP on macOS,
|
|
299
|
+
reached via the VM's NAT).
|
|
300
|
+
"""
|
|
301
|
+
import json
|
|
302
|
+
|
|
303
|
+
if gh_level in ("off", "direct"):
|
|
304
|
+
return None
|
|
305
|
+
from .auth_proxy import AUTH_PROXY_ENDPOINT_FILE
|
|
306
|
+
|
|
307
|
+
if not AUTH_PROXY_ENDPOINT_FILE.exists():
|
|
308
|
+
return None
|
|
309
|
+
try:
|
|
310
|
+
data = json.loads(AUTH_PROXY_ENDPOINT_FILE.read_text())
|
|
311
|
+
except (json.JSONDecodeError, OSError):
|
|
312
|
+
return None
|
|
313
|
+
tcp = data.get("tcp") or {}
|
|
314
|
+
host = tcp.get("host")
|
|
315
|
+
port = tcp.get("port")
|
|
316
|
+
if not host or not isinstance(port, int):
|
|
317
|
+
return None
|
|
318
|
+
# Don't punch a hole for loopback — that's the legacy bind fallback
|
|
319
|
+
# and the proxy device handles delivery internally.
|
|
320
|
+
if host == "127.0.0.1":
|
|
321
|
+
return None
|
|
322
|
+
return (host, port)
|
|
323
|
+
|
|
324
|
+
|
|
286
325
|
def detect_project_dir(runtime: ContainerRuntime, name: str) -> str:
|
|
287
326
|
"""Detect the project directory inside a container.
|
|
288
327
|
|