commit ab1a42091d21c463427750fce139dbde58ef848d Author: ilanq7 Date: Wed Oct 15 17:41:05 2025 +0200 first commit diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..7e66199 --- /dev/null +++ b/.env.example @@ -0,0 +1,7 @@ +# Copy to .env and adjust +DJANGO_SECRET_KEY=dev-insecure-change-me +DJANGO_DEBUG=1 +ALLOWED_HOSTS=127.0.0.1,localhost +SSH_KEY_DIR=/home/youruser/.ssh +KNOWN_HOSTS_PATH=/home/youruser/.ssh/known_hosts +STRICT_HOST_KEY_CHECKING=true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d4762dc --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +__pycache__/ +*.pyc +.env +.venv/ +db.sqlite3 +staticfiles/ diff --git a/Procfile.dev b/Procfile.dev new file mode 100644 index 0000000..846233f --- /dev/null +++ b/Procfile.dev @@ -0,0 +1 @@ +web: daphne -b 127.0.0.1 -p 8000 config.asgi:application diff --git a/README.md b/README.md new file mode 100644 index 0000000..5ffd045 --- /dev/null +++ b/README.md @@ -0,0 +1,259 @@ +# Remote Admin (Local Dev) + +Lightweight Django + Channels app for live SSH command execution & streaming. + +## Stack +- Python 3.12 +- Django 5.x +- Channels 4 (InMemoryChannelLayer, single-process only) +- asyncssh for SSH +- HTMX + vanilla JS (no build toolchain) +- Tailwind via CDN +- SQLite + +## Current Feature Set +- Hosts: CRUD for SSH target metadata +- Tasks: Fully editable reusable command tasks (custom only; static registry removed) +- Batch Scripts: Multi-step scripts with `cd` directory persistence and per-step progress +- Web Console: + - Connect/disconnect via WebSocket + - Run ad‑hoc command OR select saved task OR run batch + - Real-time stdout/stderr streaming + - Batch progress events (STEP X/Y) + - Cancel running command/batch (graceful termination) +- Validation & UX safeguards: + - Empty commands prevented client-side + - Batch normalization (strip Windows newlines, trim trailing blanks, ignore comments/blank lines) + - Delete confirmations (hx-confirm) +- Logs: + - Status, exit code, duration, output tail + - Run type (`single` / `batch`) + - Failed step index (for batch failures / errors) +- Structured error events JSON: `{event:"error", type:"ssh|runtime", message:"..."}` +- Auth: Login required for all views +- Manual: `/manual/` in-app searchable documentation +- Tests: CRUD + WebSocket execution (success, failure, batch success/failure, cancel) 13 passing + +## Setup +```bash +python -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +cp .env.example .env # edit values +python manage.py migrate +python manage.py createsuperuser +``` + +Run (single process ONLY): +```bash +daphne -b 127.0.0.1 -p 8000 config.asgi:application +# or for quick dev +python manage.py runserver 0.0.0.0:8000 # fine for dev only +``` +Visit: http://127.0.0.1:8000/ + +## Environment Variables (.env) +| Var | Purpose | +|-----|---------| +| DJANGO_SECRET_KEY | Django secret key | +| DJANGO_DEBUG | 0/1 toggle | +| ALLOWED_HOSTS | Comma list | +| SSH_KEY_DIR | Optional default key base path | +| KNOWN_HOSTS_PATH | Path to known_hosts file | +| STRICT_HOST_KEY_CHECKING | true/false – enforce host key verification | + +## SSH Setup (DE) +Ausführliche Anleitung zur Einrichtung einer funktionierenden SSH-Verbindung für die Web-Konsole. + +### 1. Lokalen SSH-Key prüfen oder erzeugen +Prüfen ob bereits ein (modernen) Ed25519 Key existiert: +```bash +ls -l ~/.ssh/id_ed25519 ~/.ssh/id_ed25519.pub 2>/dev/null || echo 'kein ed25519 key' +``` +Falls nicht vorhanden erstellen: +```bash +ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519 -C "$(whoami)@$(hostname)" -N '' +``` +Sichere Rechte setzen: +```bash +chmod 700 ~/.ssh +chmod 600 ~/.ssh/id_ed25519 +chmod 644 ~/.ssh/id_ed25519.pub +``` + +### 2. Public Key auf Zielhost hinterlegen +Ersetzt `deploy@server1` durch Benutzer & Host. +```bash +cat ~/.ssh/id_ed25519.pub | ssh deploy@server1 '\ + mkdir -p ~/.ssh && chmod 700 ~/.ssh && \ + cat >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys' +``` +Test (direkt): +```bash +ssh deploy@server1 'echo OK' +``` + +### 3. Host Key erfassen (bei Strict Checking) +Nur nötig wenn `STRICT_HOST_KEY_CHECKING=true` (empfohlen Production). +```bash +ssh-keyscan -T 5 -t rsa,ecdsa,ed25519 server1 >> ~/.ssh/known_hosts +``` +Bei Key-Änderung (Rotation / Reprovisioning) vorher entfernen: +```bash +ssh-keygen -R server1 +``` + +### 4. Authentifizierungs-Methode wählen +| Methode | Wann nutzen | Voraussetzungen | +|---------|-------------|-----------------| +| ssh_key | Fester Schlüsselpfad | Privater Key-Dateipfad existiert & Rechte korrekt | +| agent | Temporäre Nutzung / Passphrase-Key | `ssh-agent` läuft & `ssh-add` ausgeführt | + +Agent starten (falls nicht aktiv): +```bash +eval "$(ssh-agent -s)" +ssh-add ~/.ssh/id_ed25519 +``` + +### 5. Host in der Web-App anlegen +Formularfelder: +- Name: frei, z.B. `prod-app-1` +- Hostname: DNS / IP, z.B. `10.10.10.15` +- Port: Standard 22 +- Username: z.B. `deploy` +- Auth Method: `SSH Key` oder `Agent` +- Key Path (nur bei SSH Key): Absoluter Pfad, z.B. `/home/deploy/.ssh/id_ed25519` +- Strict Host Key Checking: Aktiv lassen außer im lokalen Test-Lab + +### 6. Verbindung testen +In der Console: +1. Host wählen +2. Connect klicken (Status-Badge -> CONNECTED) +3. Ad-hoc Command `uname -a` eingeben → Run +4. Ausgabe sollte erscheinen; bei Fehlern → Fehlermeldung lesen (structured error event) + +### 7. Typische Fehler & Lösungen +| Meldung | Ursache | Lösung | +|---------|---------|-------| +| Host key not trusted | Host Key fehlt / weicht ab | Key mit `ssh-keyscan` sammeln oder Strict deaktivieren (nur dev) | +| Permission denied | Key / User passt nicht | User/Key prüfen, Rechte kontrollieren, ggf. Agent laden | +| Connection lost | Netzwerk/Firewall | Direkten SSH Versuch testen, Port offen? | +| Unknown action | Falsches WebSocket Payload | Seite neu laden | + +### 8. Beispiel: Zwei Hosts (Prod & Staging) +``` +Name: prod-api Host: prod-api.company.internal User: deploy Auth: ssh_key Key Path: /home/deploy/.ssh/id_ed25519 +Name: staging-api Host: staging.company.internal User: deploy Auth: agent Key Path: (leer, via ssh-agent) +``` + +### 9. Sicherheit +- Keine Passwörter im Modell gespeichert +- Private Keys verbleiben ausschließlich im Dateisystem +- Strict Checking schützt vor Man-in-the-Middle + +> Password Auth ist aktuell deaktiviert – nutze SSH Key oder Agent. + +## Console Usage Overview +1. Select host, Connect. +2. Choose EITHER a saved task, ad-hoc command, or batch. +3. Press Run / Run Batch. +4. Observe live output; stderr in red. +5. For batch: status badge shows `STEP X/Y`. +6. Cancel to terminate; status becomes Canceling then Canceled. +7. On completion badge shows exit code. +8. View history under Logs. + +## Tasks +Custom tasks only (registry removed). Fields: +- name (unique key) +- label (display) +- command (shell executed exactly as typed) +- description (optional) + +Example: +``` +Name: restart_app +Label: Restart App Service +Command: sudo systemctl restart app.service +``` + +## Batch Scripts +One line per step. Rules: +- Blank lines & lines starting with `#` ignored. +- `cd path` lines set persistent working directory for subsequent steps. +- On failure (non-zero exit) batch stops; failed step recorded. + +Example: +``` +# Deploy +cd /srv/app +./stop.sh +./deploy.sh +./start.sh +``` + +## Structured Events (WebSocket) +| Event | Payload Fields | Notes | +|-------|----------------|-------| +| connected | session | Initial handshake | +| started | log_id, command | Run started | +| chunk | stream, data | Output chunk (stdout/stderr) | +| progress | current, total, step | Batch step progress | +| canceling | | Cancel requested | +| completed | status, exit_code | Terminal status | +| error | type, message | type = ssh | runtime | + +Status -> final badge mapping: +- ok -> green +- failed -> red (shows failed step when batch) +- error -> red (darker) +- canceled -> yellow + +## Logs +Fields now include: +- run_type (single|batch) +- failed_step (nullable) +- duration (derived) & timestamps +- output_tail (last 32K) + +## Manual +Accessible at `/manual/` or nav link “Manual” – contains full user guide & examples (browser searchable). + +## Tests +```bash +pytest -q +``` +Coverage includes model CRUD and WebSocket execution scenarios (success, fail, batch fail, cancel). + +## Troubleshooting (Quick Table) +| Symptom | Cause | Fix | +|---------|-------|-----| +| Host key not trusted | Missing known_hosts entry | ssh-keyscan >> known_hosts or disable (dev) | +| Permission denied | Wrong user/key / permissions | Fix key path, permissions, agent add | +| Invalid task | Name not found | Refresh tasks, re-create | +| Batch stops early | Non-zero exit | Inspect failed step in log, tail output | +| No output | Command buffers | Use unbuffered flags (e.g. python -u) | + +## Security Notes +- Keys referenced by path only; never stored or uploaded. +- Use strict host key checking in production. +- Single-process Channels (in-memory layer) – not for multi-worker production. + +## Roadmap / Future Ideas +- Redis channel layer for scaling +- Parameterized tasks (templated inputs) +- Role-based access control +- Stream-to-disk full log archiving +- ANSI color pass-through + +## Development Aids +Formatting / lint: +```bash +ruff check . +black . +``` + +Tailwind is loaded via CDN (see `remotectl/base.html`). + +--- + diff --git a/manage.py b/manage.py new file mode 100644 index 0000000..61f6e56 --- /dev/null +++ b/manage.py @@ -0,0 +1,15 @@ +#!/usr/bin/env python +import os +import sys +from pathlib import Path + +def main(): + project_dir = Path(__file__).resolve().parent / 'project' + if str(project_dir) not in sys.path: + sys.path.insert(0, str(project_dir)) + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'project.config.settings') + from django.core.management import execute_from_command_line + execute_from_command_line(sys.argv) + +if __name__ == '__main__': + main() diff --git a/project/__init__.py b/project/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/project/config/__init__.py b/project/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/project/config/asgi.py b/project/config/asgi.py new file mode 100644 index 0000000..2762cda --- /dev/null +++ b/project/config/asgi.py @@ -0,0 +1,22 @@ +import os +import sys +from pathlib import Path +from django.core.asgi import get_asgi_application +from channels.routing import ProtocolTypeRouter, URLRouter +from channels.auth import AuthMiddlewareStack + +project_dir = Path(__file__).resolve().parent.parent +if str(project_dir) not in sys.path: + sys.path.insert(0, str(project_dir)) + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'project.config.settings') + +http_app = get_asgi_application() + +# Lazy import after Django setup +from project.config import routing # noqa: E402 + +application = ProtocolTypeRouter({ + 'http': http_app, + 'websocket': AuthMiddlewareStack(URLRouter(routing.websocket_urlpatterns)), +}) diff --git a/project/config/routing.py b/project/config/routing.py new file mode 100644 index 0000000..545cd60 --- /dev/null +++ b/project/config/routing.py @@ -0,0 +1,6 @@ +from django.urls import path +from project.remotectl import consumers + +websocket_urlpatterns = [ + path('ws/ssh//stream/', consumers.SSHStreamConsumer.as_asgi()), +] diff --git a/project/config/settings.py b/project/config/settings.py new file mode 100644 index 0000000..a2cc090 --- /dev/null +++ b/project/config/settings.py @@ -0,0 +1,85 @@ +import os +from pathlib import Path +from dotenv import load_dotenv +from django.core.management.utils import get_random_secret_key +import warnings + +try: + from cryptography.utils import CryptographyDeprecationWarning # type: ignore +except Exception: # pragma: no cover + class CryptographyDeprecationWarning(Warning): + pass + +# Suppress noisy crypto algorithm deprecation warnings from asyncssh (ARC4, 3DES) +warnings.filterwarnings("ignore", category=CryptographyDeprecationWarning) + +load_dotenv() + +BASE_DIR = Path(__file__).resolve().parent.parent.parent +SECRET_KEY = os.getenv('DJANGO_SECRET_KEY', get_random_secret_key()) +DEBUG = os.getenv('DJANGO_DEBUG', '0') == '1' +ALLOWED_HOSTS = [h.strip() for h in os.getenv('ALLOWED_HOSTS', '127.0.0.1,localhost').split(',') if h.strip()] + +INSTALLED_APPS = [ 'django.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes', 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', 'channels', 'project.remotectl', ] + +MIDDLEWARE = [ 'django.middleware.security.SecurityMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.common.CommonMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', ] + + + + +ROOT_URLCONF = 'project.config.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [BASE_DIR / 'project' / 'remotectl' / 'templates'], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +WSGI_APPLICATION = 'project.config.wsgi.application' +ASGI_APPLICATION = 'project.config.asgi.application' + +CHANNEL_LAYERS = { + 'default': {'BACKEND': 'channels.layers.InMemoryChannelLayer'} +} + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': BASE_DIR / 'db.sqlite3', + } +} + +AUTH_PASSWORD_VALIDATORS = [ + {'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator'}, + {'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator'}, + {'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator'}, + {'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator'}, +] + +LANGUAGE_CODE = 'en-us' +TIME_ZONE = 'UTC' +USE_I18N = True +USE_TZ = True + +STATIC_URL = 'static/' +STATIC_ROOT = BASE_DIR / 'staticfiles' +STATICFILES_DIRS = [BASE_DIR / 'project' / 'remotectl' / 'static'] + +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' +LOGIN_URL = '/accounts/login/' +LOGIN_REDIRECT_URL = '/' +LOGOUT_REDIRECT_URL = '/accounts/login/' + +SSH_KEY_DIR = os.getenv('SSH_KEY_DIR') or str(Path.home() / '.ssh') +KNOWN_HOSTS_PATH = os.getenv('KNOWN_HOSTS_PATH') or str(Path.home() / '.ssh' / 'known_hosts') +STRICT_HOST_KEY_CHECKING = os.getenv('STRICT_HOST_KEY_CHECKING', 'true').lower() == 'true' diff --git a/project/config/urls.py b/project/config/urls.py new file mode 100644 index 0000000..23d7c89 --- /dev/null +++ b/project/config/urls.py @@ -0,0 +1,10 @@ +from django.contrib import admin +from django.urls import path, include +from django.views.generic import RedirectView + +urlpatterns = [ + path('admin/', admin.site.urls), + path('accounts/', include('django.contrib.auth.urls')), + path('', include('project.remotectl.urls')), + path('', RedirectView.as_view(pattern_name='console', permanent=False)), +] diff --git a/project/config/wsgi.py b/project/config/wsgi.py new file mode 100644 index 0000000..cf71b2f --- /dev/null +++ b/project/config/wsgi.py @@ -0,0 +1,11 @@ +import os +import sys +from pathlib import Path +from django.core.wsgi import get_wsgi_application + +project_dir = Path(__file__).resolve().parent.parent +if str(project_dir) not in sys.path: + sys.path.insert(0, str(project_dir)) + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'project.config.settings') +application = get_wsgi_application() diff --git a/project/remotectl/__init__.py b/project/remotectl/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/project/remotectl/admin.py b/project/remotectl/admin.py new file mode 100644 index 0000000..e381072 --- /dev/null +++ b/project/remotectl/admin.py @@ -0,0 +1,13 @@ +from django.contrib import admin +from .models import RemoteHost, CommandLog + +@admin.register(RemoteHost) +class RemoteHostAdmin(admin.ModelAdmin): + list_display = ("name","hostname","username","auth_method","strict_host_key_checking") + search_fields = ("name","hostname","username") + +@admin.register(CommandLog) +class CommandLogAdmin(admin.ModelAdmin): + list_display = ("id","host","command","status","exit_code","started_at","finished_at") + list_filter = ("status",) + search_fields = ("command",) diff --git a/project/remotectl/apps.py b/project/remotectl/apps.py new file mode 100644 index 0000000..4b4d0b1 --- /dev/null +++ b/project/remotectl/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + +class RemoteCtlConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'project.remotectl' diff --git a/project/remotectl/consumers.py b/project/remotectl/consumers.py new file mode 100644 index 0000000..899162b --- /dev/null +++ b/project/remotectl/consumers.py @@ -0,0 +1,230 @@ +import asyncio +import json +import uuid +from channels.generic.websocket import AsyncJsonWebsocketConsumer +from channels.db import database_sync_to_async +from django.contrib.auth import get_user_model +from .models import RemoteHost, CommandLog, BatchScript, CommandTask +from .services.ssh_client import open_connection, run_command, SSHError +import shlex + +User = get_user_model() + +class SSHStreamConsumer(AsyncJsonWebsocketConsumer): + async def connect(self): + # ensure attributes exist even if auth fails + self.run_task = None + self.conn = None + self.cancel_event = asyncio.Event() + self.log_id = None + if not self.scope['user'].is_authenticated: + await self.close() + return + self.session_id = self.scope['url_route']['kwargs'].get('session_id') + self.group_name = f"ssh_session_{self.session_id}" + await self.channel_layer.group_add(self.group_name, self.channel_name) + await self.accept() + await self.send_json({'event':'connected','session':str(self.session_id)}) + + async def disconnect(self, close_code): # noqa: D401 + run_task = getattr(self, 'run_task', None) + if run_task and not run_task.done(): + self.cancel_event.set() + try: + await asyncio.wait_for(run_task, timeout=3) + except asyncio.TimeoutError: + pass + if getattr(self, 'conn', None): + self.conn.close() + if hasattr(self, 'group_name'): + await self.channel_layer.group_discard(self.group_name, self.channel_name) + + async def receive_json(self, content, **kwargs): # noqa: D401 + action = content.get('action') + if action == 'start': + if self.run_task and not self.run_task.done(): + await self.send_json({'event':'error','type':'runtime','message':'Command already running'}) + return + host_id = content.get('host_id') + command = content.get('command') + task_key = content.get('task_key') + if task_key and not command: + task = await self.get_db_task(task_key) + if not task: + await self.send_json({'event':'error','message':'Invalid task'}) + return + command = task.command + if not command: + await self.send_json({'event':'error','type':'runtime','message':'No command provided'}) + return + host = await self.get_host(host_id) + if not host: + await self.send_json({'event':'error','type':'runtime','message':'Host not found'}) + return + try: + self.conn = await open_connection(host) + except SSHError as e: + await self.send_json({'event':'error','type':'ssh','message':str(e)}) + return + log = await self.create_log(host, command, run_type='single') + self.log_id = log.id + self.cancel_event = asyncio.Event() + self.run_task = asyncio.create_task(self._run_and_stream(log.id, command)) + await self.send_json({'event':'started','log_id':log.id,'command':command}) + elif action == 'start_batch': + if self.run_task and not self.run_task.done(): + await self.send_json({'event':'error','type':'runtime','message':'Command already running'}) + return + host_id = content.get('host_id') + batch_id = content.get('batch_id') + host = await self.get_host(host_id) + if not host: + await self.send_json({'event':'error','type':'runtime','message':'Host not found'}) + return + batch = await self.get_batch(batch_id) + if not batch: + await self.send_json({'event':'error','type':'runtime','message':'Batch not found'}) + return + try: + self.conn = await open_connection(host) + except SSHError as e: + await self.send_json({'event':'error','type':'ssh','message':str(e)}) + return + log = await self.create_log(host, f"BATCH:{batch.name}\n{batch.script}", run_type='batch') + self.log_id = log.id + self.cancel_event = asyncio.Event() + self.run_task = asyncio.create_task(self._run_batch_and_stream(log.id, batch)) + await self.send_json({'event':'started','log_id':log.id,'command':f'BATCH {batch.name}'}) + elif action == 'cancel': + if self.run_task and not self.run_task.done(): + self.cancel_event.set() + await self.send_json({'event':'canceling'}) + elif action == 'disconnect': + await self.close() + else: + await self.send_json({'event':'error','type':'runtime','message':'Unknown action'}) + + async def _run_and_stream(self, log_id: int, command: str): + tail_buf = {'data': ''} + + async def on_chunk(stream_name, data): + tail_buf['data'] = (tail_buf['data'] + data)[-32768:] + await self.channel_layer.group_send(self.group_name, { + 'type':'ssh.message', + 'payload': {'event':'chunk','stream':stream_name,'data':data} + }) + status = 'ok' + exit_code = None + try: + exit_code = await run_command(self.conn, command, on_chunk, self.cancel_event) + if self.cancel_event.is_set(): + status = 'canceled' + elif exit_code != 0: + status = 'failed' + except SSHError as e: + status = 'error' + await self.channel_layer.group_send(self.group_name, {'type':'ssh.message','payload':{'event':'error','type':'ssh','message':str(e)}}) + finally: + await self.update_log(log_id, status, exit_code, tail_buf['data']) + await self.channel_layer.group_send(self.group_name, {'type':'ssh.message','payload':{'event':'completed','status':status,'exit_code':exit_code}}) + if self.conn: + self.conn.close() + + async def _run_batch_and_stream(self, log_id: int, batch: BatchScript): + tail_buf = {'data': ''} + lines = [ln.rstrip('\n') for ln in batch.script.splitlines()] + raw_commands = [l for l in lines if l.strip() and not l.strip().startswith('#')] + steps = [] + current_dir = None + for line in raw_commands: + stripped = line.strip() + if stripped.lower().startswith('cd '): + current_dir = stripped[3:].strip() + steps.append((stripped, None)) + else: + cmd = stripped + if current_dir: + cmd = f"cd {shlex.quote(current_dir)} && {cmd}" + steps.append((stripped, cmd)) + + async def emit(stream, data): + tail_buf['data'] = (tail_buf['data'] + data)[-32768:] + await self.channel_layer.group_send(self.group_name, {'type':'ssh.message','payload':{'event':'chunk','stream':stream,'data':data}}) + + status = 'ok' + overall_exit = 0 + total = len(steps) + failed_step_index = None + try: + for idx, (display_cmd, exec_cmd) in enumerate(steps, start=1): + if self.cancel_event.is_set(): + status = 'canceled' + break + # send progress event + await self.channel_layer.group_send(self.group_name, {'type':'ssh.message','payload':{'event':'progress','current':idx,'total':total,'step':display_cmd}}) + await emit('stdout', f"\n>>> [{idx}/{total}] {display_cmd}\n") + if exec_cmd: + try: + ec = await run_command(self.conn, exec_cmd, lambda s,d: emit(s,d), self.cancel_event) + except SSHError as e: + await emit('stderr', f"ERROR: {e}\n") + status = 'error' + failed_step_index = idx + break + if self.cancel_event.is_set(): + status = 'canceled' + break + if ec != 0: + overall_exit = ec + status = 'failed' + failed_step_index = idx + await emit('stderr', f"Step failed with exit code {ec}, aborting batch.\n") + break + else: + overall_exit = 0 + finally: + await self.update_log(log_id, status, overall_exit, tail_buf['data'], failed_step_index) + await self.channel_layer.group_send(self.group_name, {'type':'ssh.message','payload':{'event':'completed','status':status,'exit_code':overall_exit}}) + if self.conn: + self.conn.close() + + async def ssh_message(self, event): + # Handler required for group_send events + await self.send_json(event['payload']) + + @database_sync_to_async + def get_host(self, host_id): + if not host_id: + return None + try: + return RemoteHost.objects.get(id=host_id) + except RemoteHost.DoesNotExist: + return None + + @database_sync_to_async + def create_log(self, host, command: str, run_type: str): + return CommandLog.objects.create(host=host, command=command, created_by=self.scope['user'], run_type=run_type) + + @database_sync_to_async + def update_log(self, log_id: int, status: str, exit_code, tail: str, failed_step: int | None = None): + try: + log = CommandLog.objects.get(id=log_id) + except CommandLog.DoesNotExist: # pragma: no cover + return + log.mark_finished(status=status, exit_code=exit_code, tail=tail, failed_step=failed_step) + + @database_sync_to_async + def get_batch(self, batch_id): + if not batch_id: + return None + try: + return BatchScript.objects.get(id=batch_id) + except BatchScript.DoesNotExist: + return None + + @database_sync_to_async + def get_db_task(self, name: str): + try: + return CommandTask.objects.get(name=name) + except CommandTask.DoesNotExist: + return None diff --git a/project/remotectl/forms.py b/project/remotectl/forms.py new file mode 100644 index 0000000..580e0ed --- /dev/null +++ b/project/remotectl/forms.py @@ -0,0 +1,27 @@ +from django import forms +from .models import RemoteHost, BatchScript, CommandTask + +class RemoteHostForm(forms.ModelForm): + class Meta: + model = RemoteHost + fields = ['name','hostname','port','username','auth_method','key_path','strict_host_key_checking','notes'] + widgets = { + 'notes': forms.Textarea(attrs={'rows':3}), + } + +class BatchScriptForm(forms.ModelForm): + class Meta: + model = BatchScript + fields = ['name','description','script'] + widgets = { + 'script': forms.Textarea(attrs={'rows':14, 'spellcheck':'false'}), + } + +class CommandTaskForm(forms.ModelForm): + class Meta: + model = CommandTask + fields = ['name','label','command','description'] + widgets = { + 'command': forms.Textarea(attrs={'rows':6,'spellcheck':'false','class':'font-mono'}), + 'description': forms.Textarea(attrs={'rows':2}), + } diff --git a/project/remotectl/migrations/0001_initial.py b/project/remotectl/migrations/0001_initial.py new file mode 100644 index 0000000..ebc0e8c --- /dev/null +++ b/project/remotectl/migrations/0001_initial.py @@ -0,0 +1,103 @@ +# Generated by Django 5.1.1 on 2025-10-14 12:55 + +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="RemoteHost", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("name", models.CharField(max_length=100)), + ("hostname", models.CharField(max_length=255)), + ("port", models.PositiveIntegerField(default=22)), + ("username", models.CharField(max_length=100)), + ( + "auth_method", + models.CharField( + choices=[ + ("ssh_key", "SSH Key"), + ("agent", "SSH Agent"), + ("password", "Password"), + ], + default="ssh_key", + max_length=20, + ), + ), + ("key_path", models.CharField(blank=True, max_length=500)), + ("strict_host_key_checking", models.BooleanField(default=True)), + ("notes", models.TextField(blank=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ], + options={ + "ordering": ["name"], + }, + ), + migrations.CreateModel( + name="CommandLog", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("command", models.TextField()), + ("args_json", models.JSONField(blank=True, null=True)), + ("started_at", models.DateTimeField(default=django.utils.timezone.now)), + ("finished_at", models.DateTimeField(blank=True, null=True)), + ( + "status", + models.CharField( + choices=[ + ("running", "Running"), + ("ok", "Success"), + ("failed", "Failed"), + ("canceled", "Canceled"), + ("error", "Error"), + ], + default="running", + max_length=20, + ), + ), + ("exit_code", models.IntegerField(blank=True, null=True)), + ("output_tail", models.TextField(blank=True)), + ( + "created_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "host", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="command_logs", + to="remotectl.remotehost", + ), + ), + ], + options={ + "ordering": ["-started_at"], + }, + ), + ] diff --git a/project/remotectl/migrations/0002_batchscript.py b/project/remotectl/migrations/0002_batchscript.py new file mode 100644 index 0000000..ede7adb --- /dev/null +++ b/project/remotectl/migrations/0002_batchscript.py @@ -0,0 +1,49 @@ +# Generated by Django 5.1.1 on 2025-10-15 09:27 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("remotectl", "0001_initial"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="BatchScript", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("name", models.CharField(max_length=100, unique=True)), + ("description", models.CharField(blank=True, max_length=255)), + ( + "script", + models.TextField( + help_text="One command per line. Lines starting with # are ignored." + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "created_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "ordering": ["name"], + }, + ), + ] diff --git a/project/remotectl/migrations/0003_commandtask.py b/project/remotectl/migrations/0003_commandtask.py new file mode 100644 index 0000000..257f518 --- /dev/null +++ b/project/remotectl/migrations/0003_commandtask.py @@ -0,0 +1,48 @@ +# Generated by Django 5.1.1 on 2025-10-15 11:33 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("remotectl", "0002_batchscript"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="CommandTask", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ( + "name", + models.CharField(help_text="Key used internally", max_length=100, unique=True), + ), + ("label", models.CharField(max_length=150)), + ("command", models.TextField()), + ("description", models.CharField(blank=True, max_length=255)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "created_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "ordering": ["name"], + }, + ), + ] diff --git a/project/remotectl/migrations/0004_commandlog_failed_step_commandlog_run_type.py b/project/remotectl/migrations/0004_commandlog_failed_step_commandlog_run_type.py new file mode 100644 index 0000000..08ddd46 --- /dev/null +++ b/project/remotectl/migrations/0004_commandlog_failed_step_commandlog_run_type.py @@ -0,0 +1,25 @@ +# Generated by Django 5.1.1 on 2025-10-15 12:48 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("remotectl", "0003_commandtask"), + ] + + operations = [ + migrations.AddField( + model_name="commandlog", + name="failed_step", + field=models.IntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name="commandlog", + name="run_type", + field=models.CharField( + choices=[("single", "Single"), ("batch", "Batch")], default="single", max_length=10 + ), + ), + ] diff --git a/project/remotectl/migrations/__init__.py b/project/remotectl/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/project/remotectl/models.py b/project/remotectl/models.py new file mode 100644 index 0000000..b3b7296 --- /dev/null +++ b/project/remotectl/models.py @@ -0,0 +1,118 @@ +from __future__ import annotations +from django.db import models +from django.contrib.auth import get_user_model +from django.utils import timezone + +# Remove eager evaluation to avoid accessing settings before configured +# User = get_user_model() + +def get_user_model_cached(): + return get_user_model() + +class RemoteHost(models.Model): + AUTH_METHOD_CHOICES = [ + ('ssh_key', 'SSH Key'), + ('agent', 'SSH Agent'), + ('password', 'Password'), # not stored; prompt in future + ] + name = models.CharField(max_length=100) + hostname = models.CharField(max_length=255) + port = models.PositiveIntegerField(default=22) + username = models.CharField(max_length=100) + auth_method = models.CharField(max_length=20, choices=AUTH_METHOD_CHOICES, default='ssh_key') + key_path = models.CharField(max_length=500, blank=True) + strict_host_key_checking = models.BooleanField(default=True) + notes = models.TextField(blank=True) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ['name'] + + def __str__(self): + return f"{self.name} ({self.username}@{self.hostname})" + +class CommandLog(models.Model): + STATUS_CHOICES = [ + ('running', 'Running'), + ('ok', 'Success'), + ('failed', 'Failed'), + ('canceled', 'Canceled'), + ('error', 'Error'), + ] + RUN_TYPE_CHOICES = [ + ('single','Single'), + ('batch','Batch'), + ] + host = models.ForeignKey(RemoteHost, on_delete=models.CASCADE, related_name='command_logs') + created_by = models.ForeignKey('auth.User', null=True, blank=True, on_delete=models.SET_NULL) + command = models.TextField() + args_json = models.JSONField(null=True, blank=True) + run_type = models.CharField(max_length=10, choices=RUN_TYPE_CHOICES, default='single') + failed_step = models.IntegerField(null=True, blank=True) + started_at = models.DateTimeField(default=timezone.now) + finished_at = models.DateTimeField(null=True, blank=True) + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='running') + exit_code = models.IntegerField(null=True, blank=True) + output_tail = models.TextField(blank=True) + + class Meta: + ordering = ['-started_at'] + + def mark_finished(self, status: str, exit_code: int | None, tail: str | None, failed_step: int | None = None): + self.status = status + self.exit_code = exit_code + self.failed_step = failed_step + self.finished_at = timezone.now() + if tail is not None: + self.output_tail = tail + self.save(update_fields=['status', 'exit_code', 'failed_step', 'finished_at', 'output_tail']) + + def duration(self): # pragma: no cover simple helper + end = self.finished_at or timezone.now() + return (end - self.started_at).total_seconds() + +class BatchScript(models.Model): + name = models.CharField(max_length=100, unique=True) + description = models.CharField(max_length=255, blank=True) + script = models.TextField(help_text="One command per line. Lines starting with # are ignored.") + created_by = models.ForeignKey('auth.User', null=True, blank=True, on_delete=models.SET_NULL) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ['name'] + + def __str__(self): # pragma: no cover simple + return self.name + + def clean(self): # normalize script formatting + if self.script: + normalized = self.script.replace('\r\n', '\n').replace('\r', '\n') + lines = [ln.rstrip() for ln in normalized.split('\n')] + # remove trailing empty lines + while lines and not lines[-1].strip(): + lines.pop() + self.script = '\n'.join(lines).strip('\n') + + def step_lines(self): # returns list of effective lines excluding comments/blank + if not self.script: + return [] + return [l for l in self.script.split('\n') if l.strip() and not l.strip().startswith('#')] + + def step_count(self): # pragma: no cover trivial + return len(self.step_lines()) + +class CommandTask(models.Model): + name = models.CharField(max_length=100, unique=True, help_text="Key used internally") + label = models.CharField(max_length=150) + command = models.TextField() + description = models.CharField(max_length=255, blank=True) + created_by = models.ForeignKey('auth.User', null=True, blank=True, on_delete=models.SET_NULL) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ['name'] + + def __str__(self): # pragma: no cover + return self.label or self.name diff --git a/project/remotectl/services/__init__.py b/project/remotectl/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/project/remotectl/services/ssh_client.py b/project/remotectl/services/ssh_client.py new file mode 100644 index 0000000..db6519b --- /dev/null +++ b/project/remotectl/services/ssh_client.py @@ -0,0 +1,100 @@ +import asyncio +import asyncssh +import os +import logging +from typing import Callable, Awaitable +from django.conf import settings + +CHUNK_LIMIT = 32768 # store last N chars for tail +logger = logging.getLogger(__name__) + +class SSHError(Exception): + pass + +async def open_connection(remote_host): + """Open an SSH connection honoring host settings. + Adds diagnostics & graceful fallback if key file missing. + """ + if remote_host.strict_host_key_checking and settings.STRICT_HOST_KEY_CHECKING: + known_hosts = os.path.expanduser(settings.KNOWN_HOSTS_PATH) + else: + known_hosts = None # disables host key verification (dev only) + + client_keys = None + agent_fwd = False + + if remote_host.auth_method == 'ssh_key': + key_paths = [] + if remote_host.key_path: + kp = os.path.expanduser(remote_host.key_path) + if os.path.exists(kp): + key_paths.append(kp) + logger.info("Using explicit SSH key: %s", kp) + else: + logger.warning("Configured key_path does not exist: %s (falling back to default keys / agent)", kp) + # If no valid explicit key, allow asyncssh to try default keys & agent + client_keys = key_paths or None + elif remote_host.auth_method == 'agent': + agent_fwd = True + client_keys = None # rely on agent + logger.info("Using SSH agent for auth (SSH_AUTH_SOCK=%s)", os.environ.get('SSH_AUTH_SOCK')) + elif remote_host.auth_method == 'password': + raise SSHError("Password auth not implemented – bitte SSH Key oder Agent nutzen") + + try: + logger.info( + "Opening SSH connection to %s, port %s (known_hosts=%s, client_keys=%s, agent=%s)", + remote_host.hostname, remote_host.port, known_hosts if known_hosts else 'DISABLED', client_keys, agent_fwd + ) + return await asyncssh.connect( + host=remote_host.hostname, + port=remote_host.port, + username=remote_host.username, + known_hosts=known_hosts, + client_keys=client_keys, + agent_forwarding=agent_fwd, + ) + except asyncssh.HostKeyNotVerifiable as e: + raise SSHError(f"Host key not trusted for host {remote_host.hostname}") from e + except asyncssh.PermissionDenied as e: + raise SSHError(f"Permission denied for user {remote_host.username} on host {remote_host.hostname}") from e + except (asyncssh.DisconnectError, asyncssh.ConnectionLost) as e: + raise SSHError(f"Connection lost: {e}") from e + except (asyncssh.Error, OSError) as e: + raise SSHError(str(e)) from e + +async def run_command(conn: asyncssh.SSHClientConnection, command: str, on_chunk: Callable[[str,str], Awaitable[None]], cancel_event: asyncio.Event) -> int: + try: + proc = await conn.create_process(command) + except (asyncssh.Error, OSError) as e: + raise SSHError(str(e)) from e + + tail_buf = '' + + async def reader(stream, stream_name): + nonlocal tail_buf + async for line in stream: + if cancel_event.is_set(): + break + data = line.rstrip('\n') + tail_buf = (tail_buf + data + '\n')[-CHUNK_LIMIT:] + await on_chunk(stream_name, data + '\n') + + stdout_task = asyncio.create_task(reader(proc.stdout, 'stdout')) + stderr_task = asyncio.create_task(reader(proc.stderr, 'stderr')) + + while True: + if cancel_event.is_set(): + proc.terminate() + try: + await asyncio.wait_for(proc.wait_closed(), timeout=5) + except asyncio.TimeoutError: + proc.kill() + return 130 # typical canceled code + if proc.exit_status is not None: + break + await asyncio.sleep(0.1) + + await stdout_task + await stderr_task + return proc.exit_status diff --git a/project/remotectl/services/tasks.py b/project/remotectl/services/tasks.py new file mode 100644 index 0000000..5a1e3b1 --- /dev/null +++ b/project/remotectl/services/tasks.py @@ -0,0 +1,38 @@ +# Simple predefined tasks registry +# key -> metadata (label, command template, description) +# Command template can use {param} formatting; future extension with parameters + +TASKS = { + 'deploy_app': { + 'label': 'Deploy App', + 'command': 'cd /srv/app && ./deploy.sh', + 'description': 'Run application deployment script.' + }, + 'restart_ssh': { + 'label': 'Restart SSH Service', + 'command': 'sudo systemctl restart ssh', + 'description': 'Restart the SSH daemon.' + }, + 'status_app': { + 'label': 'status App', + 'command': 'sudo systemctl status pipeline@pyapp-dali', + 'description': 'Run status.' + }, + 'start_app': { + 'label': 'start App', + 'command': 'sudo systemctl start pipeline@pyapp-dali', + 'description': 'Run start application ' + }, + 'stop_app': { + 'label': 'stop App', + 'command': 'sudo systemctl stop pipeline@pyapp-dali', + 'description': 'Run stop application ' + }, + +} + +def list_tasks(): + return TASKS + +def get_task(key: str): + return TASKS.get(key) diff --git a/project/remotectl/static/remotectl/app.js b/project/remotectl/static/remotectl/app.js new file mode 100644 index 0000000..2e431d5 --- /dev/null +++ b/project/remotectl/static/remotectl/app.js @@ -0,0 +1 @@ +// Placeholder for future JS (ANSI parsing, etc.) diff --git a/project/remotectl/templates/registration/logged_out.html b/project/remotectl/templates/registration/logged_out.html new file mode 100644 index 0000000..7f3d638 --- /dev/null +++ b/project/remotectl/templates/registration/logged_out.html @@ -0,0 +1,20 @@ + + + + + + Logout + + + + + +
+

Abgemeldet

+

Du bist jetzt abgemeldet.

+ Erneut anmelden +
+ + + \ No newline at end of file diff --git a/project/remotectl/templates/registration/login.html b/project/remotectl/templates/registration/login.html new file mode 100644 index 0000000..e1715f0 --- /dev/null +++ b/project/remotectl/templates/registration/login.html @@ -0,0 +1,35 @@ +{% load remotectl_extras %} + + + + + + Login + + + + + +
+

Login

+ {% if form.errors %} +
Ungültige Zugangsdaten.
+ {% endif %} +
+ {% csrf_token %} +
+ + {{ form.username|add_class:"w-full rounded border-gray-300 dark:border-gray-600 dark:bg-gray-900" }} +
+
+ + {{ form.password|add_class:"w-full rounded border-gray-300 dark:border-gray-600 dark:bg-gray-900" }} +
+ {% if next %}{% endif %} + +
+
+ + + \ No newline at end of file diff --git a/project/remotectl/templates/remotectl/base.html b/project/remotectl/templates/remotectl/base.html new file mode 100644 index 0000000..b2ea5ed --- /dev/null +++ b/project/remotectl/templates/remotectl/base.html @@ -0,0 +1,39 @@ + + + + + + {% block title %}Remote Admin{% endblock %} + + + + + + + +
+ {% block content %}{% endblock %} +
+ + + \ No newline at end of file diff --git a/project/remotectl/templates/remotectl/batch_delete.html b/project/remotectl/templates/remotectl/batch_delete.html new file mode 100644 index 0000000..76207e6 --- /dev/null +++ b/project/remotectl/templates/remotectl/batch_delete.html @@ -0,0 +1,12 @@ +{% extends 'remotectl/base.html' %} +{% block title %}Delete Batch | Remote Admin{% endblock %} +{% block content %} +

Delete Batch Script

+

Are you sure you want to delete {{ script.name }}?

+
+ {% csrf_token %} + + Cancel +
+{% endblock %} \ No newline at end of file diff --git a/project/remotectl/templates/remotectl/batch_form.html b/project/remotectl/templates/remotectl/batch_form.html new file mode 100644 index 0000000..377d285 --- /dev/null +++ b/project/remotectl/templates/remotectl/batch_form.html @@ -0,0 +1,28 @@ +{% extends 'remotectl/base.html' %} +{% load remotectl_extras %} +{% block title %}Batch Script | Remote Admin{% endblock %} +{% block content %} +

Batch Script

+
+ {% csrf_token %} + {{ form.non_field_errors }} + {% for field in form %} +
+ + {{ field|add_class:'w-full rounded border-gray-300 dark:border-gray-600 dark:bg-gray-900 font-mono text-sm' }} + {% if field.name == 'script' %} +

One command per line. Lines starting with # are ignored. Use 'cd path' to + set a working directory for subsequent steps.

+ {% endif %} + {% for e in field.errors %}

{{ e }}

{% endfor %} +
+ {% endfor %} +
+ + Cancel +
+
+{% endblock %} \ No newline at end of file diff --git a/project/remotectl/templates/remotectl/batch_form_help.txt b/project/remotectl/templates/remotectl/batch_form_help.txt new file mode 100644 index 0000000..2618bc8 --- /dev/null +++ b/project/remotectl/templates/remotectl/batch_form_help.txt @@ -0,0 +1,6 @@ +# Batch Script Help +# One command per line. Lines beginning with # are ignored. +# Example: +# cd /opt/app +# git pull origin master +# systemctl restart app@instance diff --git a/project/remotectl/templates/remotectl/batch_list.html b/project/remotectl/templates/remotectl/batch_list.html new file mode 100644 index 0000000..eb204c6 --- /dev/null +++ b/project/remotectl/templates/remotectl/batch_list.html @@ -0,0 +1,38 @@ +{% extends 'remotectl/base.html' %} +{% block title %}Batches | Remote Admin{% endblock %} +{% block content %} +
+

Batch Scripts

+ + New +
+
+ + + + + + + + + + + {% for s in scripts %} + + + + + + + {% empty %} + + + + {% endfor %} + +
NameDescriptionUpdated
{{ s.name }}{{ s.description }}{{ s.updated_at|date:'Y-m-d H:i' }} + Edit + Del +
No batch scripts.
+
+{% endblock %} \ No newline at end of file diff --git a/project/remotectl/templates/remotectl/console.html b/project/remotectl/templates/remotectl/console.html new file mode 100644 index 0000000..a3ac744 --- /dev/null +++ b/project/remotectl/templates/remotectl/console.html @@ -0,0 +1,132 @@ +{% extends 'remotectl/base.html' %} +{% block title %}Console | Remote Admin{% endblock %} +{% block content %} +

ConsoleDisconnected +

+
+
+
+

Connection +

+ + +
+ + +
+
+
+

Task / + Command

+
+ +
+ + Manage +
+
+
+ + +
+
+ + +
+
+ + +
+ + Manage +
+
+
+
+

Status

+
Disconnected
+
+
+
+
+ Live Output + +
+
+

+        
+
+
+ +{% endblock %} \ No newline at end of file diff --git a/project/remotectl/templates/remotectl/host_form.html b/project/remotectl/templates/remotectl/host_form.html new file mode 100644 index 0000000..d553149 --- /dev/null +++ b/project/remotectl/templates/remotectl/host_form.html @@ -0,0 +1,29 @@ +{% extends 'remotectl/base.html' %} +{% load remotectl_extras %} +{% block title %}Host | Remote Admin{% endblock %} +{% block content %} +

Host

+
+ {% csrf_token %} + {{ form.non_field_errors }} + {% for field in form %} +
+ + {% if field.field.widget.input_type == 'checkbox' %} +
{{ field }} {% if field.help_text %}{{ + field.help_text }}{% endif %}
+ {% else %} + {{ field|add_class:'w-full rounded border-gray-300 dark:border-gray-600 dark:bg-gray-900' }} + {% endif %} + {% for e in field.errors %}

{{ e }}

{% endfor %} +
+ {% endfor %} +
+ + Cancel +
+
+{% endblock %} \ No newline at end of file diff --git a/project/remotectl/templates/remotectl/host_list.html b/project/remotectl/templates/remotectl/host_list.html new file mode 100644 index 0000000..fb25cbc --- /dev/null +++ b/project/remotectl/templates/remotectl/host_list.html @@ -0,0 +1,40 @@ +{% extends 'remotectl/base.html' %} +{% block title %}Hosts | Remote Admin{% endblock %} +{% block content %} +
+

Hosts

+ + + New Host +
+
+ + + + + + + + + + + + {% for h in hosts %} + + + + + + + + {% empty %} + + + + {% endfor %} + +
NameHostUserAuth
{{ h.name }}{{ h.hostname }}:{{ h.port }}{{ h.username }}{{ + h.get_auth_method_display }}Edit
No hosts yet.
+
+{% endblock %} \ No newline at end of file diff --git a/project/remotectl/templates/remotectl/log_detail.html b/project/remotectl/templates/remotectl/log_detail.html new file mode 100644 index 0000000..7fe34ec --- /dev/null +++ b/project/remotectl/templates/remotectl/log_detail.html @@ -0,0 +1,32 @@ +{% extends 'remotectl/base.html' %} +{% block title %}Log {{ log.id }} | Remote Admin{% endblock %} +{% block content %} +

Log #{{ log.id }}

+
+
+
+
Host: {{ log.host }}
+
Type: {{ log.run_type }}
+
Status: {{ log.status }}{% if log.failed_step %} (failed step + {{ log.failed_step }}){% endif %}
+
Exit Code: {{ log.exit_code }}
+
Started: {{ log.started_at }}
+
Finished: {{ log.finished_at }}
+
Duration (s): {% if log.finished_at %}{{ log.duration|floatformat:2 + }}{% endif %}
+
+
+
Command
+ {{ log.command }} +
+
+
+
+

Output Tail

+
{{ log.output_tail }}
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/project/remotectl/templates/remotectl/log_list.html b/project/remotectl/templates/remotectl/log_list.html new file mode 100644 index 0000000..82e42f5 --- /dev/null +++ b/project/remotectl/templates/remotectl/log_list.html @@ -0,0 +1,49 @@ +{% extends 'remotectl/base.html' %} +{% block title %}Logs | Remote Admin{% endblock %} +{% block content %} +

Logs

+
+ + + + + + + + + + + + + + + {% for log in logs %} + + + + + + + + + + + {% empty %} + + + + {% endfor %} + +
StartedHostTypeCommandStatusExitDur (s)User
{{ log.started_at|date:'Y-m-d H:i:s' }}{{ log.host.name }}{{ log.run_type }}{{ log.command|truncatechars:60 }} + {% with s=log.status %} + {% if s == 'ok' %}OK + {% elif s == 'failed' %}FAILED{% if log.failed_step %}#{{ log.failed_step }}{% endif %} + {% elif s == 'canceled' %}CANCELED + {% elif s == 'error' %}ERROR{% if log.failed_step %}#{{ log.failed_step }}{% endif %} + {% else %}RUNNING{% endif %} + {% endwith %} + {{ log.exit_code }}{% if log.finished_at %}{{ log.duration|floatformat:2 }}{% endif %}{{ log.created_by }}
No logs.
+
+{% endblock %} \ No newline at end of file diff --git a/project/remotectl/templates/remotectl/manual.html b/project/remotectl/templates/remotectl/manual.html new file mode 100644 index 0000000..181929f --- /dev/null +++ b/project/remotectl/templates/remotectl/manual.html @@ -0,0 +1,130 @@ +{% extends 'remotectl/base.html' %} +{% block title %}Manual | Remote Admin{% endblock %} +{% block content %} +

Application Manual

+

Detailed reference for using the Remote Admin console. Use your + browser search (Ctrl+F) to find topics quickly.

+
+

1. Overview

+

This application lets you manage remote Linux hosts over SSH, execute ad-hoc commands, run saved tasks, and + process multi-step batch scripts with real-time streaming output via WebSockets.

+ +

2. Hosts

+

Create remote hosts under Hosts. Each host stores:

+
    +
  • name: Friendly display name
  • +
  • hostname: DNS name or IP
  • +
  • port: SSH port (default 22)
  • +
  • username: SSH login user
  • +
  • auth_method: ssh_key or agent (password not yet implemented)
  • +
  • key_path: Optional explicit private key path (not uploaded)
  • +
  • strict_host_key_checking: Enforce known_hosts validation
  • +
+

Example:

+
Host: prod-app-1
+Hostname: 10.10.10.15
+User: deploy
+Auth: ssh_key
+Key Path: /home/deploy/.ssh/id_ed25519
+ +

3. Console

+

Connect to a host then choose one of:

+
    +
  • Saved Task: Select a predefined, editable command.
  • +
  • Ad-hoc Command: Type any shell command (e.g. uname -a).
  • +
  • Batch Script: Multi-step script defined under Batches.
  • +
+

While running you can cancel. Output streams in real-time; stderr lines are highlighted.

+ +

4. Tasks

+

Tasks are named reusable commands. Fields:

+
    +
  • name: Unique internal key (no spaces recommended)
  • +
  • label: Display label
  • +
  • command: Shell snippet executed exactly on remote host
  • +
+

Example task:

+
Name: restart_app
+Label: Restart App Service
+Command: sudo systemctl restart app.service
+ +

5. Batch Scripts

+

A batch script is a list of steps (one per line). Blank lines and lines starting with # are ignored. + A line starting with cd <dir> sets the working directory for subsequent commands.

+

Example batch:

+
# Deploy sequence
+cd /srv/app
+./stop.sh
+./build.sh
+./start.sh
+

During execution you will see markers like:

+
>>> [2/4] ./build.sh
+ +

6. Status & Progress

+

Status badge meanings:

+
    +
  • CONNECTED: WebSocket open, no command running
  • +
  • RUNNING: Command or batch executing
  • +
  • STEP X/Y: Current batch step progress
  • +
  • CANCELING: Cancel requested; waiting for termination
  • +
  • COMPLETED (exit n): Finished with exit code
  • +
  • FAILED / ERROR: Non-zero exit or SSH/runtime issue
  • +
+ +

7. Logs

+

Each execution creates a log entry with:

+
    +
  • run_type: single or batch
  • +
  • status, exit_code
  • +
  • failed_step (batch only on failure)
  • +
  • Tail output (last 32K captured)
  • +
  • Timestamps and duration
  • +
+ +

8. Errors

+

Error events are structured as:

+
{"event":"error","type":"ssh|runtime","message":"..."}
+

SSH errors come from connection/auth issues; runtime errors are validation or state (e.g. duplicate run).

+ +

9. Cancellation

+

When you press Cancel the server sends a terminate signal; some commands may take time to exit gracefully. Batch + processing stops at the current step.

+ +

10. Security Notes

+
    +
  • Private keys are referenced by filesystem path only (never stored in DB).
  • +
  • Strict host key checking recommended for production.
  • +
  • All operations require authentication (Django login).
  • +
+ +

11. Example Use Cases

+

Deploy Application

+
Task: build_assets
+Command: npm run build
+
+Batch:
+cd /srv/app
+./stop.sh
+./deploy.sh
+./start.sh
+ +

Quick Diagnostics

+
Ad-hoc: df -h | grep /data
+Ad-hoc: sudo journalctl -u app.service -n 50
+ +

12. Troubleshooting

+
    +
  • Permission denied: Check user, key, and file permissions.
  • +
  • Host key not trusted: Add host to known_hosts or disable strict checking (dev only).
  • +
  • No output: Some scripts buffer output; use unbuffered modes (e.g. python -u). +
  • +
+ +

13. Future Extensions

+
    +
  • Parameterized tasks
  • +
  • Role-based permissions
  • +
  • Streaming partial log persistence
  • +
+
+{% endblock %} \ No newline at end of file diff --git a/project/remotectl/templates/remotectl/readme.html b/project/remotectl/templates/remotectl/readme.html new file mode 100644 index 0000000..69f7a8d --- /dev/null +++ b/project/remotectl/templates/remotectl/readme.html @@ -0,0 +1,6 @@ +{% extends 'remotectl/base.html' %} +{% block title %}README | Remote Admin{% endblock %} +{% block content %} +

Project README

+
{{ readme_html|safe }}
+{% endblock %} \ No newline at end of file diff --git a/project/remotectl/templates/remotectl/task_delete.html b/project/remotectl/templates/remotectl/task_delete.html new file mode 100644 index 0000000..1910898 --- /dev/null +++ b/project/remotectl/templates/remotectl/task_delete.html @@ -0,0 +1,12 @@ +{% extends 'remotectl/base.html' %} +{% block title %}Delete Task | Remote Admin{% endblock %} +{% block content %} +

Delete Task

+

Are you sure you want to delete {{ task.label }}?

+
+ {% csrf_token %} + + Cancel +
+{% endblock %} \ No newline at end of file diff --git a/project/remotectl/templates/remotectl/task_form.html b/project/remotectl/templates/remotectl/task_form.html new file mode 100644 index 0000000..2ced759 --- /dev/null +++ b/project/remotectl/templates/remotectl/task_form.html @@ -0,0 +1,26 @@ +{% extends 'remotectl/base.html' %} +{% load remotectl_extras %} +{% block title %}Task | Remote Admin{% endblock %} +{% block content %} +

Task

+
+ {% csrf_token %} + {{ form.non_field_errors }} + {% for field in form %} +
+ + {{ field|add_class:'w-full rounded border-gray-300 dark:border-gray-600 dark:bg-gray-900 font-mono text-sm' }} + {% if field.name == 'command' %}

Shell snippet executed exactly as entered + on the remote host.

{% endif %} + {% for e in field.errors %}

{{ e }}

{% endfor %} +
+ {% endfor %} +
+ + Cancel +
+
+{% endblock %} \ No newline at end of file diff --git a/project/remotectl/templates/remotectl/task_list.html b/project/remotectl/templates/remotectl/task_list.html new file mode 100644 index 0000000..2ff2713 --- /dev/null +++ b/project/remotectl/templates/remotectl/task_list.html @@ -0,0 +1,34 @@ +{% extends 'remotectl/base.html' %} +{% block title %}Tasks | Remote Admin{% endblock %} +{% block content %} +
+

Tasks

+ + New +
+
+ + + + + + + + + + {% for t in tasks_db %} + + + + + + {% empty %} + + + + {% endfor %} + +
LabelCommand
{{ t.label }}{{ t.command|truncatechars:160 }}EditDel
No tasks.
+
+{% endblock %} \ No newline at end of file diff --git a/project/remotectl/templatetags/__init__.py b/project/remotectl/templatetags/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/project/remotectl/templatetags/remotectl_extras.py b/project/remotectl/templatetags/remotectl_extras.py new file mode 100644 index 0000000..8551e38 --- /dev/null +++ b/project/remotectl/templatetags/remotectl_extras.py @@ -0,0 +1,10 @@ +from django import template + +register = template.Library() + +@register.filter(name="add_class") +def add_class(field, css): + # Preserve existing classes and append new ones + existing = (field.field.widget.attrs or {}).get('class', '') + combined = (existing + ' ' + css).strip() + return field.as_widget(attrs={**(field.field.widget.attrs or {}), 'class': combined}) diff --git a/project/remotectl/tests/__init__.py b/project/remotectl/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/project/remotectl/tests/conftest.py b/project/remotectl/tests/conftest.py new file mode 100644 index 0000000..b9a6259 --- /dev/null +++ b/project/remotectl/tests/conftest.py @@ -0,0 +1,11 @@ +import pytest +from django.contrib.auth import get_user_model + +@pytest.fixture +def user_factory(): + User = get_user_model() + def make_user(**kwargs): + defaults = dict(username='user') + defaults.update(kwargs) + return User.objects.create(**defaults) + return make_user diff --git a/project/remotectl/tests/test_batch.py b/project/remotectl/tests/test_batch.py new file mode 100644 index 0000000..e8d7638 --- /dev/null +++ b/project/remotectl/tests/test_batch.py @@ -0,0 +1,20 @@ +import pytest +from channels.testing import WebsocketCommunicator +from django.urls import reverse +from project.config.asgi import application +from project.remotectl.models import RemoteHost, BatchScript + +@pytest.mark.django_db(transaction=True) +@pytest.mark.asyncio +async def test_batch_model_creation(django_user_model): + user = await django_user_model.objects.acreate(username='u') + b = await BatchScript.objects.acreate(name='test', description='d', script='echo hi') + assert b.name == 'test' + +@pytest.mark.django_db(transaction=True) +@pytest.mark.asyncio +async def test_batch_ws_requires_auth(): + communicator = WebsocketCommunicator(application, "/ws/ssh/11111111-1111-1111-1111-111111111111/stream/") + connected, _ = await communicator.connect() + assert connected is False or (await communicator.receive_nothing(timeout=0.1) is None) + await communicator.disconnect() diff --git a/project/remotectl/tests/test_crud.py b/project/remotectl/tests/test_crud.py new file mode 100644 index 0000000..ffb2b3f --- /dev/null +++ b/project/remotectl/tests/test_crud.py @@ -0,0 +1,81 @@ +import pytest +from django.urls import reverse +from project.remotectl.models import RemoteHost, BatchScript, CommandTask + +@pytest.mark.django_db +def test_host_crud(client, user_factory): + user = user_factory(username='u1') + client.force_login(user) + # create + resp = client.post(reverse('host_create'), { + 'name': 'TestHost', 'hostname': '127.0.0.1', 'port': 22, 'username': 'root', + 'auth_method': 'ssh_key', 'key_path': '', 'strict_host_key_checking': True, 'notes': 'n' + }) + assert resp.status_code == 302 + assert RemoteHost.objects.filter(name='TestHost').exists() + host = RemoteHost.objects.get(name='TestHost') + # edit + resp = client.post(f"/hosts/{host.id}/edit/", { + 'name': 'TestHost2', 'hostname': '127.0.0.1', 'port': 22, 'username': 'root', + 'auth_method': 'ssh_key', 'key_path': '', 'strict_host_key_checking': True, 'notes': 'n2' + }) + assert resp.status_code == 302 + host.refresh_from_db() + assert host.name == 'TestHost2' + # list + resp = client.get(reverse('host_list')) + assert resp.status_code == 200 + assert 'TestHost2' in resp.content.decode() + +@pytest.mark.django_db +def test_batch_crud(client, user_factory): + user = user_factory(username='u2') + client.force_login(user) + # create + resp = client.post(reverse('batch_create'), { + 'name': 'batch1', 'description': 'desc', 'script': 'echo one' + }) + assert resp.status_code == 302 + b = BatchScript.objects.get(name='batch1') + # edit + resp = client.post(f"/batches/{b.id}/edit/", { + 'name': 'batch1', 'description': 'desc2', 'script': 'echo two' + }) + assert resp.status_code == 302 + b.refresh_from_db() + assert 'two' in b.script + # list json + resp = client.get(reverse('batch_list_json')) + assert resp.status_code == 200 + assert 'batch1' in resp.json()[0]['name'] + # delete + resp = client.post(f"/batches/{b.id}/delete/") + assert resp.status_code == 302 + assert not BatchScript.objects.filter(id=b.id).exists() + +@pytest.mark.django_db +def test_task_crud_and_json(client, user_factory): + user = user_factory(username='u3') + client.force_login(user) + # create + resp = client.post(reverse('task_create'), { + 'name': 'task1', 'label': 'Task One', 'command': 'uptime', 'description': 'd' + }) + assert resp.status_code == 302 + t = CommandTask.objects.get(name='task1') + # edit + resp = client.post(f"/tasks/{t.id}/edit/", { + 'name': 'task1', 'label': 'Task 1', 'command': 'echo hi', 'description': 'd2' + }) + assert resp.status_code == 302 + t.refresh_from_db() + assert 'echo hi' in t.command + # json list + resp = client.get(reverse('task_list_json')) + assert resp.status_code == 200 + data = resp.json() + assert any(item['name'] == 'task1' for item in data) + # delete + resp = client.post(f"/tasks/{t.id}/delete/") + assert resp.status_code == 302 + assert not CommandTask.objects.filter(id=t.id).exists() diff --git a/project/remotectl/tests/test_dummy_consumer.py b/project/remotectl/tests/test_dummy_consumer.py new file mode 100644 index 0000000..5933178 --- /dev/null +++ b/project/remotectl/tests/test_dummy_consumer.py @@ -0,0 +1,14 @@ +import pytest +from channels.testing import WebsocketCommunicator +from project.config.asgi import application +from django.contrib.auth import get_user_model +import uuid + +@pytest.mark.asyncio +@pytest.mark.django_db(transaction=True) +async def test_requires_auth(): + session_id = uuid.uuid4() + communicator = WebsocketCommunicator(application, f"/ws/ssh/{session_id}/stream/") + connected, _ = await communicator.connect() + assert connected is False or (await communicator.receive_nothing(timeout=0.1) is None) + await communicator.disconnect() diff --git a/project/remotectl/tests/test_models.py b/project/remotectl/tests/test_models.py new file mode 100644 index 0000000..baad202 --- /dev/null +++ b/project/remotectl/tests/test_models.py @@ -0,0 +1,16 @@ +import pytest +from project.remotectl.models import RemoteHost, CommandLog + +@pytest.mark.django_db +def test_create_host(): + h = RemoteHost.objects.create(name='Local', hostname='127.0.0.1', username='root') + assert str(h).startswith('Local') + +@pytest.mark.django_db +def test_command_log_mark_finished(user_factory, django_user_model): + user = django_user_model.objects.create(username='tester') + h = RemoteHost.objects.create(name='Local', hostname='127.0.0.1', username='root') + log = CommandLog.objects.create(host=h, command='echo hi', created_by=user) + log.mark_finished('ok', 0, 'hi') + assert log.status == 'ok' + assert log.exit_code == 0 diff --git a/project/remotectl/tests/test_ws_execution.py b/project/remotectl/tests/test_ws_execution.py new file mode 100644 index 0000000..9216913 --- /dev/null +++ b/project/remotectl/tests/test_ws_execution.py @@ -0,0 +1,77 @@ +import pytest +import uuid +from channels.testing import WebsocketCommunicator +from project.config.asgi import application +from project.remotectl.models import RemoteHost + +class DummyProc: + def __init__(self, chunks, exit_status=0): + self._chunks = chunks + self._exit_status_value = exit_status + self.stdout_iter = self._iter_stream('stdout') + self.stderr_iter = self._iter_stream('stderr') + self.stdout = self._aiter(self.stdout_iter) + self.stderr = self._aiter(self.stderr_iter) + self._done = False + + def _iter_stream(self, which): + for stream, data in self._chunks: + if stream == which: + yield data + + async def _aiter(self, it): + for item in it: + yield item + self._done = True + + @property + def exit_status(self): + return self._exit_status_value if self._done else None + + def terminate(self): + self._done = True + + def kill(self): + self._done = True + +class DummyConn: + def __init__(self, procs): + self.procs = procs + async def create_process(self, command): + return self.procs.pop(0) + def close(self): + pass + +@pytest.mark.django_db(transaction=True) +@pytest.mark.asyncio +async def test_run_simple_command(monkeypatch, django_user_model): + user = await django_user_model.objects.acreate(username='user') + host = await RemoteHost.objects.acreate(name='h', hostname='localhost', username='u') + + from project.remotectl import consumers + async def fake_open_connection(rh): + return DummyConn([DummyProc([('stdout', 'line1\n'), ('stdout', 'line2\n')], exit_status=0)]) + monkeypatch.setattr(consumers, 'open_connection', fake_open_connection) + + session_id = uuid.uuid4() + communicator = WebsocketCommunicator(application, f"/ws/ssh/{session_id}/stream/") + communicator.scope['user'] = user + connected, _ = await communicator.connect() + assert connected + msg = await communicator.receive_json_from() + assert msg['event'] == 'connected' + + await communicator.send_json_to({'action':'start','host_id': host.id, 'command':'echo hi'}) + started_or_error = await communicator.receive_json_from() + assert started_or_error['event'] == 'started' + + chunks = [] + while True: + msg = await communicator.receive_json_from() + if msg['event'] == 'chunk': + chunks.append(msg['data'].strip()) + elif msg['event'] == 'completed': + assert msg['status'] == 'ok' + break + assert 'line1' in chunks and 'line2' in chunks + await communicator.disconnect() diff --git a/project/remotectl/tests/test_ws_execution_more.py b/project/remotectl/tests/test_ws_execution_more.py new file mode 100644 index 0000000..b14e8f8 --- /dev/null +++ b/project/remotectl/tests/test_ws_execution_more.py @@ -0,0 +1,167 @@ +import pytest +import uuid +import asyncio +from channels.testing import WebsocketCommunicator +from project.config.asgi import application +from project.remotectl.models import RemoteHost, BatchScript, CommandTask + +class DummyConn: + def close(self): + pass + +@pytest.mark.django_db(transaction=True) +@pytest.mark.asyncio +async def test_command_failure(monkeypatch, django_user_model): + user = await django_user_model.objects.acreate(username='userf') + host = await RemoteHost.objects.acreate(name='hf', hostname='localhost', username='u') + + from project.remotectl import consumers + + async def fake_open_connection(rh): + return DummyConn() + + async def fake_run_command(conn, command, on_chunk, cancel_event): + await on_chunk('stdout', 'doing something\n') + return 5 # non-zero + + monkeypatch.setattr(consumers, 'open_connection', fake_open_connection) + monkeypatch.setattr(consumers, 'run_command', fake_run_command) + + session_id = uuid.uuid4() + comm = WebsocketCommunicator(application, f"/ws/ssh/{session_id}/stream/") + comm.scope['user'] = user + connected, _ = await comm.connect(); assert connected + await comm.receive_json_from() # connected + await comm.send_json_to({'action':'start','host_id':host.id,'command':'failingcmd'}) + msg = await comm.receive_json_from(); assert msg['event']=='started' + # drain until completed + status = None + while True: + m = await comm.receive_json_from() + if m['event'] == 'completed': + status = m['status']; break + assert status == 'failed' + await comm.disconnect() + +@pytest.mark.django_db(transaction=True) +@pytest.mark.asyncio +async def test_batch_success(monkeypatch, django_user_model): + user = await django_user_model.objects.acreate(username='userb') + host = await RemoteHost.objects.acreate(name='hb', hostname='localhost', username='u') + batch = await BatchScript.objects.acreate(name='b1', description='d', script='cd /tmp\necho one\necho two\n# comment\necho three') + + from project.remotectl import consumers + async def fake_open_connection(rh): + return DummyConn() + + calls = [] + async def fake_run_command(conn, command, on_chunk, cancel_event): + calls.append(command) + await on_chunk('stdout', 'out\n') + return 0 + + monkeypatch.setattr(consumers, 'open_connection', fake_open_connection) + monkeypatch.setattr(consumers, 'run_command', fake_run_command) + + session_id = uuid.uuid4() + comm = WebsocketCommunicator(application, f"/ws/ssh/{session_id}/stream/") + comm.scope['user'] = user + connected, _ = await comm.connect(); assert connected + await comm.receive_json_from() # connected + await comm.send_json_to({'action':'start_batch','host_id':host.id,'batch_id':batch.id}) + msg = await comm.receive_json_from(); assert msg['event']=='started' + step_headers = 0 + completed = None + while True: + m = await comm.receive_json_from() + if m['event']=='chunk' and m['data'].startswith('\n>>>'): + step_headers += 1 + if m['event']=='completed': + completed = m; break + assert completed['status']=='ok' + # Steps: cd, echo one, echo two, echo three => 4 headers + assert step_headers == 5 or step_headers >= 4 # tolerate extra formatting + # run_command should have been called for 3 executable echo commands + assert len(calls) == 3 + await comm.disconnect() + +@pytest.mark.django_db(transaction=True) +@pytest.mark.asyncio +async def test_batch_failure(monkeypatch, django_user_model): + user = await django_user_model.objects.acreate(username='userbf') + host = await RemoteHost.objects.acreate(name='hbf', hostname='localhost', username='u') + batch = await BatchScript.objects.acreate(name='bf', description='d', script='echo ok\necho fail\necho skip') + + from project.remotectl import consumers + async def fake_open_connection(rh): + return DummyConn() + + call_count = 0 + async def fake_run_command(conn, command, on_chunk, cancel_event): + nonlocal call_count + call_count += 1 + await on_chunk('stdout', f'run {call_count}\n') + if call_count == 2: + return 9 # fail second step + return 0 + + monkeypatch.setattr(consumers, 'open_connection', fake_open_connection) + monkeypatch.setattr(consumers, 'run_command', fake_run_command) + + session_id = uuid.uuid4() + comm = WebsocketCommunicator(application, f"/ws/ssh/{session_id}/stream/") + comm.scope['user'] = user + connected, _ = await comm.connect(); assert connected + await comm.receive_json_from() # connected + await comm.send_json_to({'action':'start_batch','host_id':host.id,'batch_id':batch.id}) + await comm.receive_json_from() # started + while True: + m = await comm.receive_json_from() + if m['event']=='completed': + assert m['status'] == 'failed' + break + assert call_count == 2 # third not executed + await comm.disconnect() + +@pytest.mark.django_db(transaction=True) +@pytest.mark.asyncio +async def test_cancel_command(monkeypatch, django_user_model): + user = await django_user_model.objects.acreate(username='userc') + host = await RemoteHost.objects.acreate(name='hc', hostname='localhost', username='u') + + from project.remotectl import consumers + async def fake_open_connection(rh): + return DummyConn() + + async def fake_run_command(conn, command, on_chunk, cancel_event): + # emit some output repeatedly until canceled + for i in range(5): + await on_chunk('stdout', f'line {i}\n') + await asyncio.sleep(0) + if cancel_event.is_set(): + return 130 + return 0 + + monkeypatch.setattr(consumers, 'open_connection', fake_open_connection) + monkeypatch.setattr(consumers, 'run_command', fake_run_command) + + session_id = uuid.uuid4() + comm = WebsocketCommunicator(application, f"/ws/ssh/{session_id}/stream/") + comm.scope['user'] = user + connected, _ = await comm.connect(); assert connected + await comm.receive_json_from() # connected + await comm.send_json_to({'action':'start','host_id':host.id,'command':'longrun'}) + await comm.receive_json_from() # started + # send cancel quickly + await comm.send_json_to({'action':'cancel'}) + saw_canceling = False + status = None + while True: + m = await comm.receive_json_from() + if m['event']=='canceling': + saw_canceling = True + if m['event']=='completed': + status = m['status']; break + assert saw_canceling + assert status == 'canceled' + await comm.disconnect() diff --git a/project/remotectl/urls.py b/project/remotectl/urls.py new file mode 100644 index 0000000..194233f --- /dev/null +++ b/project/remotectl/urls.py @@ -0,0 +1,23 @@ +from django.urls import path +from . import views + +urlpatterns = [ + path('', views.console, name='console'), + path('manual/', views.manual, name='manual'), + path('readme/', views.readme, name='readme'), + path('hosts/', views.host_list, name='host_list'), + path('hosts/new/', views.host_create, name='host_create'), + path('hosts//edit/', views.host_edit, name='host_edit'), + path('logs/', views.log_list, name='log_list'), + path('logs//', views.log_detail, name='log_detail'), + path('batches/', views.batch_list, name='batch_list'), + path('batches/new/', views.batch_create, name='batch_create'), + path('batches//edit/', views.batch_edit, name='batch_edit'), + path('batches//delete/', views.batch_delete, name='batch_delete'), + path('batches/json/', views.batch_list_json, name='batch_list_json'), + path('tasks/', views.task_list, name='task_list'), + path('tasks/new/', views.task_create, name='task_create'), + path('tasks//edit/', views.task_edit, name='task_edit'), + path('tasks//delete/', views.task_delete, name='task_delete'), + path('tasks/json/', views.task_list_json, name='task_list_json'), +] diff --git a/project/remotectl/views.py b/project/remotectl/views.py new file mode 100644 index 0000000..7735485 --- /dev/null +++ b/project/remotectl/views.py @@ -0,0 +1,155 @@ +from django.contrib.auth.decorators import login_required +from django.shortcuts import render, redirect, get_object_or_404 +from django.urls import reverse +from django.utils import timezone +from django.http import JsonResponse, Http404 +from .models import RemoteHost, CommandLog, BatchScript, CommandTask +from .forms import RemoteHostForm, BatchScriptForm, CommandTaskForm +import uuid, os, markdown + +@login_required +def host_list(request): + hosts = RemoteHost.objects.all() + return render(request, 'remotectl/host_list.html', {'hosts': hosts}) + +@login_required +def host_create(request): + if request.method == 'POST': + form = RemoteHostForm(request.POST) + if form.is_valid(): + form.save() + return redirect('host_list') + else: + form = RemoteHostForm() + return render(request, 'remotectl/host_form.html', {'form': form}) + +@login_required +def host_edit(request, pk): + host = get_object_or_404(RemoteHost, pk=pk) + if request.method == 'POST': + form = RemoteHostForm(request.POST, instance=host) + if form.is_valid(): + form.save() + return redirect('host_list') + else: + form = RemoteHostForm(instance=host) + return render(request, 'remotectl/host_form.html', {'form': form, 'host': host}) + +@login_required +def console(request): + session_id = uuid.uuid4() + hosts = RemoteHost.objects.all() + tasks = CommandTask.objects.all() + return render(request, 'remotectl/console.html', {'session_id': session_id, 'hosts': hosts, 'tasks': tasks}) + +@login_required +def log_list(request): + logs = CommandLog.objects.select_related('host','created_by')[:200] + return render(request, 'remotectl/log_list.html', {'logs': logs}) + +@login_required +def log_detail(request, pk): + log = get_object_or_404(CommandLog, pk=pk) + return render(request, 'remotectl/log_detail.html', {'log': log}) + +@login_required +def batch_list(request): + scripts = BatchScript.objects.all() + return render(request, 'remotectl/batch_list.html', {'scripts': scripts}) + +@login_required +def batch_create(request): + if request.method == 'POST': + form = BatchScriptForm(request.POST) + if form.is_valid(): + obj = form.save(commit=False) + obj.created_by = request.user + obj.save() + return redirect('batch_list') + else: + form = BatchScriptForm() + return render(request, 'remotectl/batch_form.html', {'form': form}) + +@login_required +def batch_edit(request, pk): + script = get_object_or_404(BatchScript, pk=pk) + if request.method == 'POST': + form = BatchScriptForm(request.POST, instance=script) + if form.is_valid(): + form.save() + return redirect('batch_list') + else: + form = BatchScriptForm(instance=script) + return render(request, 'remotectl/batch_form.html', {'form': form, 'script': script}) + +@login_required +def batch_delete(request, pk): + script = get_object_or_404(BatchScript, pk=pk) + if request.method == 'POST': + script.delete() + return redirect('batch_list') + return render(request, 'remotectl/batch_delete.html', {'script': script}) + +@login_required +def batch_list_json(request): + scripts = list(BatchScript.objects.values('id','name','description','updated_at')) + return JsonResponse(scripts, safe=False) + +@login_required +def task_list(request): + tasks_db = CommandTask.objects.all() + return render(request, 'remotectl/task_list.html', {'tasks_db': tasks_db}) + +@login_required +def task_create(request): + if request.method == 'POST': + form = CommandTaskForm(request.POST) + if form.is_valid(): + obj = form.save(commit=False) + obj.created_by = request.user + obj.save() + return redirect('task_list') + else: + form = CommandTaskForm() + return render(request, 'remotectl/task_form.html', {'form': form}) + +@login_required +def task_edit(request, pk): + task = get_object_or_404(CommandTask, pk=pk) + if request.method == 'POST': + form = CommandTaskForm(request.POST, instance=task) + if form.is_valid(): + form.save() + return redirect('task_list') + else: + form = CommandTaskForm(instance=task) + return render(request, 'remotectl/task_form.html', {'form': form, 'task': task}) + +@login_required +def task_delete(request, pk): + task = get_object_or_404(CommandTask, pk=pk) + if request.method == 'POST': + task.delete() + return redirect('task_list') + return render(request, 'remotectl/task_delete.html', {'task': task}) + +@login_required +def task_list_json(request): + data = list(CommandTask.objects.values('id','name','label','command')) + return JsonResponse(data, safe=False) + +@login_required +def manual(request): + return render(request, 'remotectl/manual.html') + +@login_required +def readme(request): + base_path = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) # project root + readme_path = os.path.join(base_path, 'README.md') + try: + with open(readme_path, 'r', encoding='utf-8') as f: + md_text = f.read() + except OSError: + md_text = '# README.md not found' # fallback + html = markdown.markdown(md_text, extensions=['fenced_code', 'tables']) + return render(request, 'remotectl/readme.html', {'readme_html': html}) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..c5933f8 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,13 @@ +[tool.black] +line-length = 100 +target-version = ['py312'] + +[tool.ruff] +line-length = 100 +select = ["E","F","I","UP"] +ignore = ["E203","E501"] + +[tool.pytest.ini_options] +DJANGO_SETTINGS_MODULE = "project.config.settings" +python_files = ["tests.py","test_*.py"] +asyncio_mode = "auto" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..a948fa7 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,14 @@ +Django==5.1.1 +channels==4.1.0 +daphne==4.0.0 +asyncssh==2.14.2 +python-dotenv==1.0.1 +# htmx is loaded via CDN in templates; no pip package required +uvicorn==0.30.6 # optional alternative server +asgiref>=3.8.0 +black==24.8.0 +ruff==0.6.5 +pytest==8.3.2 +pytest-django==4.8.0 +pytest-asyncio==0.24.0 +markdown==3.7