labtool-cf 0.1.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.
Potentially problematic release.
This version of labtool-cf might be problematic. Click here for more details.
- labtool/__init__.py +0 -0
- labtool/__main__.py +3 -0
- labtool/adapters/__init__.py +0 -0
- labtool/adapters/cloudflared.py +67 -0
- labtool/adapters/lab_file.py +80 -0
- labtool/adapters/runner.py +67 -0
- labtool/adapters/system.py +29 -0
- labtool/cli.py +145 -0
- labtool/config_builder.py +11 -0
- labtool/domain.py +80 -0
- labtool/labtool.py +39 -0
- labtool/ports.py +12 -0
- labtool/services.py +94 -0
- labtool/ui.py +34 -0
- labtool_cf-0.1.0.dist-info/METADATA +233 -0
- labtool_cf-0.1.0.dist-info/RECORD +20 -0
- labtool_cf-0.1.0.dist-info/WHEEL +5 -0
- labtool_cf-0.1.0.dist-info/entry_points.txt +2 -0
- labtool_cf-0.1.0.dist-info/licenses/LICENSE +21 -0
- labtool_cf-0.1.0.dist-info/top_level.txt +1 -0
labtool/__init__.py
ADDED
|
File without changes
|
labtool/__main__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import subprocess
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import yaml
|
|
6
|
+
|
|
7
|
+
from ..domain import LabError, Tunnel
|
|
8
|
+
|
|
9
|
+
CF_DIR = Path.home() / ".cloudflared"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _run(cmd):
|
|
13
|
+
"""Run a command; raise LabError with stderr on failure."""
|
|
14
|
+
r = subprocess.run(cmd, capture_output=True, text=True)
|
|
15
|
+
if r.returncode != 0:
|
|
16
|
+
raise LabError(f"{' '.join(cmd)}\n{r.stderr.strip()}")
|
|
17
|
+
return r.stdout
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class CloudflaredGateway:
|
|
21
|
+
"""Talks to Cloudflare through the cloudflared binary."""
|
|
22
|
+
|
|
23
|
+
def find(self, name):
|
|
24
|
+
"""Return the Tunnel with this name, or None."""
|
|
25
|
+
out = _run(["cloudflared", "tunnel", "list", "--output", "json"])
|
|
26
|
+
for t in json.loads(out) or []:
|
|
27
|
+
if t["name"] == name:
|
|
28
|
+
return Tunnel(id=t["id"], name=t["name"])
|
|
29
|
+
return None
|
|
30
|
+
|
|
31
|
+
def create(self, name):
|
|
32
|
+
_run(["cloudflared", "tunnel", "create", name])
|
|
33
|
+
return self.find(name)
|
|
34
|
+
|
|
35
|
+
def route_dns(self, tunnel_name, hostname):
|
|
36
|
+
"""Create or overwrite the CNAME record for hostname."""
|
|
37
|
+
_run(["cloudflared", "tunnel", "route", "dns",
|
|
38
|
+
"--overwrite-dns", tunnel_name, hostname])
|
|
39
|
+
|
|
40
|
+
def delete(self, name):
|
|
41
|
+
"""Delete the tunnel; -f also cleans up stale connections."""
|
|
42
|
+
_run(["cloudflared", "tunnel", "delete", "-f", name])
|
|
43
|
+
|
|
44
|
+
def credentials_path(self, tunnel):
|
|
45
|
+
return str(CF_DIR / f"{tunnel.id}.json")
|
|
46
|
+
|
|
47
|
+
def is_logged_in(self):
|
|
48
|
+
"""cert.pem exists only after `cloudflared tunnel login`."""
|
|
49
|
+
return (CF_DIR / "cert.pem").exists()
|
|
50
|
+
|
|
51
|
+
def login(self, log=print):
|
|
52
|
+
"""Run the interactive login; the URL prints straight to the terminal."""
|
|
53
|
+
log("Open the displayed URL in your browser and click Authorize...")
|
|
54
|
+
r = subprocess.run(["cloudflared", "tunnel", "login"])
|
|
55
|
+
if r.returncode != 0:
|
|
56
|
+
raise LabError("Login failed.")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class YamlConfigStore:
|
|
60
|
+
"""Writes the cloudflared config.yml file."""
|
|
61
|
+
|
|
62
|
+
def __init__(self, path=CF_DIR / "config.yml"):
|
|
63
|
+
self.path = path
|
|
64
|
+
|
|
65
|
+
def save(self, config):
|
|
66
|
+
# sort_keys=False: ingress order matters to cloudflared
|
|
67
|
+
self.path.write_text(yaml.safe_dump(config, sort_keys=False))
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import yaml
|
|
2
|
+
import os
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
import click
|
|
5
|
+
from ..domain import Lab, LabError, Node
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
APP_NAME = "labtool"
|
|
9
|
+
|
|
10
|
+
TEMPLATE = """\
|
|
11
|
+
# labtool configuration
|
|
12
|
+
domain: example.com # a domain that is Active on Cloudflare
|
|
13
|
+
tunnel_name: my-tunnel # name of the tunnel
|
|
14
|
+
nodes:
|
|
15
|
+
- name: node1 # becomes node1.example.com
|
|
16
|
+
ip: 192.168.56.11 # address the tunnel machine can reach
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def user_config_path():
|
|
21
|
+
"""Per-user config location (differs per OS)."""
|
|
22
|
+
return Path(click.get_app_dir(APP_NAME)) / "lab.yaml"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def resolve_lab_path(explicit=None):
|
|
26
|
+
"""Find lab.yaml: -f flag, env var, current dir, then user config dir."""
|
|
27
|
+
if explicit:
|
|
28
|
+
return Path(explicit)
|
|
29
|
+
|
|
30
|
+
env = os.environ.get("LABTOOL_FILE")
|
|
31
|
+
if env:
|
|
32
|
+
return Path(env)
|
|
33
|
+
|
|
34
|
+
for candidate in (Path("lab.yaml"), user_config_path()):
|
|
35
|
+
if candidate.exists():
|
|
36
|
+
return candidate
|
|
37
|
+
|
|
38
|
+
raise LabError(
|
|
39
|
+
"lab.yaml not found. Create one with: labtool config init\n"
|
|
40
|
+
f"(or pass -f PATH, or set LABTOOL_FILE). Searched: ./lab.yaml, {user_config_path()}"
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def write_template(path, force=False):
|
|
45
|
+
"""Write the template; refuse to overwrite unless force=True."""
|
|
46
|
+
path = Path(path)
|
|
47
|
+
if path.exists() and not force:
|
|
48
|
+
raise LabError(f"{path} already exists (use --force to overwrite).")
|
|
49
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
50
|
+
path.write_text(TEMPLATE)
|
|
51
|
+
return path
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def load_lab(path):
|
|
55
|
+
"""Read and validate the lab file; raise LabError with a clear message."""
|
|
56
|
+
path = Path(path)
|
|
57
|
+
try:
|
|
58
|
+
raw = yaml.safe_load(path.read_text())
|
|
59
|
+
except FileNotFoundError:
|
|
60
|
+
raise LabError(f"File {path} not found")
|
|
61
|
+
except yaml.YAMLError as e:
|
|
62
|
+
raise LabError(f"{path} is not valid YAML: {e}")
|
|
63
|
+
|
|
64
|
+
if not isinstance(raw, dict):
|
|
65
|
+
raise LabError(f"{path} is empty or has the wrong format")
|
|
66
|
+
|
|
67
|
+
for key in ("domain", "tunnel_name", "nodes"):
|
|
68
|
+
if key not in raw:
|
|
69
|
+
raise LabError(f"{path}: missing required key '{key}'")
|
|
70
|
+
|
|
71
|
+
if not isinstance(raw["nodes"], list) or not raw["nodes"]:
|
|
72
|
+
raise LabError(f"{path}: 'nodes' must be a non-empty list")
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
nodes = tuple(Node(**n) for n in raw["nodes"])
|
|
76
|
+
return Lab(domain=raw["domain"], tunnel_name=raw["tunnel_name"], nodes=nodes)
|
|
77
|
+
except (TypeError, ValueError) as e:
|
|
78
|
+
# TypeError: unknown/missing node field, ValueError: invalid IP, duplicate names
|
|
79
|
+
raise LabError(f"{path}: invalid node definition: {e}")
|
|
80
|
+
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import signal
|
|
3
|
+
import subprocess
|
|
4
|
+
import time
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from ..domain import LabError, RunState
|
|
8
|
+
|
|
9
|
+
STATE_DIR = Path.home() / ".labtool"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ProcessRunner:
|
|
13
|
+
"""Runs cloudflared in the background and tracks it via a PID file."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, state_dir=STATE_DIR):
|
|
16
|
+
self.dir = Path(state_dir)
|
|
17
|
+
self.pid_file = self.dir / "tunnel.pid"
|
|
18
|
+
self.log_path = self.dir / "tunnel.log"
|
|
19
|
+
|
|
20
|
+
def _read_pid(self):
|
|
21
|
+
try:
|
|
22
|
+
return int(self.pid_file.read_text())
|
|
23
|
+
except (FileNotFoundError, ValueError):
|
|
24
|
+
return None
|
|
25
|
+
|
|
26
|
+
def _alive(self, pid):
|
|
27
|
+
"""Signal 0 sends nothing; it only checks that the process exists."""
|
|
28
|
+
try:
|
|
29
|
+
os.kill(pid, 0)
|
|
30
|
+
return True
|
|
31
|
+
except ProcessLookupError:
|
|
32
|
+
return False
|
|
33
|
+
except PermissionError:
|
|
34
|
+
return True # exists, but owned by another user
|
|
35
|
+
|
|
36
|
+
def state(self):
|
|
37
|
+
"""A stale PID file (dead process) counts as not running."""
|
|
38
|
+
pid = self._read_pid()
|
|
39
|
+
if pid and self._alive(pid):
|
|
40
|
+
return RunState(True, pid)
|
|
41
|
+
return RunState(False)
|
|
42
|
+
|
|
43
|
+
def start(self, tunnel_name):
|
|
44
|
+
self.dir.mkdir(parents=True, exist_ok=True)
|
|
45
|
+
log = open(self.log_path, "ab")
|
|
46
|
+
proc = subprocess.Popen(
|
|
47
|
+
["cloudflared", "tunnel", "run", tunnel_name],
|
|
48
|
+
stdout=log, stderr=log,
|
|
49
|
+
start_new_session=True, # detach so it survives closing the terminal/SSH
|
|
50
|
+
)
|
|
51
|
+
self.pid_file.write_text(str(proc.pid))
|
|
52
|
+
time.sleep(1) # if it dies immediately, report it instead of hiding it
|
|
53
|
+
if proc.poll() is not None:
|
|
54
|
+
self.pid_file.unlink(missing_ok=True)
|
|
55
|
+
raise LabError(f"cloudflared exited immediately. Log: {self.log_path}")
|
|
56
|
+
return proc.pid
|
|
57
|
+
|
|
58
|
+
def stop(self):
|
|
59
|
+
"""Send SIGTERM and wait up to 5 seconds for a clean exit."""
|
|
60
|
+
pid = self._read_pid()
|
|
61
|
+
if pid and self._alive(pid):
|
|
62
|
+
os.kill(pid, signal.SIGTERM)
|
|
63
|
+
for _ in range(50):
|
|
64
|
+
if not self._alive(pid):
|
|
65
|
+
break
|
|
66
|
+
time.sleep(0.1)
|
|
67
|
+
self.pid_file.unlink(missing_ok=True)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import platform
|
|
2
|
+
import shutil
|
|
3
|
+
import subprocess
|
|
4
|
+
|
|
5
|
+
from ..domain import LabError, Platform
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def detect_platform():
|
|
9
|
+
system = platform.system().lower() # darwin | linux | windows
|
|
10
|
+
machine = platform.machine().lower()
|
|
11
|
+
arch = "arm64" if machine in ("arm64", "aarch64") else "amd64"
|
|
12
|
+
return Platform(
|
|
13
|
+
system=system,
|
|
14
|
+
arch=arch,
|
|
15
|
+
has_apt=shutil.which("apt-get") is not None,
|
|
16
|
+
has_brew=shutil.which("brew") is not None,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def is_installed():
|
|
21
|
+
return shutil.which("cloudflared") is not None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def run_commands(commands, log=print):
|
|
25
|
+
for cmd in commands:
|
|
26
|
+
log("$ " + " ".join(cmd))
|
|
27
|
+
r = subprocess.run(cmd)
|
|
28
|
+
if r.returncode != 0:
|
|
29
|
+
raise LabError(f"دستور شکست خورد: {' '.join(cmd)}")
|
labtool/cli.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import click
|
|
2
|
+
import sys
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from labtool.adapters import system
|
|
5
|
+
from labtool.services import install_cloudflared
|
|
6
|
+
from importlib.metadata import version
|
|
7
|
+
from labtool.adapters.cloudflared import CloudflaredGateway, YamlConfigStore
|
|
8
|
+
from labtool.adapters.lab_file import load_lab
|
|
9
|
+
from labtool.adapters.runner import ProcessRunner
|
|
10
|
+
from labtool.domain import LabError
|
|
11
|
+
from labtool.services import (
|
|
12
|
+
delete_tunnel,
|
|
13
|
+
down_tunnel,
|
|
14
|
+
ensure_login,
|
|
15
|
+
init_tunnel,
|
|
16
|
+
tunnel_status,
|
|
17
|
+
up_tunnel,
|
|
18
|
+
)
|
|
19
|
+
from . import ui
|
|
20
|
+
from .adapters.lab_file import load_lab, resolve_lab_path, user_config_path, write_template
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
CONTEXT = {"help_option_names": ["-h", "--help"]}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@click.group(context_settings=CONTEXT)
|
|
30
|
+
@click.version_option(version("labtool-cf"), "-v", "--version", prog_name="labtool-cf")
|
|
31
|
+
def cli():
|
|
32
|
+
"""labtool: manage a Cloudflare tunnel for your lab nodes.
|
|
33
|
+
|
|
34
|
+
\b
|
|
35
|
+
Typical flow:
|
|
36
|
+
labtool install install cloudflared
|
|
37
|
+
labtool login authorize with Cloudflare
|
|
38
|
+
labtool init create tunnel, DNS and config.yml
|
|
39
|
+
labtool up run the tunnel in the background
|
|
40
|
+
labtool status check whether it is running
|
|
41
|
+
labtool down stop it
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@cli.command(context_settings=CONTEXT)
|
|
46
|
+
@click.option("-f", "--file", default=None, show_default=True)
|
|
47
|
+
def init(file):
|
|
48
|
+
"""Create the tunnel, DNS records and config.yml."""
|
|
49
|
+
lab = load_lab(resolve_lab_path(file))
|
|
50
|
+
init_tunnel(lab, CloudflaredGateway(), YamlConfigStore(), log=ui.smart_log)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@cli.command(context_settings=CONTEXT)
|
|
54
|
+
def install():
|
|
55
|
+
"""Install cloudflared on this system."""
|
|
56
|
+
install_cloudflared(system.detect_platform, system.is_installed,
|
|
57
|
+
system.run_commands, log=ui.smart_log)
|
|
58
|
+
|
|
59
|
+
@cli.command(context_settings=CONTEXT)
|
|
60
|
+
def status():
|
|
61
|
+
"""Show whether the tunnel is running."""
|
|
62
|
+
state = tunnel_status(ProcessRunner())
|
|
63
|
+
if state.running:
|
|
64
|
+
ui.success(f"Running (PID {state.pid})")
|
|
65
|
+
else:
|
|
66
|
+
ui.warn("Stopped")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@cli.command(context_settings=CONTEXT)
|
|
70
|
+
@click.option("-f", "--file", default=None, show_default=True)
|
|
71
|
+
@click.option("-y", "--yes", is_flag=True, help="Skip the confirmation prompt.")
|
|
72
|
+
def delete(file, yes):
|
|
73
|
+
"""Delete the tunnel from Cloudflare (irreversible)."""
|
|
74
|
+
lab = load_lab(resolve_lab_path(file))
|
|
75
|
+
if not yes:
|
|
76
|
+
click.confirm(
|
|
77
|
+
click.style(f"Delete tunnel '{lab.tunnel_name}'?", fg="red", bold=True),
|
|
78
|
+
abort=True,
|
|
79
|
+
)
|
|
80
|
+
delete_tunnel(lab, CloudflaredGateway(), ProcessRunner(), log=ui.smart_log)
|
|
81
|
+
|
|
82
|
+
@cli.command(context_settings=CONTEXT)
|
|
83
|
+
@click.option("-f", "--file", default=None, show_default=True,
|
|
84
|
+
help="Path to the lab definition file.")
|
|
85
|
+
def up(file):
|
|
86
|
+
"""Run the tunnel in the background."""
|
|
87
|
+
lab = load_lab(resolve_lab_path(file))
|
|
88
|
+
up_tunnel(lab, CloudflaredGateway(), ProcessRunner(), log=ui.smart_log)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@cli.command(context_settings=CONTEXT)
|
|
92
|
+
def down():
|
|
93
|
+
"""Stop the running tunnel."""
|
|
94
|
+
down_tunnel(ProcessRunner(), log=ui.smart_log)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@cli.command(context_settings=CONTEXT)
|
|
98
|
+
def login():
|
|
99
|
+
"""Log in to Cloudflare (creates cert.pem)."""
|
|
100
|
+
ensure_login(CloudflaredGateway(), log=ui.smart_log)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@cli.group(context_settings=CONTEXT)
|
|
105
|
+
def config():
|
|
106
|
+
"""Manage the lab.yaml configuration file."""
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@config.command("init", context_settings=CONTEXT)
|
|
110
|
+
@click.option("--local", is_flag=True, help="Create ./lab.yaml instead of the user config dir.")
|
|
111
|
+
@click.option("--force", is_flag=True, help="Overwrite an existing file.")
|
|
112
|
+
def config_init(local, force):
|
|
113
|
+
"""Create a template lab.yaml and show where it is."""
|
|
114
|
+
path = Path("lab.yaml") if local else user_config_path()
|
|
115
|
+
write_template(path, force)
|
|
116
|
+
ui.success(f"Created {path}")
|
|
117
|
+
ui.info("Edit it (domain, tunnel_name, nodes), then run: labtool init")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@config.command("path", context_settings=CONTEXT)
|
|
121
|
+
def config_path():
|
|
122
|
+
"""Print the config file that would be used."""
|
|
123
|
+
click.echo(resolve_lab_path())
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def main():
|
|
129
|
+
"""Entry point: turn errors and Ctrl+C into clean, colored messages."""
|
|
130
|
+
try:
|
|
131
|
+
cli(standalone_mode=False)
|
|
132
|
+
except click.exceptions.Abort:
|
|
133
|
+
ui.warn("Cancelled.")
|
|
134
|
+
sys.exit(130) # 130 = conventional exit code for Ctrl+C
|
|
135
|
+
except KeyboardInterrupt:
|
|
136
|
+
ui.warn("Cancelled (Ctrl+C).")
|
|
137
|
+
sys.exit(130)
|
|
138
|
+
except click.ClickException as e:
|
|
139
|
+
ui.error(e.format_message())
|
|
140
|
+
sys.exit(e.exit_code)
|
|
141
|
+
except click.exceptions.Exit as e:
|
|
142
|
+
sys.exit(e.exit_code)
|
|
143
|
+
except LabError as e:
|
|
144
|
+
ui.error(str(e))
|
|
145
|
+
sys.exit(1)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
def build_tunnel_config(lab, tunnel, credentials_path):
|
|
2
|
+
ingress = [
|
|
3
|
+
{"hostname": lab.hostname(n), "service": n.service}
|
|
4
|
+
for n in lab.nodes
|
|
5
|
+
]
|
|
6
|
+
ingress.append({"service": "http_status:404"}) # The final mandatory rule
|
|
7
|
+
return {
|
|
8
|
+
"tunnel": tunnel.id,
|
|
9
|
+
"credentials-file": credentials_path,
|
|
10
|
+
"ingress": ingress,
|
|
11
|
+
}
|
labtool/domain.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import ipaddress
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class LabError(Exception):
|
|
6
|
+
"""A user-facing error."""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class Node:
|
|
11
|
+
name: str
|
|
12
|
+
ip: str
|
|
13
|
+
port: int = 22
|
|
14
|
+
|
|
15
|
+
def __post_init__(self):
|
|
16
|
+
ipaddress.ip_address(self.ip) # An invalid IP triggers an error here.
|
|
17
|
+
|
|
18
|
+
@property
|
|
19
|
+
def service(self):
|
|
20
|
+
return f"ssh://{self.ip}:{self.port}"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class Lab:
|
|
25
|
+
domain: str
|
|
26
|
+
tunnel_name: str
|
|
27
|
+
nodes: tuple
|
|
28
|
+
|
|
29
|
+
def __post_init__(self):
|
|
30
|
+
names = [n.name for n in self.nodes]
|
|
31
|
+
if len(names) != len(set(names)):
|
|
32
|
+
raise LabError("The node names are duplicated.")
|
|
33
|
+
|
|
34
|
+
def hostname(self, node):
|
|
35
|
+
return f"{node.name}.{self.domain}"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class Tunnel:
|
|
40
|
+
id: str
|
|
41
|
+
name: str
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class Platform:
|
|
45
|
+
system: str # "darwin" | "linux" | "windows"
|
|
46
|
+
arch: str # "amd64" | "arm64"
|
|
47
|
+
has_apt: bool = False
|
|
48
|
+
has_brew: bool = False
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class RunState:
|
|
52
|
+
"""Whether the tunnel process is running, and its PID if so."""
|
|
53
|
+
running: bool
|
|
54
|
+
pid: int | None = None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def plan_install(p):
|
|
58
|
+
"""List of commands required to install cloudflared on this platform."""
|
|
59
|
+
if p.system == "darwin":
|
|
60
|
+
if not p.has_brew:
|
|
61
|
+
raise LabError("brew is required to install cloudflared on macOS")
|
|
62
|
+
return [["brew", "install", "cloudflared"]]
|
|
63
|
+
|
|
64
|
+
if p.system == "linux":
|
|
65
|
+
url = ("https://github.com/cloudflare/cloudflared/releases/latest/download/"
|
|
66
|
+
f"cloudflared-linux-{p.arch}")
|
|
67
|
+
if p.has_apt:
|
|
68
|
+
return [
|
|
69
|
+
["curl", "-fsSL", "-o", "/tmp/cloudflared.deb", url + ".deb"],
|
|
70
|
+
["sudo", "dpkg", "-i", "/tmp/cloudflared.deb"],
|
|
71
|
+
]
|
|
72
|
+
return [
|
|
73
|
+
["curl", "-fsSL", "-o", "/tmp/cloudflared", url],
|
|
74
|
+
["sudo", "install", "/tmp/cloudflared", "/usr/local/bin/cloudflared"],
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
if p.system == "windows":
|
|
78
|
+
return [["winget", "install", "--id", "Cloudflare.cloudflared", "-e"]]
|
|
79
|
+
|
|
80
|
+
raise LabError(f"Unsupported operating system: {p.system}")
|
labtool/labtool.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import ipaddress
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
import yaml
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def load_lab(path):
|
|
8
|
+
with open(path) as f:
|
|
9
|
+
lab = yaml.safe_load(f)
|
|
10
|
+
for node in lab["nodes"]:
|
|
11
|
+
ipaddress.ip_address(node["ip"])
|
|
12
|
+
return lab
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@click.group()
|
|
16
|
+
def cli():
|
|
17
|
+
"""lab tool management"""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@cli.command()
|
|
21
|
+
@click.option("--file", "-f", default="lab.yaml")
|
|
22
|
+
@click.option("--tunnel-id", required=True)
|
|
23
|
+
@click.option("--creds", default="~/.cloudflared/TUNNEL_ID.json")
|
|
24
|
+
def render(file, tunnel_id, creds):
|
|
25
|
+
"""print tunnel config.yml"""
|
|
26
|
+
lab = load_lab(file)
|
|
27
|
+
ingress = []
|
|
28
|
+
for node in lab["nodes"]:
|
|
29
|
+
ingress.append({
|
|
30
|
+
"hostname": f"{node['name']}.{lab['domain']}",
|
|
31
|
+
"service": f"ssh://{node['ip']}:22",
|
|
32
|
+
})
|
|
33
|
+
ingress.append({"service": "http_status:404"})
|
|
34
|
+
config = {"tunnel": tunnel_id, "credentials-file": creds, "ingress": ingress}
|
|
35
|
+
click.echo(yaml.safe_dump(config, sort_keys=False))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
if __name__ == "__main__":
|
|
39
|
+
cli()
|
labtool/ports.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from typing import Protocol
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class TunnelGateway(Protocol):
|
|
5
|
+
def find(self, name): ...
|
|
6
|
+
def create(self, name): ...
|
|
7
|
+
def route_dns(self, tunnel_name, hostname): ...
|
|
8
|
+
def credentials_path(self, tunnel): ...
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ConfigStore(Protocol):
|
|
12
|
+
def save(self, config): ...
|
labtool/services.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
from .config_builder import build_tunnel_config
|
|
2
|
+
from .domain import plan_install
|
|
3
|
+
from .domain import LabError
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def init_tunnel(lab, gateway, store, log=print):
|
|
11
|
+
ensure_login(gateway, log)
|
|
12
|
+
tunnel = gateway.find(lab.tunnel_name)
|
|
13
|
+
if tunnel:
|
|
14
|
+
log(f"tunnel {lab.tunnel_name} already exists.")
|
|
15
|
+
else:
|
|
16
|
+
tunnel = gateway.create(lab.tunnel_name)
|
|
17
|
+
log(f"tunnel {lab.tunnel_name} created.")
|
|
18
|
+
|
|
19
|
+
for node in lab.nodes:
|
|
20
|
+
host = lab.hostname(node)
|
|
21
|
+
gateway.route_dns(lab.tunnel_name, host)
|
|
22
|
+
log(f"DNS: {host}")
|
|
23
|
+
|
|
24
|
+
config = build_tunnel_config(lab, tunnel, gateway.credentials_path(tunnel))
|
|
25
|
+
store.save(config)
|
|
26
|
+
return tunnel
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def ensure_login(gateway, log=print):
|
|
30
|
+
"""Start the interactive login only if cert.pem is missing."""
|
|
31
|
+
if gateway.is_logged_in():
|
|
32
|
+
log("Already logged in.")
|
|
33
|
+
return False
|
|
34
|
+
gateway.login(log)
|
|
35
|
+
return True
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def up_tunnel(lab, gateway, runner, log=print):
|
|
39
|
+
"""Run the tunnel in the background (no-op if already running)."""
|
|
40
|
+
state = runner.state()
|
|
41
|
+
if state.running:
|
|
42
|
+
log(f"The tunnel is already running (PID {state.pid}).")
|
|
43
|
+
return state
|
|
44
|
+
|
|
45
|
+
tunnel = gateway.find(lab.tunnel_name)
|
|
46
|
+
if not tunnel:
|
|
47
|
+
raise LabError(f"The tunnel {lab.tunnel_name} does not exist. First run: labtool init")
|
|
48
|
+
|
|
49
|
+
pid = runner.start(lab.tunnel_name)
|
|
50
|
+
log(f"The tunnel is running (PID {pid}). Log: {runner.log_path}")
|
|
51
|
+
return runner.state()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def down_tunnel(runner, log=print):
|
|
55
|
+
"""Stop the running tunnel. Returns True if something was stopped."""
|
|
56
|
+
state = runner.state()
|
|
57
|
+
if not state.running:
|
|
58
|
+
log("The tunnel is not running.")
|
|
59
|
+
return False
|
|
60
|
+
runner.stop()
|
|
61
|
+
log(f"The tunnel has been stopped (PID {state.pid}).")
|
|
62
|
+
return True
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def tunnel_status(runner):
|
|
66
|
+
"""Return the current RunState."""
|
|
67
|
+
return runner.state()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def delete_tunnel(lab, gateway, runner, log=print):
|
|
71
|
+
"""Stop the tunnel, then delete it from Cloudflare."""
|
|
72
|
+
tunnel = gateway.find(lab.tunnel_name)
|
|
73
|
+
if not tunnel:
|
|
74
|
+
log(f"Tunnel {lab.tunnel_name} does not exist.")
|
|
75
|
+
return False
|
|
76
|
+
|
|
77
|
+
down_tunnel(runner, log) # a running tunnel cannot be deleted
|
|
78
|
+
gateway.delete(lab.tunnel_name)
|
|
79
|
+
log(f"Tunnel {lab.tunnel_name} has been deleted.")
|
|
80
|
+
return True
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def install_cloudflared(detect, installed, run, log=print):
|
|
86
|
+
if installed():
|
|
87
|
+
log("cloudflared is already installed.")
|
|
88
|
+
return False
|
|
89
|
+
commands = plan_install(detect())
|
|
90
|
+
run(commands, log)
|
|
91
|
+
log("cloudflared installed.")
|
|
92
|
+
return True
|
|
93
|
+
|
|
94
|
+
|
labtool/ui.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import click
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def success(msg):
|
|
5
|
+
click.echo(click.style("✔ ", fg="green", bold=True) + msg)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def info(msg):
|
|
9
|
+
click.echo(click.style("ℹ ", fg="cyan") + msg)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def warn(msg):
|
|
13
|
+
click.echo(click.style("! ", fg="yellow", bold=True) + click.style(msg, fg="yellow"))
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def error(msg):
|
|
17
|
+
click.echo(click.style("✖ ", fg="red", bold=True) + click.style(msg, fg="red"), err=True)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def step(msg):
|
|
21
|
+
"""A command being run, shown dimmed."""
|
|
22
|
+
click.echo(click.style(f"$ {msg}", dim=True))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def smart_log(msg):
|
|
26
|
+
"""Pick a color by message content so services stay UI-agnostic."""
|
|
27
|
+
if "created" in msg or "started" in msg or "stopped" in msg or "deleted" in msg:
|
|
28
|
+
success(msg)
|
|
29
|
+
elif "already exists" in msg or "does not exist" in msg or "not found" in msg:
|
|
30
|
+
warn(msg)
|
|
31
|
+
elif msg.startswith("$"):
|
|
32
|
+
step(msg[2:])
|
|
33
|
+
else:
|
|
34
|
+
info(msg)
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: labtool-cf
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Manage a Cloudflare Tunnel for machines behind NAT
|
|
5
|
+
Author-email: Your Name <you@example.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/afourteene/labtool
|
|
8
|
+
Keywords: cloudflare,tunnel,cloudflared,cli
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Requires-Python: >=3.10
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
License-File: LICENSE
|
|
15
|
+
Requires-Dist: click>=8.1
|
|
16
|
+
Requires-Dist: pyyaml>=6.0
|
|
17
|
+
Provides-Extra: dev
|
|
18
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
19
|
+
Requires-Dist: build; extra == "dev"
|
|
20
|
+
Requires-Dist: twine; extra == "dev"
|
|
21
|
+
Dynamic: license-file
|
|
22
|
+
|
|
23
|
+
# labtool
|
|
24
|
+
|
|
25
|
+
A small CLI that sets up and manages a Cloudflare Tunnel (`cloudflared`) for machines behind NAT, so they can be reached over the internet without opening any ports.
|
|
26
|
+
|
|
27
|
+
Everything is described in one file, `lab.yaml`. The tool turns it into a tunnel, DNS records and a `cloudflared` `config.yml`.
|
|
28
|
+
|
|
29
|
+
## Requirements
|
|
30
|
+
|
|
31
|
+
- Python 3.10+
|
|
32
|
+
- A domain managed by Cloudflare (status: Active)
|
|
33
|
+
- A Cloudflare account (used once for `labtool login`)
|
|
34
|
+
- `cloudflared` (the tool can install it for you: `labtool install`)
|
|
35
|
+
|
|
36
|
+
## Using your domain with Cloudflare
|
|
37
|
+
|
|
38
|
+
`labtool` creates DNS records such as `node1.example.com`, so your domain must be managed by Cloudflare (its nameservers must point to Cloudflare). You only do this once per domain.
|
|
39
|
+
|
|
40
|
+
### Option A: you already own a domain at another registrar
|
|
41
|
+
|
|
42
|
+
1. Create a free account at [dash.cloudflare.com](https://dash.cloudflare.com).
|
|
43
|
+
2. Go to **Domains** and click **Add domain**. Enter your domain (for example `example.com`) and choose the **Free** plan.
|
|
44
|
+
3. Cloudflare scans your existing DNS records. Review them and continue. Keep any records you still need (website, mail).
|
|
45
|
+
4. Cloudflare shows you two nameservers, for example `xxx.ns.cloudflare.com` and `yyy.ns.cloudflare.com`.
|
|
46
|
+
5. Log in to your registrar (where you bought the domain), open the domain's nameserver settings, and replace the current nameservers with the two from Cloudflare.
|
|
47
|
+
6. Wait until the domain shows **Active** in the Cloudflare dashboard. This usually takes minutes, but can take up to 24 hours.
|
|
48
|
+
|
|
49
|
+
Check from the terminal:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
dig +short NS example.com
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
The output should list the two `*.ns.cloudflare.com` servers.
|
|
56
|
+
|
|
57
|
+
### Option B: buy the domain from Cloudflare
|
|
58
|
+
|
|
59
|
+
In the dashboard choose **Domains**, then **Buy domain**. A domain bought there is managed by Cloudflare right away; there is nothing to move.
|
|
60
|
+
|
|
61
|
+
### Authorize labtool for that domain
|
|
62
|
+
|
|
63
|
+
Run:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
labtool login
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Open the printed URL, log in, and **select your domain** on the authorization page. Cloudflare saves a `cert.pem` that lets `labtool` create tunnels and DNS records for that domain only. If you own several domains, pick the one set as `domain:` in `lab.yaml`.
|
|
70
|
+
|
|
71
|
+
### What `labtool init` does to your DNS
|
|
72
|
+
|
|
73
|
+
For every node in `lab.yaml` it creates a proxied `CNAME` record that points to the tunnel:
|
|
74
|
+
|
|
75
|
+
| Record | Type | Points to |
|
|
76
|
+
|---|---|---|
|
|
77
|
+
| `node1.example.com` | CNAME (proxied) | `<TUNNEL_ID>.cfargotunnel.com` |
|
|
78
|
+
|
|
79
|
+
Notes:
|
|
80
|
+
|
|
81
|
+
- No server IP is stored in DNS, and no port has to be opened on your machines.
|
|
82
|
+
- If a record with the same name already exists, `init` overwrites it. Do not reuse a hostname that serves something else.
|
|
83
|
+
- To use a subdomain only for this tool, set `domain: lab.example.com` in `lab.yaml`; nodes then become `node1.lab.example.com`.
|
|
84
|
+
- After `labtool delete`, the CNAME records stay in DNS. Remove them in the dashboard under **DNS > Records**.
|
|
85
|
+
- The free plan is enough. No payment method is needed for DNS and tunnels.
|
|
86
|
+
|
|
87
|
+
### Troubleshooting the domain
|
|
88
|
+
|
|
89
|
+
| Problem | Fix |
|
|
90
|
+
|---|---|
|
|
91
|
+
| Domain stays **Pending** | Nameservers at the registrar are not changed yet, or have not propagated. Re-check step 5 and wait. |
|
|
92
|
+
| `labtool init` cannot create DNS records | The domain is not Active, or you selected a different domain during `labtool login`. Delete `~/.cloudflared/cert.pem` and run `labtool login` again. |
|
|
93
|
+
| `node1.example.com` does not resolve | The record is missing. Run `labtool init` again and check the dashboard under **DNS > Records**. |
|
|
94
|
+
|
|
95
|
+
## Installation
|
|
96
|
+
|
|
97
|
+
From the project root (the folder containing `pyproject.toml`):
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
python3 -m venv venv
|
|
101
|
+
source venv/bin/activate
|
|
102
|
+
pip install -e .
|
|
103
|
+
labtool -h
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
For development (tests):
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
pip install pytest
|
|
110
|
+
pytest -v
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
You can also run it as a module: `python -m labtool -h`.
|
|
114
|
+
|
|
115
|
+
## Configuration: `lab.yaml`
|
|
116
|
+
|
|
117
|
+
```yaml
|
|
118
|
+
domain: example.com # a domain active on Cloudflare
|
|
119
|
+
tunnel_name: my-tunnel # name of the tunnel
|
|
120
|
+
nodes:
|
|
121
|
+
- name: node1 # becomes node1.example.com
|
|
122
|
+
ip: 192.168.56.11 # address the tunnel machine can reach
|
|
123
|
+
- name: node2
|
|
124
|
+
ip: 192.168.56.12
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Each node is exposed as `ssh://<ip>:22` under `<name>.<domain>`. Run commands from the folder that contains `lab.yaml`, or pass `-f path/to/lab.yaml`.
|
|
128
|
+
|
|
129
|
+
## Quick start
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
labtool install # 1. install cloudflared for this OS
|
|
133
|
+
labtool login # 2. authorize with Cloudflare (creates cert.pem)
|
|
134
|
+
labtool init # 3. create tunnel + DNS records + config.yml
|
|
135
|
+
labtool up # 4. run the tunnel in the background
|
|
136
|
+
labtool status # 5. check it
|
|
137
|
+
labtool down # 6. stop it
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
## How it works
|
|
141
|
+
|
|
142
|
+
1. **You describe the lab** in `lab.yaml`: a domain, a tunnel name and a list of nodes (name + IP).
|
|
143
|
+
2. **`labtool init`** talks to Cloudflare through the `cloudflared` binary. It creates the tunnel (once), adds a DNS record `<node>.<domain>` for every node, and writes a `config.yml` whose `ingress` rules map each hostname to `ssh://<ip>:22`. A last catch-all rule (`http_status:404`) is added automatically, because `cloudflared` requires it.
|
|
144
|
+
3. **`labtool up`** starts `cloudflared tunnel run` in the background and remembers its PID. The tunnel connects *outward* to Cloudflare, so no inbound port is opened.
|
|
145
|
+
4. **A client** connects to `node1.example.com`. The request reaches Cloudflare, travels through the tunnel to your machine, and is forwarded to the node's IP.
|
|
146
|
+
5. **`labtool down`** stops the process; the hostnames stop working until you run `up` again.
|
|
147
|
+
|
|
148
|
+
```
|
|
149
|
+
client ──ssh──► Cloudflare ──► tunnel (cloudflared on your machine) ──► node IP:22
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
## Commands
|
|
153
|
+
|
|
154
|
+
Every command supports `-h` / `--help`. `labtool -v` prints the version.
|
|
155
|
+
|
|
156
|
+
| Command | What it does |
|
|
157
|
+
|---|---|
|
|
158
|
+
| `install` | Installs `cloudflared` (Homebrew on macOS, `.deb` or binary on Linux, `winget` on Windows). Skips if already installed. |
|
|
159
|
+
| `login` | Runs `cloudflared tunnel login`. Prints a URL; open it in any browser and click Authorize. Skips if `cert.pem` already exists. |
|
|
160
|
+
| `init` | Logs in if needed, creates the tunnel if missing, routes DNS for every node, and writes `~/.cloudflared/config.yml`. This is the only command that creates the tunnel. |
|
|
161
|
+
| `up` | Starts `cloudflared tunnel run` in the background. Does nothing if already running. |
|
|
162
|
+
| `down` | Stops the running tunnel. |
|
|
163
|
+
| `status` | Shows whether the tunnel is running (and its PID). |
|
|
164
|
+
| `delete` | Stops and deletes the tunnel from Cloudflare. Asks for confirmation; `-y` skips the prompt. Irreversible. |
|
|
165
|
+
|
|
166
|
+
Common option: `-f, --file PATH` (default `lab.yaml`).
|
|
167
|
+
|
|
168
|
+
All commands are safe to run more than once (idempotent).
|
|
169
|
+
|
|
170
|
+
## Files used
|
|
171
|
+
|
|
172
|
+
| Path | Purpose |
|
|
173
|
+
|---|---|
|
|
174
|
+
| `~/.cloudflared/cert.pem` | Result of `login`. Admin access to your domain; treat it like a password. |
|
|
175
|
+
| `~/.cloudflared/<TUNNEL_ID>.json` | Tunnel credentials, needed on any machine that runs the tunnel. |
|
|
176
|
+
| `~/.cloudflared/config.yml` | Generated by `init`. |
|
|
177
|
+
| `~/.labtool/tunnel.pid` | PID of the background tunnel. |
|
|
178
|
+
| `~/.labtool/tunnel.log` | `cloudflared` output (check here if `up` fails). |
|
|
179
|
+
|
|
180
|
+
## Connecting to a node
|
|
181
|
+
|
|
182
|
+
Install `cloudflared` on the client machine, then:
|
|
183
|
+
|
|
184
|
+
```bash
|
|
185
|
+
ssh -i ~/.ssh/id_rsa \
|
|
186
|
+
-o ProxyCommand="cloudflared access ssh --hostname %h" \
|
|
187
|
+
user@node1.example.com
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
## Running the tunnel on another machine
|
|
191
|
+
|
|
192
|
+
- `cert.pem` is only needed for management commands (`init`, `delete`). Running a tunnel (`up`) needs only `<TUNNEL_ID>.json` and `config.yml`.
|
|
193
|
+
- The credentials file exists only on the machine where the tunnel was created. Either copy it to the other machine, or set a different `tunnel_name` in that machine's `lab.yaml` and run `labtool init` there to create a separate tunnel.
|
|
194
|
+
- Do not run the same tunnel on two machines at once unless you want traffic split between them. Stop one with `labtool down` (or `Ctrl+C`).
|
|
195
|
+
- `init` overwrites existing DNS records, so they will point to the tunnel you just initialised.
|
|
196
|
+
|
|
197
|
+
## Troubleshooting
|
|
198
|
+
|
|
199
|
+
| Message | Cause and fix |
|
|
200
|
+
|---|---|
|
|
201
|
+
| `Cannot determine default origin certificate path` | No `cert.pem`. Run `labtool login`. |
|
|
202
|
+
| `Tunnel credentials file ... doesn't exist` | Credentials are missing on this machine. Copy the `.json`, or create a tunnel here. |
|
|
203
|
+
| `cloudflared exited immediately` | Check `~/.labtool/tunnel.log` (bad config or missing credentials). |
|
|
204
|
+
| `You have an existing certificate ... which login would overwrite` | You are already logged in. Nothing to do. |
|
|
205
|
+
| `lab.yaml is empty or invalid` | The file is empty or has the wrong format. |
|
|
206
|
+
| `No module named labtool.cli` | `cli.py` must be in `src/labtool/`; then run `pip install -e .` again. |
|
|
207
|
+
|
|
208
|
+
`Ctrl+C` cancels any command cleanly (exit code 130).
|
|
209
|
+
|
|
210
|
+
## Project layout
|
|
211
|
+
|
|
212
|
+
```
|
|
213
|
+
labtool/
|
|
214
|
+
├── pyproject.toml
|
|
215
|
+
├── lab.yaml
|
|
216
|
+
├── src/labtool/
|
|
217
|
+
│ ├── cli.py # click commands only (wiring)
|
|
218
|
+
│ ├── ui.py # colored output
|
|
219
|
+
│ ├── services.py # use cases: init, up, down, delete, ...
|
|
220
|
+
│ ├── domain.py # models and pure rules, no I/O
|
|
221
|
+
│ ├── config_builder.py # pure function: Lab -> tunnel config
|
|
222
|
+
│ ├── ports.py # interfaces
|
|
223
|
+
│ └── adapters/ # cloudflared, process runner, system, lab file
|
|
224
|
+
└── tests/ # run with pytest, using fakes (no Cloudflare needed)
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
Dependencies point inward: `cli -> services -> domain`, and adapters implement the interfaces in `ports`. Services never import `click` or `subprocess`, which is why they can be tested with fakes.
|
|
228
|
+
|
|
229
|
+
## Security notes
|
|
230
|
+
|
|
231
|
+
- Never commit `cert.pem`, `<TUNNEL_ID>.json`, SSH private keys, or tokens. Add them to `.gitignore` (`venv/`, `.labtool/`, `*.json`, `ssh-keys/`).
|
|
232
|
+
- Use key-only SSH on the nodes (`PasswordAuthentication no`).
|
|
233
|
+
- A tunnel hostname is public while the tunnel runs. Stop it with `labtool down` when you are not using it.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
labtool/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
labtool/__main__.py,sha256=VlwlDPUFQme_ppKS7d1cN-_ww3xpMR2WGOPVqVUcMZo,29
|
|
3
|
+
labtool/cli.py,sha256=8EZF-6aCKVxLVJv6qY4YuQU27FKZTSTiXygJyncMslY,4636
|
|
4
|
+
labtool/config_builder.py,sha256=S5UOq1ixG7Ai_veN93cBFJqbYqjM0ZwQh0r13fHEnPg,365
|
|
5
|
+
labtool/domain.py,sha256=EJ-_q416I9p30XCQcVBJTmnuU9V8pioGhh31B8Vt9jQ,2139
|
|
6
|
+
labtool/labtool.py,sha256=sfP4JcPS0bHBrznBMLzA4KNl6IZuAiq2Ph2_prORPhI,957
|
|
7
|
+
labtool/ports.py,sha256=F5RhcIRii8Pw5EDMT1MQhT1Ab7r51g7PtAUl3jWdL7A,281
|
|
8
|
+
labtool/services.py,sha256=AXoBF3haW_1Vc5lFkyfdQWpwlhvoxNZk9rxyds_rZLo,2575
|
|
9
|
+
labtool/ui.py,sha256=o9dXK0eU2qKLUKxjE55_jtMiywt_HNfnl42OsPaPKpo,881
|
|
10
|
+
labtool/adapters/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
11
|
+
labtool/adapters/cloudflared.py,sha256=Qpj-70ymjG3X3hA-QjbwAMEq4cOQhwy7nWphjV6UNj8,2177
|
|
12
|
+
labtool/adapters/lab_file.py,sha256=fzT67fOVRZooDtl_SI4-iIpT8FvWICyxmSLlOS-567I,2523
|
|
13
|
+
labtool/adapters/runner.py,sha256=wHMBTKTIWZKZ5Bhp7xm88vJu2W0dibttpNLPCcgQK1o,2196
|
|
14
|
+
labtool/adapters/system.py,sha256=0xZGJDGlnsqsf6SL6M_GsqwozubsSUoH5DV8cOtZk3M,781
|
|
15
|
+
labtool_cf-0.1.0.dist-info/licenses/LICENSE,sha256=z0vVDrwjw-5LNq0_xqmIcfPcWIOABEklibuYrvt1WnQ,1074
|
|
16
|
+
labtool_cf-0.1.0.dist-info/METADATA,sha256=nDcUKAqiIH12LaxugoarqLDEnU6KcS30I5qW7doGBds,10564
|
|
17
|
+
labtool_cf-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
18
|
+
labtool_cf-0.1.0.dist-info/entry_points.txt,sha256=WFTkfNwQ5QB1K0WqMIdiftxY2XI8XxZ1dNDgtvFKDP4,45
|
|
19
|
+
labtool_cf-0.1.0.dist-info/top_level.txt,sha256=tB36FzMvBypIGqfkSHKb-Bgf0Ta6oJJDtVtm8baSm5M,8
|
|
20
|
+
labtool_cf-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Amirhossein Ziaee
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
labtool
|