Skip to content

Instantly share code, notes, and snippets.

@u8sand
Last active May 6, 2026 20:56
Show Gist options
  • Select an option

  • Save u8sand/10fdd8c2c5de9317ad771b113a1a9b9b to your computer and use it in GitHub Desktop.

Select an option

Save u8sand/10fdd8c2c5de9317ad771b113a1a9b9b to your computer and use it in GitHub Desktop.
A CLI for ngrok-like functionality through a publicly accessible traefik server with SSH access

sshgrok

SSH reverse proxy with Traefik route registration.

What it does

  • Starts a Traefik container on the server.
  • Uses SSH reverse tunneling to expose a local service through the remote server.
  • Writes Traefik dynamic config files remotely so requests to a domain route back through the tunnel.
  • Supports automatic remote port selection and optional TLS via a Traefik certificate resolver / Let’s Encrypt.

Requirements

  • Requires an SSH server with a wildcard dns configured to point to it.
  • Requires GatewayPorts clientspecified to be enabled in the SSH server configuration.
  • Ports 80/443 should be accessible over the internet for Traefik to receive requests and route them to the tunnels.

Example:

# run a local service to expose (e.g. nginx on port 80)
docker run -it -p 80:80 nginx

# run the sshgrok client to create a reverse tunnel and register the route
uv run sshgrok client --ssh-host sand-pi.ssh.u8sand.net --local-port 80 --domain test.dev.u8sand.net --cert-resolver letsencrypt

Installation

pip install git+https://gist.github.com/10fdd8c2c5de9317ad771b113a1a9b9b.git
# or with uv
uv tool install git+https://gist.github.com/10fdd8c2c5de9317ad771b113a1a9b9b.git

After installation, the CLI is available as:

sshgrok

Server

Start the server and run Traefik:

sshgrok server \
  --config-dir ./traefik \
  --image traefik:latest \
  --letsencrypt-email you@example.com \
  --cert-resolver le

Options:

  • --config-dir: Traefik config folder (default: ./traefik)
  • --image: Traefik Docker image (default: `traefik:latest
  • --letsencrypt-email: Email for Let’s Encrypt ACME registration (optional)
  • --cert-resolver: Certificate resolver for TLS (optional, e.g. le)

Client

Start the client and create an SSH reverse tunnel:

sshgrok client \
  --remote-user user \
  --remote-host example.com \
  --remote-port 8080 \
  --local-port 80 \
  --domain example.com

Options:

  • --remote-user: SSH username for the remote server
  • --remote-host: SSH hostname or IP of the remote server
  • --remote-port: Remote port to use for the reverse tunnel (default: random)
  • --local-port: Local port to forward (default: 80)
  • --domain: Domain to route through Traefik (e.g. example.com)

Notes

  • The server command mounts the dynamic config folder into Traefik and supports Linux with Docker host.docker.internal via host-gateway.
  • The client automatically chooses an available remote port if none is provided.
  • If TLS is enabled, both HTTP and HTTPS routers are created, but HTTP still works.

Requirements

  • Python 3.12+
  • docker installed on the server
  • ssh access from client to server
  • Traefik container support on the server
# Allow you to pip install this gist with:
# pip install git+https://gist.github.com/10fdd8c2c5de9317ad771b113a1a9b9b.git
[project]
name = "sshgrok"
version = "0.1.0"
description = "SSH reverse proxy with Traefik route registration"
readme = "!sshgrok.md"
authors = [
{name = "Daniel J. B. Clarke", email = "danieljbclarkemssm@gmail.com"},
{name = "AI (Qwen Coder)"}
]
requires-python = ">=3.12.3"
dependencies = [
"click>=8.3.3",
]
[project.scripts]
sshgrok = "sshgrok:cli"
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()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment