diff --git a/requirements.txt b/requirements.txt index 6b017e4..5ba654d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,3 +8,4 @@ Flask requests numpy colorama +python-multipart diff --git a/ssh_docker_server.py b/ssh_docker_server.py new file mode 100644 index 0000000..4fa0e7b --- /dev/null +++ b/ssh_docker_server.py @@ -0,0 +1,6 @@ +#!/usr/bin/env python3 +"""Entry point for the SSH Docker server package.""" +from ssh_server.main import main + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/ssh_server/__init__.py b/ssh_server/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ssh_server/api.py b/ssh_server/api.py new file mode 100644 index 0000000..3e37126 --- /dev/null +++ b/ssh_server/api.py @@ -0,0 +1,326 @@ +from typing import List + +import os +from fastapi import FastAPI, HTTPException, Request, Form +from fastapi.responses import HTMLResponse, RedirectResponse +from fastapi.templating import Jinja2Templates +from starlette.middleware.sessions import SessionMiddleware +from pydantic import BaseModel + +from .database import Database +from .docker_manager import DockerManager + +# Tailscale IP constant +TAILSCALE_IP = "100.76.98.95" + +app = FastAPI() + +templates = Jinja2Templates(directory=os.path.join(os.path.dirname(__file__), "templates")) +app.add_middleware(SessionMiddleware, secret_key="ssh-docker-server-secret") + +db = Database() +docker_manager = DockerManager() + +class UserRequest(BaseModel): + name: str | None = None + email: str + password: str + +class ContainerRequest(BaseModel): + email: str + password: str + image_tag: str = "linuxserver/openssh-server" + +class ComposeRequest(BaseModel): + email: str + password: str + compose_name: str = "openssh" + +class ContainerInfo(BaseModel): + id: str + port: int + password: str + image_tag: str + ssh_command: str + web_urls: dict = {} + +class ImageInfo(BaseModel): + id: str + tag: str + size: int + created: str + + +@app.get("/", response_class=HTMLResponse) +def home(request: Request): + return templates.TemplateResponse("login.html", {"request": request, "message": None}) + + +@app.post("/login", response_class=HTMLResponse) +def login_web(request: Request, email: str = Form(...), password: str = Form(...)): + user_id = db.verify_user(email, password) + if not user_id: + return templates.TemplateResponse( + "login.html", + {"request": request, "message": "Invalid credentials"}, + status_code=401, + ) + request.session["email"] = email + request.session["password"] = password + return RedirectResponse("/dashboard", status_code=303) + + +@app.post("/register", response_class=HTMLResponse) +def register_web( + request: Request, + name: str = Form(...), + email: str = Form(...), + password: str = Form(...), +): + if not db.add_user(name, email, password): + return templates.TemplateResponse( + "login.html", + {"request": request, "message": "Email already registered"}, + status_code=400, + ) + request.session["email"] = email + request.session["password"] = password + return RedirectResponse("/dashboard", status_code=303) + + +@app.post("/logout") +def logout(request: Request): + request.session.clear() + return RedirectResponse("/", status_code=303) + + +@app.get("/dashboard", response_class=HTMLResponse) +def dashboard(request: Request): + email = request.session.get("email") + password = request.session.get("password") + if not email or not password: + return RedirectResponse("/", status_code=303) + user_id = db.verify_user(email, password) + if not user_id: + return RedirectResponse("/", status_code=303) + + rows = db.list_containers(user_id) + containers = [ + { + "id": r[0], + "docker_id": r[1], + "port": r[2], + "password": r[3], + "image_tag": r[4], + } + for r in rows + ] + images = docker_manager.list_available_images() + return templates.TemplateResponse( + "dashboard.html", + { + "request": request, + "email": email, + "containers": containers, + "images": images, + "tailscale_ip": TAILSCALE_IP, + }, + ) + + +@app.post("/create") +def create_container_web(request: Request, image_tag: str = Form(...)): + email = request.session.get("email") + password = request.session.get("password") + if not email or not password: + return RedirectResponse("/", status_code=303) + user_id = db.verify_user(email, password) + if not user_id: + return RedirectResponse("/", status_code=303) + try: + cid, docker_id, port, passwd, img_tag, info = docker_manager.create_container( + user_id, image_tag + ) + db.add_container( + cid, + user_id, + docker_id, + port, + passwd, + img_tag, + info["ssh_ready"], + info.get("funnel_port"), + ) + except Exception: + pass + return RedirectResponse("/dashboard", status_code=303) + + +@app.post("/delete") +def delete_container_web(request: Request, cid: str = Form(...)): + email = request.session.get("email") + password = request.session.get("password") + if not email or not password: + return RedirectResponse("/", status_code=303) + user_id = db.verify_user(email, password) + if not user_id: + return RedirectResponse("/", status_code=303) + rows = db.list_containers(user_id) + for r in rows: + if r[0] == cid: + docker_manager.remove_container(r[1]) + db.delete_container(user_id, cid) + break + return RedirectResponse("/dashboard", status_code=303) + +@app.post("/api/register") +def register(req: UserRequest): + if not req.name: + raise HTTPException(status_code=400, detail="Name required") + if not db.add_user(req.name, req.email, req.password): + raise HTTPException(status_code=400, detail="Email already registered") + return {"message": "registered"} + +@app.post("/api/login") +def login(req: UserRequest): + user_id = db.verify_user(req.email, req.password) + if not user_id: + raise HTTPException(status_code=401, detail="Invalid credentials") + return {"message": "success"} + +@app.get("/api/images", response_model=List[ImageInfo]) +def list_images(email: str, password: str): + user_id = db.verify_user(email, password) + if not user_id: + raise HTTPException(status_code=401, detail="Invalid credentials") + + try: + images = docker_manager.list_available_images() + return [ImageInfo(**img) for img in images] + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to list images: {str(e)}") + +@app.get("/compose", response_model=List[str]) +def list_compose(email: str, password: str): + user_id = db.verify_user(email, password) + if not user_id: + raise HTTPException(status_code=401, detail="Invalid credentials") + return docker_manager.list_available_compose() + +@app.get("/api/images/{image_tag:path}") +def get_image_info(image_tag: str, email: str, password: str): + user_id = db.verify_user(email, password) + if not user_id: + raise HTTPException(status_code=401, detail="Invalid credentials") + + try: + info = docker_manager.get_image_info(image_tag) + if not info: + raise HTTPException(status_code=404, detail="Image not found") + return info + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to get image info: {str(e)}") + +@app.post("/api/container", response_model=ContainerInfo) +def create_container(req: ContainerRequest): + user_id = db.verify_user(req.email, req.password) + if not user_id: + raise HTTPException(status_code=401, detail="Invalid credentials") + + try: + cid, docker_id, port, password, image_tag, info = docker_manager.create_container(user_id, req.image_tag) + db.add_container(cid, user_id, docker_id, port, password, image_tag, info["ssh_ready"], info.get("funnel_port")) + + # Generate SSH command and web URLs + ssh_command = f"ssh dev@{TAILSCALE_IP} -p {port}" + web_urls = {} + + if "hydrus" in image_tag.lower(): + web_urls = { + "web_interface": f"http://{TAILSCALE_IP}:8000", + "detection_viewer": f"http://{TAILSCALE_IP}:5000" + } + + return ContainerInfo( + id=cid, + port=port, + password=password, + image_tag=image_tag, + ssh_command=ssh_command, + web_urls=web_urls + ) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except RuntimeError as e: + raise HTTPException(status_code=500, detail=str(e)) + +@app.post("/compose", response_model=ContainerInfo) +def create_compose(req: ComposeRequest): + user_id = db.verify_user(req.email, req.password) + if not user_id: + raise HTTPException(status_code=401, detail="Invalid credentials") + + try: + cid, docker_id, port, password, name = docker_manager.create_container_from_compose( + user_id, req.compose_name + ) + db.add_container(cid, user_id, docker_id, port, password, name) + + ssh_command = f"ssh dev@{TAILSCALE_IP} -p {port}" if port else "ssh access unavailable" + web_urls = {} + + return ContainerInfo( + id=cid, + port=port or 0, + password=password, + image_tag=name, + ssh_command=ssh_command, + web_urls=web_urls, + ) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except RuntimeError as e: + raise HTTPException(status_code=500, detail=str(e)) + +@app.get("/api/container", response_model=List[ContainerInfo]) +def list_containers(email: str, password: str): + user_id = db.verify_user(email, password) + if not user_id: + raise HTTPException(status_code=401, detail="Invalid credentials") + rows = db.list_containers(user_id) + + containers = [] + for r in rows: + ssh_command = f"ssh dev@{TAILSCALE_IP} -p {r[2]}" + web_urls = {} + + if "hydrus" in r[4].lower(): + web_urls = { + "web_interface": f"http://{TAILSCALE_IP}:8000", + "detection_viewer": f"http://{TAILSCALE_IP}:5000" + } + + containers.append(ContainerInfo( + id=r[0], + port=r[2], + password=r[3], + image_tag=r[4], + ssh_command=ssh_command, + web_urls=web_urls + )) + + return containers + +@app.delete("/api/container/{cid}") +def delete_container(cid: str, email: str, password: str): + user_id = db.verify_user(email, password) + if not user_id: + raise HTTPException(status_code=401, detail="Invalid credentials") + rows = db.list_containers(user_id) + for r in rows: + if r[0] == cid: + docker_manager.remove_container(r[1]) + db.delete_container(user_id, cid) + return {"message": "deleted"} + raise HTTPException(status_code=404, detail="Not found") + diff --git a/ssh_server/cli.py b/ssh_server/cli.py new file mode 100644 index 0000000..bbd9db9 --- /dev/null +++ b/ssh_server/cli.py @@ -0,0 +1,178 @@ +from .database import Database +from .docker_manager import DockerManager, TAILSCALE_IP, TAILSCALE_FUNNEL_DOMAIN + + +def cli() -> None: + """Interactive command-line interface for your Docker-SSH service.""" + db = Database() + docker_manager = DockerManager() + + print("=== SSH Docker Server CLI ===") + if (choice := input("1) Register\n2) Login\nSelect: ")) == "1": + if db.add_user(input("Name: "), input("Email: "), input("Password: ")): + print("Registered successfully") + else: + print("Email already registered") + return + elif choice != "2": + return + + email, pwd = input("Email: "), input("Password: ") + if not (user_id := db.verify_user(email, pwd)): + print("Invalid credentials") + return + + while True: + print("\n1) Create instance" + "\n2) List instances" + "\n3) Delete instance" + "\n4) List available images" + "\n5) Create from compose" + "\n6) Funnel instructions" + "\n7) Exit") + opt = input("Select: ") + + # -------------------- 1) Create instance --------------------------- # + if opt == "1": + try: + # ----- let user pick an image -------------------------------- + imgs = docker_manager.list_available_images() + if not imgs: + print("No Docker images found.") + continue + + for i, img in enumerate(imgs, 1): + print(f"{i}) {img['tag']} (ID {img['id']})") + print(f"{len(imgs)+1}) Enter custom image name") + + while True: + try: + idx = int(input("Select image: ")) - 1 + if idx == len(imgs): + image_tag = input("Image tag: ").strip() + break + if 0 <= idx < len(imgs): + image_tag = imgs[idx]["tag"] + break + print("Invalid selection") + except ValueError: + print("Please enter a number") + + # ----- create container -------------------------------------- + print(f"Creating container with {image_tag}…") + cid, docker_id, port, passwd, used_img, info = \ + docker_manager.create_container(user_id, image_tag) + + funnel_path = info["funnel_path"] + funnel_port = info["funnel_port"] + + db.add_container(cid, user_id, docker_id, port, passwd, + used_img, info["ssh_ready"], funnel_port) + + print(f"\n✅ Container {cid} created (image: {used_img})") + print(f"SSH (Tailscale): ssh " + f"{'root' if 'hydrus' in used_img.lower() else 'dev'}@" + f"{TAILSCALE_IP} -p {port}") + print(f"Password: {passwd}") + + if funnel_path: + print("\n🌐 Public HTTPS via Tailscale Funnel:") + print(f" https://{TAILSCALE_FUNNEL_DOMAIN}{funnel_path}") + else: + print("\n⚠️ Funnel auto-setup failed or disabled.") + print(f" Run manually: " + f"sudo tailscale serve https /{cid} tcp://localhost:{port}") + + except Exception as exc: + print(f"[Error] {exc}") + + # -------------------- 2) List instances ---------------------------- # + elif opt == "2": + containers = db.list_containers(user_id) + if not containers: + print("No containers found") + else: + print("\n📋 Your containers:") + print(f"{'ID':<12} {'Port':<8} {'SSH':<5} {'Password':<12} {'Image':<25} {'Access'}") + print("-" * 100) + for r in containers: + container_id, docker_id, port, password, image_tag, ssh_ready, funnel_port = r + ssh_status = "✅" if ssh_ready else "⚠️" + if "hydrus" in image_tag.lower(): + access_info = f"ssh root@{TAILSCALE_IP} -p {port}" + else: + access_info = f"ssh dev@{TAILSCALE_IP} -p {port}" + print(f"{container_id:<12} {port:<8} {ssh_status:<5} {password:<12} {image_tag:<25} {access_info}") + + # -------------------- 3) Delete instance --------------------------- # + elif opt == "3": + cid = input("Container id: ").strip() + for r in db.list_containers(user_id): + if r[0] == cid: + docker_manager.remove_container(r[1]) + db.delete_container(user_id, cid) + print("Deleted.") + break + else: + print("Not found.") + + # -------------------- 4) List Docker images ------------------------ # + elif opt == "4": + imgs = docker_manager.list_available_images() + if not imgs: + print("No Docker images.") + else: + print(f"\n{'Image Tag':<45}{'ID':<15}{'Size (MB)'}") + print("-" * 70) + for img in imgs: + mb = img['size'] // (1024 * 1024) + print(f"{img['tag']:<45}{img['id']:<15}{mb}") + + # -------------------- 5) Create from compose ----------------------- # + elif opt == "5": + names = docker_manager.list_available_compose() + if not names: + print("No compose configurations available.") + continue + for i, name in enumerate(names, 1): + print(f"{i}) {name}") + try: + idx = int(input("Select compose file: ")) - 1 + if not 0 <= idx < len(names): + print("Invalid selection") + continue + chosen = names[idx] + except ValueError: + print("Invalid selection") + continue + + print(f"Launching compose '{chosen}'…") + try: + cid, docker_id, port, passwd, name, info = docker_manager.create_container_from_compose(user_id, chosen) + funnel_path = info["funnel_path"] + funnel_port = info["funnel_port"] + + db.add_container(cid, user_id, docker_id, port, passwd, name, info["ssh_ready"], funnel_port) + + if port: + print(f"SSH: ssh dev@{TAILSCALE_IP} -p {port}") + print(f"Password: {passwd}") + if funnel_path: + print(f"Funnel: https://{TAILSCALE_FUNNEL_DOMAIN}{funnel_path}") + except Exception as exc: + print(f"[Error] {exc}") + + # -------------------- 6) Quick Funnel help ------------------------- # + elif opt == "6": + print("\n📡 Tailscale Funnel Quick-start") + print("--------------------------------") + print("Expose any TCP port publicly:") + print(" sudo tailscale serve https /myapp tcp://localhost:") + print("Visit:") + print(f" https://{TAILSCALE_FUNNEL_DOMAIN}/myapp\n" + "SSH itself can’t be wrapped in HTTPS; use Tailscale IP+port " + "for SSH, and funnel web UIs instead.") + + # -------------------- 7) Exit -------------------------------------- # + else: + break diff --git a/ssh_server/compose/openssh.yaml b/ssh_server/compose/openssh.yaml new file mode 100644 index 0000000..2be6732 --- /dev/null +++ b/ssh_server/compose/openssh.yaml @@ -0,0 +1,15 @@ +version: '3.8' +services: + ssh: + image: linuxserver/openssh-server + environment: + - PASSWORD_ACCESS=true + - USER_NAME=dev + - PUID=1000 + - PGID=1000 + - TZ=UTC + ports: + - "22" + volumes: + - /workspace/hydrus-software-stack:/workspace + tty: true diff --git a/ssh_server/database.py b/ssh_server/database.py new file mode 100644 index 0000000..18be905 --- /dev/null +++ b/ssh_server/database.py @@ -0,0 +1,100 @@ +import os +import sqlite3 +from typing import List, Tuple + +DB_PATH = os.path.join(os.path.dirname(__file__), "users.db") + + +class Database: + """Simple SQLite-based user and container registry.""" + + def __init__(self, path: str = DB_PATH): + self.conn = sqlite3.connect(path) + self._create() + + def _create(self) -> None: + cur = self.conn.cursor() + cur.execute( + """CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT, + email TEXT UNIQUE, + password TEXT + )""" + ) + cur.execute( + """CREATE TABLE IF NOT EXISTS containers ( + id TEXT PRIMARY KEY, + user_id INTEGER, + docker_id TEXT, + port INTEGER, + password TEXT, + image_tag TEXT DEFAULT 'linuxserver/openssh-server', + ssh_ready BOOLEAN DEFAULT 1, + funnel_port INTEGER, + FOREIGN KEY(user_id) REFERENCES users(id) + )""" + ) + # Silent ALTERs for backwards compatibility + for col, ddl in [ + ("image_tag", "TEXT DEFAULT 'linuxserver/openssh-server'"), + ("ssh_ready", "BOOLEAN DEFAULT 1"), + ("funnel_port", "INTEGER") + ]: + try: + cur.execute(f"ALTER TABLE containers ADD COLUMN {col} {ddl}") + except sqlite3.OperationalError: + pass + self.conn.commit() + + # -------------------- user helpers ------------------------------------ # + def add_user(self, name: str, email: str, password: str) -> bool: + try: + self.conn.execute( + "INSERT INTO users (name, email, password) VALUES (?, ?, ?)", + (name, email, password), + ) + self.conn.commit() + return True + except sqlite3.IntegrityError: + return False + + def verify_user(self, email: str, password: str) -> int | None: + cur = self.conn.cursor() + cur.execute("SELECT id FROM users WHERE email=? AND password=?", + (email, password)) + row = cur.fetchone() + return row[0] if row else None + + # -------------------- container helpers ------------------------------- # + def add_container( + self, cid: str, user_id: int, docker_id: str, port: int, password: str, + image_tag: str = "linuxserver/openssh-server", ssh_ready: bool = True, + funnel_port: int | None = None + ) -> None: + self.conn.execute( + "INSERT INTO containers (id, user_id, docker_id, port, password, " + "image_tag, ssh_ready, funnel_port) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + (cid, user_id, docker_id, port, password, + image_tag, ssh_ready, funnel_port), + ) + self.conn.commit() + + def list_containers(self, user_id: int) -> List[ + Tuple[str, str, int, str, str, bool, int | None]]: + cur = self.conn.cursor() + cur.execute( + "SELECT id, docker_id, port, password, COALESCE(image_tag, " + "'linuxserver/openssh-server'), COALESCE(ssh_ready, 1), " + "funnel_port FROM containers WHERE user_id=?", + (user_id,), + ) + return cur.fetchall() + + def delete_container(self, user_id: int, cid: str) -> None: + self.conn.execute( + "DELETE FROM containers WHERE user_id=? AND id=?", + (user_id, cid), + ) + self.conn.commit() diff --git a/ssh_server/docker_manager.py b/ssh_server/docker_manager.py new file mode 100644 index 0000000..c6623f1 --- /dev/null +++ b/ssh_server/docker_manager.py @@ -0,0 +1,303 @@ +import os +import random +import socket +import string +import subprocess +import uuid +from typing import Any, Dict, List + +import docker + +# --------------------------------------------------------------------------- # +# Repo mount + Tailscale basics +# --------------------------------------------------------------------------- # +REPO_PATH = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + +# Pre-defined docker compose configurations shipped with the server. Users +# can only choose among these files and cannot supply their own. The keys of +# this dict are short names exposed via the API/CLI and the values are the +# absolute paths to the corresponding compose YAML files. +COMPOSE_FILES: Dict[str, str] = { + "openssh": os.path.join(os.path.dirname(__file__), "compose", "openssh.yaml"), +} + +# For each compose file we also specify which service represents the main +# container that users will interact with (used to fetch the container ID after +# `docker compose` brings the stack up). +COMPOSE_MAIN_SERVICE: Dict[str, str] = { + "openssh": "ssh", +} + +TAILSCALE_IP = "100.76.98.95" +TAILSCALE_FUNNEL_DOMAIN = ( + "cesar-rog-zephyrus-g16-gu603vi-gu603vi.tail680469.ts.net" +) + +# --------------------------------------------------------------------------- # +# Helper: expose a local TCP port at / via public HTTPS +# --------------------------------------------------------------------------- # +def expose_with_funnel_http_path(container_id: str, port: int) -> bool: + """ + tailscale serve --bg --set-path / tcp://localhost: + """ + try: + subprocess.run( + [ + "sudo", "tailscale", "serve", + "--bg", "--set-path", f"/{container_id}", + f"tcp://localhost:{port}", + ], + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + return True + except subprocess.CalledProcessError as e: + print(f"[tailscale serve] {e}") + return False +# --------------------------------------------------------------------------- # +# Docker Manager +# --------------------------------------------------------------------------- # +class DockerManager: + """Create, configure, and remove SSH-enabled Docker containers.""" + + def __init__(self) -> None: + self.client = docker.from_env() + + # ----------------------------- utilities -------------------------------- # + @staticmethod + def _free_port() -> int: + s = socket.socket() + s.bind(("", 0)) + port = s.getsockname()[1] + s.close() + return port + + # ----------------------- image discovery helpers ----------------------- # + def list_available_images(self) -> List[Dict[str, Any]]: + images: List[Dict[str, Any]] = [] + for img in self.client.images.list(): + if img.tags: # skip dangling layers + for tag in img.tags: + images.append( + { + "id": img.id[:12], + "tag": tag, + "size": img.attrs.get("Size", 0), + "created": img.attrs.get("Created", ""), + } + ) + return sorted(images, key=lambda x: x["tag"]) + + def list_available_compose(self) -> List[str]: + """Return the list of supported docker-compose configurations.""" + return sorted(COMPOSE_FILES.keys()) + + def get_image_info(self, image_tag: str) -> Dict[str, Any]: + try: + img = self.client.images.get(image_tag) + return { + "id": img.id, + "tags": img.tags, + "size": img.attrs.get("Size", 0), + "created": img.attrs.get("Created", ""), + "architecture": img.attrs.get("Architecture", ""), + "os": img.attrs.get("Os", ""), + } + except docker.errors.ImageNotFound: + return {} + + # --------------------- image-specific configuration -------------------- # + @staticmethod + def _get_image_environment(image_tag: str) -> Dict[str, str]: + default_env = { + "PASSWORD_ACCESS": "true", + "USER_NAME": "dev", + "PUID": "1000", + "PGID": "1000", + "TZ": "UTC", + } + + if "hydrus" in image_tag.lower(): + return { + "ROS_MASTER_URI": "http://host.docker.internal:11311", + "ARDUINO_BOARD": "arduino:avr:uno", + "DEPLOY": "false", + "VOLUME": "true", + "DISPLAY": os.environ.get("DISPLAY", ":0"), + "SSH_ENABLE_PASSWORD_AUTH": "true", + "SSH_ENABLE_ROOT": "true", + } + + return default_env + + def _get_image_ports(self, image_tag: str) -> Dict[str, int]: + p = self._free_port() + if "hydrus" in image_tag.lower(): + return {"22/tcp": p, "8000/tcp": self._free_port(), "5000/tcp": self._free_port()} + if "openssh-server" in image_tag.lower(): + return {"2222/tcp": p} + return {"22/tcp": p} + + # ---------------- SSH bootstrap for Hydrus containers ------------------ # + def _setup_ssh_in_container(self, container, password: str, image_tag: str) -> bool: + if "hydrus" not in image_tag.lower(): + return True # nothing to do for other images + + try: + cmds = [ + "apt-get update -qq", + "DEBIAN_FRONTEND=noninteractive apt-get install -y -qq openssh-server", + "mkdir -p /var/run/sshd /root/.ssh", + f"echo 'root:{password}' | chpasswd", + # create dev user only if missing + "id -u dev >/dev/null 2>&1 || useradd -m dev", + f"echo 'dev:{password}' | chpasswd", + # write a small override file instead of sed-ing the main config + 'printf "PermitRootLogin yes\\nPasswordAuthentication yes\\n" ' + '> /etc/ssh/sshd_config.d/99-custom.conf', + "chmod 644 /etc/ssh/sshd_config.d/99-custom.conf", + "systemctl enable ssh || true", + "service ssh restart || service ssh start", + ] + + for cmd in cmds: + res = container.exec_run(cmd, user="root") + if res.exit_code != 0: + print(f"[!] SSH setup failed at: {cmd}\n{res.output.decode()}") + return False + return True + except Exception as exc: + print(f"[SSH setup exception] {exc}") + return False + + # --------------------------- public API -------------------------------- # + def create_container(self, user_id: int, image_tag: str = "linuxserver/openssh-server"): + """Create container, keep it alive, expose SSH via Funnel.""" + cid = uuid.uuid4().hex[:8] + password = "".join(random.choices(string.ascii_letters + string.digits, k=10)) + + env = self._get_image_environment(image_tag) + ports = self._get_image_ports(image_tag) + if "openssh-server" in image_tag.lower(): + env["USER_PASSWORD"] = password + + volumes = {REPO_PATH: {"bind": "/workspace", "mode": "rw"}} + if "hydrus" in image_tag.lower(): + volumes.update({ + "/tmp/.X11-unix": {"bind": "/tmp/.X11-unix", "mode": "rw"}, + f"{REPO_PATH}/rosbags": {"bind": "/rosbags", "mode": "rw"}, + f"{REPO_PATH}/yolo_models": {"bind": "/yolo_models", "mode": "rw"}, + }) + + cfg: Dict[str, Any] = dict( + image=image_tag, + detach=True, + ports=ports, + environment=env, + volumes=volumes, + name=f"user-{user_id}-{cid}", + ) + + # Hydrus needs privileged + TTY + if "hydrus" in image_tag.lower(): + cfg.update(privileged=True, stdin_open=True, tty=True) + else: + # For “stateless” images (Ubuntu, ROS, etc.) keep them running + cfg["command"] = ["sleep", "infinity"] + + try: + container = self.client.containers.run(**cfg) + ssh_ready = self._setup_ssh_in_container(container, password, image_tag) + + main_port = next(iter(ports.values())) + funnel_ok = expose_with_funnel_http_path(cid, main_port) + funnel_path = f"/{cid}" if funnel_ok else None + + access_info = { + "ssh_ready": ssh_ready, + "tailscale_ip": TAILSCALE_IP, + "funnel_domain": TAILSCALE_FUNNEL_DOMAIN, + "funnel_path": funnel_path, + "funnel_port": main_port if funnel_ok else None, + "main_port": main_port, + "all_ports": ports, + } + + return cid, container.id, main_port, password, image_tag, access_info + + except docker.errors.ImageNotFound: + raise ValueError(f"Docker image '{image_tag}' not found.") + except Exception as exc: + raise RuntimeError(f"Container creation failed: {exc}") + + def create_container_from_compose(self, user_id: int, compose_name: str): + """Create a container using one of the bundled docker-compose files.""" + if compose_name not in COMPOSE_FILES: + raise ValueError(f"Unknown compose config '{compose_name}'") + + compose_file = COMPOSE_FILES[compose_name] + main_service = COMPOSE_MAIN_SERVICE[compose_name] + + cid = uuid.uuid4().hex[:8] + project_name = f"user-{user_id}-{cid}" + password = "".join(random.choices(string.ascii_letters + string.digits, k=10)) + + try: + subprocess.run( + ["docker", "compose", "-f", compose_file, "-p", project_name, "up", "-d"], + check=True, + cwd=os.path.dirname(compose_file), + ) + + # obtain docker ID of the main service container + result = subprocess.run( + ["docker", "compose", "-f", compose_file, "-p", project_name, "ps", "-q", main_service], + check=True, + cwd=os.path.dirname(compose_file), + capture_output=True, + text=True, + ) + docker_id = result.stdout.strip() + + container = self.client.containers.get(docker_id) + ssh_ready = self._setup_ssh_in_container(container, password, compose_name) + + # fetch the mapped SSH port (randomly assigned by compose) + ports = container.attrs.get("NetworkSettings", {}).get("Ports", {}) + ssh_ports = ports.get("22/tcp") or ports.get("2222/tcp") + if ssh_ports: + main_port = int(ssh_ports[0]["HostPort"]) + else: + main_port = None + + funnel_path = None + funnel_port = None + if main_port: + funnel_ok = expose_with_funnel_http_path(cid, main_port) + if funnel_ok: + funnel_path = f"/{cid}" + funnel_port = main_port + + access_info = { + "ssh_ready": ssh_ready, + "tailscale_ip": TAILSCALE_IP, + "funnel_domain": TAILSCALE_FUNNEL_DOMAIN, + "funnel_path": funnel_path, + "funnel_port": funnel_port, + "main_port": main_port, + "all_ports": ports, + } + + return cid, docker_id, main_port, password, compose_name, access_info + + except subprocess.CalledProcessError as exc: + raise RuntimeError(f"docker compose failed: {exc}") + + def remove_container(self, docker_id: str) -> None: + try: + cont = self.client.containers.get(docker_id) + cont.stop() + cont.remove() + except docker.errors.NotFound: + pass diff --git a/ssh_server/main.py b/ssh_server/main.py new file mode 100644 index 0000000..5f411ef --- /dev/null +++ b/ssh_server/main.py @@ -0,0 +1,22 @@ +import argparse +import uvicorn + +from .api import app +from .cli import cli + + +def main() -> None: + parser = argparse.ArgumentParser(description="SSH Docker server") + parser.add_argument("--cli", action="store_true", help="run CLI interface") + parser.add_argument("--host", default="0.0.0.0", help="host for API server") + parser.add_argument("--port", type=int, default=8000, help="port for API") + args = parser.parse_args() + + if args.cli: + cli() + else: + uvicorn.run("ssh_server.api:app", host=args.host, port=args.port) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/ssh_server/templates/dashboard.html b/ssh_server/templates/dashboard.html new file mode 100644 index 0000000..b5f27d0 --- /dev/null +++ b/ssh_server/templates/dashboard.html @@ -0,0 +1,40 @@ + + + + SSH Docker Server - Dashboard + + +

Welcome {{ email }}

+

Create Container

+
+ + +
+

Your Containers

+ + + {% for c in containers %} + + + + + + + + + {% endfor %} +
IDPortImagePasswordSSHAction
{{ c['id'] }}{{ c['port'] }}{{ c['image_tag'] }}{{ c['password'] }}ssh dev@{{ tailscale_ip }} -p {{ c['port'] }} +
+ + +
+
+
+ + diff --git a/ssh_server/templates/login.html b/ssh_server/templates/login.html new file mode 100644 index 0000000..d65244c --- /dev/null +++ b/ssh_server/templates/login.html @@ -0,0 +1,22 @@ + + + + SSH Docker Server - Login + + +

Login

+ {% if message %}

{{ message }}

{% endif %} +
+
+
+ +
+

Register

+
+
+
+
+ +
+ +