|
import os |
|
import shlex |
|
import subprocess |
|
from pathlib import Path |
|
import click |
|
|
|
@click.group() |
|
def cli(): |
|
"""sshgrok - SSH reverse proxy with Traefik route registration.""" |
|
pass |
|
|
|
@cli.command() |
|
@click.option('--config-dir', default='traefik.d', show_default=True, type=click.Path(), help='Traefik config folder.') |
|
@click.option('--image', default='traefik:latest', show_default=True, help='Traefik Docker image.') |
|
@click.option('--letsencrypt-email', default=None, help='Email to use for Let\'s Encrypt ACME registration.') |
|
@click.option('--cert-resolver', default=None, help='Certificate resolver for TLS.') |
|
def server(config_dir, image, letsencrypt_email, cert_resolver): |
|
"""Start the sshgrok server and run Traefik. |
|
WARN: the server-side isn't really tested, I just piggy back off of my existing traefik server directory |
|
""" |
|
click.echo(f'Using Traefik config directory: {config_dir}') |
|
click.echo(f'Using Traefik image: {image}') |
|
if cert_resolver or letsencrypt_email: |
|
assert cert_resolver and letsencrypt_email, "Both cert_resolver and letsencrypt_email must be provided" |
|
click.echo(f'Enabling Let\'s Encrypt using {letsencrypt_email}') |
|
click.echo(f'Configuring TLS with certificate resolver {cert_resolver}') |
|
|
|
config_path = Path(config_dir).resolve() |
|
config_path.mkdir(parents=True, exist_ok=True) |
|
|
|
cmd = [ |
|
"docker", "run", "--rm", "-d", |
|
"--name", "sshgrok-traefik", |
|
"-p", "80:80", |
|
"-p", "443:443", |
|
"-v", f"{config_path}:/etc/traefik/dynamic", |
|
] |
|
if os.platform == 'linux': |
|
cmd += [ |
|
"--add-host", "host.docker.internal:172.17.0.1", |
|
] |
|
|
|
cmd += [ |
|
image, |
|
"--providers.file.directory=/etc/traefik/dynamic", |
|
"--providers.file.watch=true", |
|
] |
|
|
|
if letsencrypt_email: |
|
acme_file = config_path / "acme.json" |
|
acme_file.touch(exist_ok=True) |
|
acme_file.chmod(0o600) |
|
|
|
cmd += [ |
|
"-v", f"{acme_file}:/acme/acme.json", |
|
"--entryPoints.web.address=:80", |
|
"--entryPoints.websecure.address=:443", |
|
f"--certificatesresolvers.{cert_resolver}.acme.email={letsencrypt_email}", |
|
f"--certificatesresolvers.{cert_resolver}.acme.storage=/acme/acme.json", |
|
f"--certificatesresolvers.{cert_resolver}.acme.httpchallenge.entryPoint=web", |
|
] |
|
|
|
try: |
|
result = subprocess.run(cmd, capture_output=True, text=True, check=True) |
|
container_id = result.stdout.strip() |
|
click.echo(f'Launched Traefik container {container_id}') |
|
except subprocess.CalledProcessError as exc: |
|
click.echo('Failed to launch Traefik container.', err=True) |
|
click.echo(exc.stderr or exc.stdout, err=True) |
|
raise click.Abort() |
|
|
|
def sanitize_filename(name: str) -> str: |
|
return "".join(ch if ch.isalnum() or ch in "-_" else "_" for ch in name) |
|
|
|
def find_remote_port(ssh_host: str) -> int: |
|
python_command = ( |
|
"import socket;" |
|
"s=socket.socket();" |
|
"s.bind(('127.0.0.1', 0));" |
|
"print(s.getsockname()[1]);" |
|
"s.close()" |
|
) |
|
remote_cmd = "python3 -c " + shlex.quote(python_command) |
|
cmd = ["ssh", ssh_host, remote_cmd] |
|
|
|
result = subprocess.run(cmd, capture_output=True, text=True) |
|
if result.returncode != 0: |
|
raise click.ClickException( |
|
f"Unable to select remote port: {result.stderr.strip() or result.stdout.strip()}" |
|
) |
|
|
|
port_text = result.stdout.strip() |
|
if not port_text.isdigit(): |
|
raise click.ClickException( |
|
f"Remote port selection returned unexpected output: {port_text}" |
|
) |
|
|
|
return int(port_text) |
|
|
|
def build_traefik_config(domain: str, remote_port: int, cert_resolver: str | None = None) -> str: |
|
router_name = sanitize_filename(domain) |
|
service_name = router_name |
|
|
|
router_block = f""" |
|
{router_name}: |
|
rule: "Host(`{domain}`)" |
|
entryPoints: |
|
- web |
|
service: "{service_name}" |
|
""".rstrip() |
|
|
|
if cert_resolver: |
|
router_block += f""" |
|
|
|
{router_name}-https: |
|
rule: "Host(`{domain}`)" |
|
entryPoints: |
|
- websecure |
|
service: "{service_name}" |
|
tls: |
|
certResolver: {cert_resolver} |
|
""".rstrip() |
|
|
|
return f""" |
|
http: |
|
routers: |
|
{router_block} |
|
services: |
|
{service_name}: |
|
loadBalancer: |
|
servers: |
|
- url: "http://host.docker.internal:{remote_port}" |
|
""".strip() |
|
|
|
@cli.command() |
|
@click.option('--ssh-host', required=True, help='SSH server host or user@host.') |
|
@click.option('--local-port', default=80, show_default=True, help='Local service port to expose.') |
|
@click.option('--remote-port', default=None, help='Remote forwarded port on the server. If omitted, select one automatically.') |
|
@click.option('--domain', required=True, help='Domain or route to register via Traefik.') |
|
@click.option('--cert-resolver', default=None, help='Certificate resolver for TLS.') |
|
@click.option('--traefik-config', default='traefik.d', show_default=True, help='Remote Traefik dynamic config folder.') |
|
def client(ssh_host, local_port, remote_port, domain, traefik_config, cert_resolver): |
|
"""Connect to the sshgrok server and register a Traefik route.""" |
|
click.echo(f'Connecting to {ssh_host}') |
|
click.echo(f'Forwarding local port {local_port} to remote port {remote_port or "(auto)"}') |
|
click.echo(f'Registering Traefik route for {domain} in {traefik_config}') |
|
|
|
if cert_resolver: |
|
click.echo(f'Configuring TLS with certificate resolver {cert_resolver}') |
|
|
|
if remote_port is None: |
|
remote_port = find_remote_port(ssh_host) |
|
click.echo(f'Selected remote port {remote_port}') |
|
|
|
route_filename = f"{sanitize_filename(domain)}.yaml" |
|
remote_file = f"{traefik_config.rstrip('/')}/{route_filename}" |
|
config_content = build_traefik_config(domain, remote_port, cert_resolver) |
|
|
|
write_cmd = [ |
|
"ssh", ssh_host, |
|
"cat > " + shlex.quote(remote_file) |
|
] |
|
|
|
try: |
|
write_proc = subprocess.run( |
|
write_cmd, |
|
input=config_content, |
|
text=True, |
|
capture_output=True, |
|
check=True, |
|
) |
|
except subprocess.CalledProcessError as exc: |
|
click.echo('Failed to write Traefik config on remote host.', err=True) |
|
click.echo(exc.stderr or exc.stdout, err=True) |
|
raise click.Abort() |
|
|
|
tunnel_cmd = [ |
|
"ssh", |
|
"-N", |
|
"-R", f"0.0.0.0:{remote_port}:127.0.0.1:{local_port}", |
|
ssh_host, |
|
] |
|
|
|
try: |
|
click.echo('Starting SSH reverse tunnel...') |
|
tunnel_proc = subprocess.Popen(tunnel_cmd) |
|
click.echo('Reverse tunnel established. Press Ctrl+C to stop.') |
|
|
|
tunnel_proc.wait() |
|
except KeyboardInterrupt: |
|
click.echo('Stopping reverse tunnel...') |
|
finally: |
|
if tunnel_proc and tunnel_proc.poll() is None: |
|
tunnel_proc.terminate() |
|
tunnel_proc.wait(timeout=5) |
|
|
|
remove_cmd = [ |
|
"ssh", ssh_host, |
|
"rm -f " + shlex.quote(remote_file) |
|
] |
|
|
|
try: |
|
subprocess.run(remove_cmd, capture_output=True, text=True, check=True) |
|
click.echo(f'Removed remote Traefik route {remote_file}') |
|
except subprocess.CalledProcessError as exc: |
|
click.echo('Failed to remove remote Traefik route.', err=True) |
|
click.echo(exc.stderr or exc.stdout, err=True) |
|
raise click.Abort() |
|
|
|
if __name__ == "__main__": |
|
cli() |