bunkervm 0.2.0__py3-none-any.whl
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.
- bunkervm/__init__.py +8 -0
- bunkervm/__main__.py +155 -0
- bunkervm/audit.py +103 -0
- bunkervm/bootstrap.py +288 -0
- bunkervm/config.py +344 -0
- bunkervm/mcp_server.py +388 -0
- bunkervm/safety.py +176 -0
- bunkervm/sandbox_client.py +316 -0
- bunkervm/vm_manager.py +352 -0
- bunkervm-0.2.0.dist-info/METADATA +192 -0
- bunkervm-0.2.0.dist-info/RECORD +15 -0
- bunkervm-0.2.0.dist-info/WHEEL +5 -0
- bunkervm-0.2.0.dist-info/entry_points.txt +2 -0
- bunkervm-0.2.0.dist-info/licenses/LICENSE +661 -0
- bunkervm-0.2.0.dist-info/top_level.txt +1 -0
bunkervm/__init__.py
ADDED
bunkervm/__main__.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
BunkerVM MCP Server — Entry point.
|
|
4
|
+
|
|
5
|
+
Usage:
|
|
6
|
+
python -m bunkervm # Boots VM with internet. Needs sudo.
|
|
7
|
+
python -m bunkervm --transport sse # SSE transport (remote/web clients)
|
|
8
|
+
python -m bunkervm --no-network # Offline mode (no internet in VM)
|
|
9
|
+
python -m bunkervm --skip-vm # VM already running externally
|
|
10
|
+
python -m bunkervm --help
|
|
11
|
+
|
|
12
|
+
Claude Desktop config (claude_desktop_config.json):
|
|
13
|
+
{
|
|
14
|
+
"mcpServers": {
|
|
15
|
+
"bunkervm": {
|
|
16
|
+
"command": "wsl",
|
|
17
|
+
"args": ["-d", "Ubuntu", "--", "sudo", "python3", "-m", "bunkervm"]
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
One line. VM boots with internet access. sudo needed for TAP networking.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
import argparse
|
|
26
|
+
import atexit
|
|
27
|
+
import logging
|
|
28
|
+
import sys
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def main():
|
|
32
|
+
parser = argparse.ArgumentParser(
|
|
33
|
+
prog="bunkervm",
|
|
34
|
+
description="BunkerVM — Hardware-isolated sandbox for AI agents",
|
|
35
|
+
)
|
|
36
|
+
parser.add_argument(
|
|
37
|
+
"--transport", choices=["stdio", "sse"], default="stdio",
|
|
38
|
+
help="MCP transport: stdio (default, for Claude) or sse (remote)",
|
|
39
|
+
)
|
|
40
|
+
parser.add_argument(
|
|
41
|
+
"--port", type=int, default=3000,
|
|
42
|
+
help="Port for SSE transport (default: 3000)",
|
|
43
|
+
)
|
|
44
|
+
parser.add_argument(
|
|
45
|
+
"--config", default=None,
|
|
46
|
+
help="Path to bunkervm.toml config file",
|
|
47
|
+
)
|
|
48
|
+
parser.add_argument(
|
|
49
|
+
"--no-network", action="store_true",
|
|
50
|
+
help="Disable TAP networking (no internet in VM, no sudo needed)",
|
|
51
|
+
)
|
|
52
|
+
parser.add_argument(
|
|
53
|
+
"--skip-vm", action="store_true",
|
|
54
|
+
help="Don't start VM (assume externally managed)",
|
|
55
|
+
)
|
|
56
|
+
parser.add_argument(
|
|
57
|
+
"--vm-ip", default=None,
|
|
58
|
+
help="Override VM IP (only with --network or --skip-vm)",
|
|
59
|
+
)
|
|
60
|
+
parser.add_argument(
|
|
61
|
+
"--vm-port", type=int, default=None,
|
|
62
|
+
help="Override VM exec-agent port",
|
|
63
|
+
)
|
|
64
|
+
parser.add_argument(
|
|
65
|
+
"-v", "--verbose", action="store_true",
|
|
66
|
+
help="Enable debug logging",
|
|
67
|
+
)
|
|
68
|
+
args = parser.parse_args()
|
|
69
|
+
|
|
70
|
+
# Logging to stderr (stdout reserved for MCP stdio protocol)
|
|
71
|
+
logging.basicConfig(
|
|
72
|
+
level=logging.DEBUG if args.verbose else logging.INFO,
|
|
73
|
+
format="%(asctime)s [%(name)s] %(levelname)s: %(message)s",
|
|
74
|
+
stream=sys.stderr,
|
|
75
|
+
)
|
|
76
|
+
logger = logging.getLogger("bunkervm")
|
|
77
|
+
|
|
78
|
+
# ── Load config ──
|
|
79
|
+
from .config import load_config
|
|
80
|
+
config = load_config(args.config)
|
|
81
|
+
|
|
82
|
+
if args.vm_ip:
|
|
83
|
+
config.vm_ip = args.vm_ip
|
|
84
|
+
if args.vm_port:
|
|
85
|
+
config.vm_port = args.vm_port
|
|
86
|
+
|
|
87
|
+
# ── Audit logger ──
|
|
88
|
+
from .audit import AuditLogger
|
|
89
|
+
audit = AuditLogger(config.audit_log_path)
|
|
90
|
+
network = not args.no_network
|
|
91
|
+
audit.log("server_start", transport=args.transport, network=network)
|
|
92
|
+
|
|
93
|
+
# ── Bootstrap: ensure BunkerVM bundle is ready ──
|
|
94
|
+
if not args.skip_vm:
|
|
95
|
+
from .bootstrap import ensure_ready
|
|
96
|
+
|
|
97
|
+
bundle = ensure_ready()
|
|
98
|
+
# Override config paths with bootstrap-provided paths
|
|
99
|
+
config.firecracker_bin = bundle.firecracker
|
|
100
|
+
config.kernel_path = bundle.kernel
|
|
101
|
+
config.rootfs_path = bundle.rootfs
|
|
102
|
+
|
|
103
|
+
# ── Start VM ──
|
|
104
|
+
vm = None
|
|
105
|
+
if not args.skip_vm:
|
|
106
|
+
from .vm_manager import VMManager
|
|
107
|
+
|
|
108
|
+
vm = VMManager(config, network=network)
|
|
109
|
+
try:
|
|
110
|
+
vm.start()
|
|
111
|
+
logger.info("VM started (PID %d)", vm.fc_pid)
|
|
112
|
+
atexit.register(vm.cleanup)
|
|
113
|
+
except Exception as e:
|
|
114
|
+
logger.error("Failed to start VM: %s", e)
|
|
115
|
+
sys.exit(1)
|
|
116
|
+
|
|
117
|
+
# ── Connect to sandbox ──
|
|
118
|
+
from .sandbox_client import SandboxClient
|
|
119
|
+
|
|
120
|
+
if args.skip_vm and args.vm_ip:
|
|
121
|
+
# External VM via TCP
|
|
122
|
+
client = SandboxClient(host=args.vm_ip, port=args.vm_port or config.vm_port)
|
|
123
|
+
else:
|
|
124
|
+
# Default: vsock (works with or without TAP)
|
|
125
|
+
client = SandboxClient(vsock_uds=config.vsock_uds_path, vsock_port=config.vm_port)
|
|
126
|
+
|
|
127
|
+
logger.info("Connecting to sandbox via %s...", client.label)
|
|
128
|
+
|
|
129
|
+
if client.wait_for_health(timeout=config.health_timeout):
|
|
130
|
+
logger.info("Sandbox ready!")
|
|
131
|
+
else:
|
|
132
|
+
if args.skip_vm:
|
|
133
|
+
logger.warning("Sandbox not responding — tools will fail until VM is available")
|
|
134
|
+
else:
|
|
135
|
+
logger.error("Sandbox did not become ready in time")
|
|
136
|
+
sys.exit(1)
|
|
137
|
+
|
|
138
|
+
# ── Start MCP server ──
|
|
139
|
+
from .mcp_server import create_server, set_globals
|
|
140
|
+
|
|
141
|
+
set_globals(client=client, audit=audit, vm_manager=vm, config=config)
|
|
142
|
+
server = create_server()
|
|
143
|
+
|
|
144
|
+
logger.info("BunkerVM MCP server ready (transport: %s)", args.transport)
|
|
145
|
+
audit.log("server_ready", transport=args.transport)
|
|
146
|
+
|
|
147
|
+
if args.transport == "sse":
|
|
148
|
+
logger.info("SSE endpoint: http://0.0.0.0:%d/sse", args.port)
|
|
149
|
+
server.run(transport="sse", port=args.port)
|
|
150
|
+
else:
|
|
151
|
+
server.run(transport="stdio")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
if __name__ == "__main__":
|
|
155
|
+
main()
|
bunkervm/audit.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""
|
|
2
|
+
BunkerVM Audit Logger — Structured event logging (JSONL).
|
|
3
|
+
|
|
4
|
+
Logs all sandbox operations to a JSONL file for audit, debugging,
|
|
5
|
+
and compliance. Each line is a self-contained JSON object with:
|
|
6
|
+
- ISO 8601 timestamp
|
|
7
|
+
- Monotonic sequence number
|
|
8
|
+
- Event type
|
|
9
|
+
- Event-specific payload
|
|
10
|
+
|
|
11
|
+
Thread-safe: uses a lock for concurrent writes.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import logging
|
|
18
|
+
import os
|
|
19
|
+
import threading
|
|
20
|
+
import time
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger("bunkervm.audit")
|
|
24
|
+
|
|
25
|
+
_DEFAULT_LOG_DIR = os.path.expanduser("~/.bunkervm/logs")
|
|
26
|
+
_DEFAULT_LOG_FILE = "audit.jsonl"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class AuditLogger:
|
|
30
|
+
"""Append-only JSONL audit logger.
|
|
31
|
+
|
|
32
|
+
Usage:
|
|
33
|
+
audit = AuditLogger("/var/log/bunkervm/audit.jsonl")
|
|
34
|
+
audit.log("exec", command="ls -la", exit_code=0)
|
|
35
|
+
audit.log("sandbox_reset")
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(self, log_path: str | None = None):
|
|
39
|
+
if log_path is None:
|
|
40
|
+
log_path = os.path.join(_DEFAULT_LOG_DIR, _DEFAULT_LOG_FILE)
|
|
41
|
+
|
|
42
|
+
self.log_path = log_path
|
|
43
|
+
self._lock = threading.Lock()
|
|
44
|
+
self._sequence = 0
|
|
45
|
+
|
|
46
|
+
# Ensure directory exists
|
|
47
|
+
log_dir = os.path.dirname(self.log_path)
|
|
48
|
+
if log_dir:
|
|
49
|
+
os.makedirs(log_dir, exist_ok=True)
|
|
50
|
+
|
|
51
|
+
logger.info("Audit log: %s", self.log_path)
|
|
52
|
+
|
|
53
|
+
def log(self, event_type: str, **kwargs: Any) -> None:
|
|
54
|
+
"""Append an audit event.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
event_type: Event name (e.g., "exec", "read_file", "sandbox_reset").
|
|
58
|
+
**kwargs: Arbitrary key-value pairs for the event payload.
|
|
59
|
+
"""
|
|
60
|
+
with self._lock:
|
|
61
|
+
self._sequence += 1
|
|
62
|
+
entry = {
|
|
63
|
+
"seq": self._sequence,
|
|
64
|
+
"ts": time.time(),
|
|
65
|
+
"iso": time.strftime("%Y-%m-%dT%H:%M:%S%z", time.localtime()),
|
|
66
|
+
"event": event_type,
|
|
67
|
+
**kwargs,
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
line = json.dumps(entry, default=str, ensure_ascii=False)
|
|
72
|
+
with open(self.log_path, "a", encoding="utf-8") as f:
|
|
73
|
+
f.write(line + "\n")
|
|
74
|
+
except Exception as e:
|
|
75
|
+
# Never let audit failures break the server
|
|
76
|
+
logger.error("Failed to write audit log: %s", e)
|
|
77
|
+
|
|
78
|
+
def read_recent(self, n: int = 50) -> list[dict]:
|
|
79
|
+
"""Read the last N audit entries. For debugging."""
|
|
80
|
+
if not os.path.exists(self.log_path):
|
|
81
|
+
return []
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
with open(self.log_path, "r", encoding="utf-8") as f:
|
|
85
|
+
lines = f.readlines()
|
|
86
|
+
|
|
87
|
+
entries = []
|
|
88
|
+
for line in lines[-n:]:
|
|
89
|
+
line = line.strip()
|
|
90
|
+
if line:
|
|
91
|
+
try:
|
|
92
|
+
entries.append(json.loads(line))
|
|
93
|
+
except json.JSONDecodeError:
|
|
94
|
+
pass
|
|
95
|
+
return entries
|
|
96
|
+
except Exception as e:
|
|
97
|
+
logger.error("Failed to read audit log: %s", e)
|
|
98
|
+
return []
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def entry_count(self) -> int:
|
|
102
|
+
"""Approximate number of log entries."""
|
|
103
|
+
return self._sequence
|
bunkervm/bootstrap.py
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
"""
|
|
2
|
+
BunkerVM Bootstrap — Zero-config first-run setup.
|
|
3
|
+
|
|
4
|
+
On first run, automatically downloads the pre-built BunkerVM bundle:
|
|
5
|
+
- firecracker (static binary)
|
|
6
|
+
- vmlinux (Linux kernel)
|
|
7
|
+
- rootfs.ext4 (BunkerVM micro-OS — Alpine + Python + exec_agent)
|
|
8
|
+
|
|
9
|
+
Everything goes into ~/.bunkervm/. Users never touch build scripts.
|
|
10
|
+
|
|
11
|
+
from .bootstrap import ensure_ready
|
|
12
|
+
paths = ensure_ready() # Downloads if needed, returns paths
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import logging
|
|
18
|
+
import os
|
|
19
|
+
import platform
|
|
20
|
+
import shutil
|
|
21
|
+
import stat
|
|
22
|
+
import sys
|
|
23
|
+
import tarfile
|
|
24
|
+
import urllib.error
|
|
25
|
+
import urllib.request
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger("bunkervm.bootstrap")
|
|
30
|
+
|
|
31
|
+
# ── Constants ──
|
|
32
|
+
|
|
33
|
+
BUNKERVM_HOME = Path.home() / ".bunkervm"
|
|
34
|
+
BUNDLE_DIR = BUNKERVM_HOME / "bundle"
|
|
35
|
+
META_FILE = BUNDLE_DIR / "bundle.json"
|
|
36
|
+
|
|
37
|
+
# GitHub release config
|
|
38
|
+
GITHUB_REPO = "ashishgituser/bunkervm"
|
|
39
|
+
BUNDLE_FILENAME = "bunkervm-bundle-x86_64.tar.gz"
|
|
40
|
+
|
|
41
|
+
# Expected files in the bundle
|
|
42
|
+
REQUIRED_FILES = {
|
|
43
|
+
"firecracker": "firecracker",
|
|
44
|
+
"kernel": "vmlinux",
|
|
45
|
+
"rootfs": "rootfs.ext4",
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class BundlePaths:
|
|
51
|
+
"""Paths to the BunkerVM bundle components."""
|
|
52
|
+
firecracker: str
|
|
53
|
+
kernel: str
|
|
54
|
+
rootfs: str
|
|
55
|
+
home: str
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def ready(self) -> bool:
|
|
59
|
+
return all(os.path.exists(p) for p in [self.firecracker, self.kernel, self.rootfs])
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def ensure_ready(version: Optional[str] = None, force: bool = False) -> BundlePaths:
|
|
63
|
+
"""Ensure BunkerVM bundle is downloaded and ready.
|
|
64
|
+
|
|
65
|
+
This is the main entry point. Call it before starting the VM.
|
|
66
|
+
On first run, downloads everything automatically.
|
|
67
|
+
On subsequent runs, returns instantly.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
version: Specific release version (default: latest)
|
|
71
|
+
force: Force re-download even if bundle exists
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
BundlePaths with firecracker, kernel, rootfs paths
|
|
75
|
+
"""
|
|
76
|
+
paths = _get_paths()
|
|
77
|
+
|
|
78
|
+
if paths.ready and not force:
|
|
79
|
+
logger.debug("Bundle ready at %s", BUNDLE_DIR)
|
|
80
|
+
return paths
|
|
81
|
+
|
|
82
|
+
# First run — need to download
|
|
83
|
+
_print_status("BunkerVM first-run setup...")
|
|
84
|
+
|
|
85
|
+
# Check prerequisites
|
|
86
|
+
_check_prerequisites()
|
|
87
|
+
|
|
88
|
+
# Create directories
|
|
89
|
+
BUNDLE_DIR.mkdir(parents=True, exist_ok=True)
|
|
90
|
+
|
|
91
|
+
# Try downloading pre-built bundle from GitHub Releases
|
|
92
|
+
if _download_bundle(version):
|
|
93
|
+
paths = _get_paths()
|
|
94
|
+
if paths.ready:
|
|
95
|
+
_print_status("BunkerVM ready! VM will boot in ~2 seconds.\n")
|
|
96
|
+
return paths
|
|
97
|
+
|
|
98
|
+
# Fallback: check if files exist in the project's build/ dir (dev mode)
|
|
99
|
+
paths = _try_dev_mode()
|
|
100
|
+
if paths and paths.ready:
|
|
101
|
+
_print_status("Using local build (dev mode).\n")
|
|
102
|
+
return paths
|
|
103
|
+
|
|
104
|
+
# Nothing worked
|
|
105
|
+
raise RuntimeError(
|
|
106
|
+
"BunkerVM bundle not found.\n\n"
|
|
107
|
+
"Options:\n"
|
|
108
|
+
f" 1. Download a release from: https://github.com/{GITHUB_REPO}/releases\n"
|
|
109
|
+
f" Extract to: {BUNDLE_DIR}\n\n"
|
|
110
|
+
" 2. Build locally (for contributors):\n"
|
|
111
|
+
" sudo bash build/setup-firecracker.sh\n"
|
|
112
|
+
" sudo bash build/build-sandbox-rootfs.sh\n"
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _get_paths() -> BundlePaths:
|
|
117
|
+
"""Get bundle file paths (may not exist yet)."""
|
|
118
|
+
return BundlePaths(
|
|
119
|
+
firecracker=str(BUNDLE_DIR / REQUIRED_FILES["firecracker"]),
|
|
120
|
+
kernel=str(BUNDLE_DIR / REQUIRED_FILES["kernel"]),
|
|
121
|
+
rootfs=str(BUNDLE_DIR / REQUIRED_FILES["rootfs"]),
|
|
122
|
+
home=str(BUNKERVM_HOME),
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _check_prerequisites() -> None:
|
|
127
|
+
"""Verify the host can run Firecracker."""
|
|
128
|
+
arch = platform.machine()
|
|
129
|
+
if arch not in ("x86_64", "amd64", "AMD64"):
|
|
130
|
+
_print_status(f"Warning: BunkerVM is built for x86_64, detected {arch}")
|
|
131
|
+
|
|
132
|
+
# Check if we're on Linux (or WSL)
|
|
133
|
+
if sys.platform != "linux":
|
|
134
|
+
# We might be on Windows calling into WSL — that's fine
|
|
135
|
+
# The actual VM runs in WSL/Linux
|
|
136
|
+
logger.debug("Not on Linux directly (platform: %s)", sys.platform)
|
|
137
|
+
|
|
138
|
+
# Check /dev/kvm
|
|
139
|
+
if sys.platform == "linux" and not os.path.exists("/dev/kvm"):
|
|
140
|
+
_print_status(
|
|
141
|
+
"Warning: /dev/kvm not found. KVM is required for Firecracker.\n"
|
|
142
|
+
" WSL2: Add to .wslconfig:\n"
|
|
143
|
+
" [wsl2]\n"
|
|
144
|
+
" nestedVirtualization=true\n"
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _download_bundle(version: Optional[str] = None) -> bool:
|
|
149
|
+
"""Download the pre-built bundle from GitHub Releases.
|
|
150
|
+
|
|
151
|
+
Returns True if download succeeded.
|
|
152
|
+
"""
|
|
153
|
+
try:
|
|
154
|
+
# Get download URL
|
|
155
|
+
if version:
|
|
156
|
+
url = f"https://github.com/{GITHUB_REPO}/releases/download/{version}/{BUNDLE_FILENAME}"
|
|
157
|
+
else:
|
|
158
|
+
# Latest release
|
|
159
|
+
url = f"https://github.com/{GITHUB_REPO}/releases/latest/download/{BUNDLE_FILENAME}"
|
|
160
|
+
|
|
161
|
+
_print_status(f"Downloading BunkerVM bundle (~100MB)...")
|
|
162
|
+
_print_status(f" From: {url}")
|
|
163
|
+
|
|
164
|
+
# Download to temp file
|
|
165
|
+
tmp_path = BUNKERVM_HOME / f".{BUNDLE_FILENAME}.tmp"
|
|
166
|
+
tmp_path.parent.mkdir(parents=True, exist_ok=True)
|
|
167
|
+
|
|
168
|
+
try:
|
|
169
|
+
_download_with_progress(url, str(tmp_path))
|
|
170
|
+
except (urllib.error.URLError, urllib.error.HTTPError, OSError) as e:
|
|
171
|
+
logger.debug("Download failed: %s", e)
|
|
172
|
+
_print_status(f" Download not available yet (this is expected for unreleased versions)")
|
|
173
|
+
if tmp_path.exists():
|
|
174
|
+
tmp_path.unlink()
|
|
175
|
+
return False
|
|
176
|
+
|
|
177
|
+
# Extract
|
|
178
|
+
_print_status(" Extracting...")
|
|
179
|
+
try:
|
|
180
|
+
with tarfile.open(str(tmp_path), "r:gz") as tar:
|
|
181
|
+
# Security: check for path traversal
|
|
182
|
+
for member in tar.getmembers():
|
|
183
|
+
if member.name.startswith("/") or ".." in member.name:
|
|
184
|
+
raise ValueError(f"Unsafe path in archive: {member.name}")
|
|
185
|
+
tar.extractall(path=str(BUNDLE_DIR))
|
|
186
|
+
except Exception as e:
|
|
187
|
+
_print_status(f" Extraction failed: {e}")
|
|
188
|
+
if tmp_path.exists():
|
|
189
|
+
tmp_path.unlink()
|
|
190
|
+
return False
|
|
191
|
+
|
|
192
|
+
# Clean up temp file
|
|
193
|
+
if tmp_path.exists():
|
|
194
|
+
tmp_path.unlink()
|
|
195
|
+
|
|
196
|
+
# Make firecracker executable
|
|
197
|
+
fc_path = BUNDLE_DIR / REQUIRED_FILES["firecracker"]
|
|
198
|
+
if fc_path.exists():
|
|
199
|
+
fc_path.chmod(fc_path.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
|
|
200
|
+
|
|
201
|
+
_print_status(" Done!")
|
|
202
|
+
return True
|
|
203
|
+
|
|
204
|
+
except Exception as e:
|
|
205
|
+
logger.debug("Bundle download failed: %s", e)
|
|
206
|
+
return False
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _download_with_progress(url: str, dest: str) -> None:
|
|
210
|
+
"""Download a file with progress indication."""
|
|
211
|
+
req = urllib.request.Request(url, headers={"User-Agent": "BunkerVM-Bootstrap/0.1"})
|
|
212
|
+
response = urllib.request.urlopen(req, timeout=120)
|
|
213
|
+
|
|
214
|
+
total = int(response.headers.get("Content-Length", 0))
|
|
215
|
+
downloaded = 0
|
|
216
|
+
chunk_size = 1024 * 1024 # 1MB chunks
|
|
217
|
+
|
|
218
|
+
with open(dest, "wb") as f:
|
|
219
|
+
while True:
|
|
220
|
+
chunk = response.read(chunk_size)
|
|
221
|
+
if not chunk:
|
|
222
|
+
break
|
|
223
|
+
f.write(chunk)
|
|
224
|
+
downloaded += len(chunk)
|
|
225
|
+
if total > 0:
|
|
226
|
+
pct = int(downloaded * 100 / total)
|
|
227
|
+
mb_done = downloaded / (1024 * 1024)
|
|
228
|
+
mb_total = total / (1024 * 1024)
|
|
229
|
+
_print_status(f" [{pct:3d}%] {mb_done:.0f}/{mb_total:.0f} MB", end="\r")
|
|
230
|
+
|
|
231
|
+
if total > 0:
|
|
232
|
+
_print_status(f" [100%] {total / (1024*1024):.0f} MB ")
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _try_dev_mode() -> Optional[BundlePaths]:
|
|
236
|
+
"""Check if bundle files exist in the project build/ directory (dev mode).
|
|
237
|
+
|
|
238
|
+
This allows contributors who build locally to skip the download.
|
|
239
|
+
Files are symlinked (or copied) into ~/.bunkervm/bundle/ for consistency.
|
|
240
|
+
"""
|
|
241
|
+
# Find project root by looking for bunkervm.toml
|
|
242
|
+
candidates = [
|
|
243
|
+
Path.cwd(),
|
|
244
|
+
Path(__file__).parent.parent, # bunkervm/../
|
|
245
|
+
]
|
|
246
|
+
|
|
247
|
+
for project_root in candidates:
|
|
248
|
+
kernel = project_root / "build" / "vmlinux"
|
|
249
|
+
rootfs = project_root / "build" / "rootfs.ext4"
|
|
250
|
+
|
|
251
|
+
if kernel.exists() and rootfs.exists():
|
|
252
|
+
logger.info("Found local build at %s", project_root)
|
|
253
|
+
|
|
254
|
+
# Check for firecracker
|
|
255
|
+
fc_bin = shutil.which("firecracker")
|
|
256
|
+
if not fc_bin:
|
|
257
|
+
fc_bin = "/usr/local/bin/firecracker"
|
|
258
|
+
if not os.path.exists(fc_bin):
|
|
259
|
+
_print_status(" Firecracker binary not found. Run: sudo bash build/setup-firecracker.sh")
|
|
260
|
+
return None
|
|
261
|
+
|
|
262
|
+
# Symlink/copy into bundle dir for consistency
|
|
263
|
+
BUNDLE_DIR.mkdir(parents=True, exist_ok=True)
|
|
264
|
+
|
|
265
|
+
_link_or_copy(fc_bin, BUNDLE_DIR / "firecracker")
|
|
266
|
+
_link_or_copy(str(kernel), BUNDLE_DIR / "vmlinux")
|
|
267
|
+
_link_or_copy(str(rootfs), BUNDLE_DIR / "rootfs.ext4")
|
|
268
|
+
|
|
269
|
+
return _get_paths()
|
|
270
|
+
|
|
271
|
+
return None
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _link_or_copy(src: str, dst: Path) -> None:
|
|
275
|
+
"""Symlink or copy a file into the bundle directory."""
|
|
276
|
+
if dst.exists() or dst.is_symlink():
|
|
277
|
+
dst.unlink()
|
|
278
|
+
|
|
279
|
+
try:
|
|
280
|
+
dst.symlink_to(src)
|
|
281
|
+
except OSError:
|
|
282
|
+
# Symlinks might fail on some filesystems
|
|
283
|
+
shutil.copy2(src, str(dst))
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _print_status(msg: str, end: str = "\n") -> None:
|
|
287
|
+
"""Print bootstrap status to stderr (stdout is for MCP protocol)."""
|
|
288
|
+
print(msg, file=sys.stderr, end=end, flush=True)
|