first commit

This commit is contained in:
ilanq7 2025-10-15 17:41:05 +02:00
commit ab1a42091d
58 changed files with 2448 additions and 0 deletions

7
.env.example Normal file
View File

@ -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

6
.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
__pycache__/
*.pyc
.env
.venv/
db.sqlite3
staticfiles/

1
Procfile.dev Normal file
View File

@ -0,0 +1 @@
web: daphne -b 127.0.0.1 -p 8000 config.asgi:application

259
README.md Normal file
View File

@ -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 adhoc 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`).
---

15
manage.py Normal file
View File

@ -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()

0
project/__init__.py Normal file
View File

View File

22
project/config/asgi.py Normal file
View File

@ -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)),
})

View File

@ -0,0 +1,6 @@
from django.urls import path
from project.remotectl import consumers
websocket_urlpatterns = [
path('ws/ssh/<uuid:session_id>/stream/', consumers.SSHStreamConsumer.as_asgi()),
]

View File

@ -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'

10
project/config/urls.py Normal file
View File

@ -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)),
]

11
project/config/wsgi.py Normal file
View File

@ -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()

View File

View File

@ -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",)

View File

@ -0,0 +1,5 @@
from django.apps import AppConfig
class RemoteCtlConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'project.remotectl'

View File

@ -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

View File

@ -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}),
}

View File

@ -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"],
},
),
]

View File

@ -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"],
},
),
]

View File

@ -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"],
},
),
]

View File

@ -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
),
),
]

View File

118
project/remotectl/models.py Normal file
View File

@ -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

View File

View File

@ -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

View File

@ -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)

View File

@ -0,0 +1 @@
// Placeholder for future JS (ANSI parsing, etc.)

View File

@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Logout</title>
<meta name="viewport" content="width=device-width,initial-scale=1" />
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="min-h-screen bg-gray-100 dark:bg-gray-900 p-8 text-gray-900 dark:text-gray-100">
<div class="max-w-md mx-auto bg-white dark:bg-gray-800 p-6 rounded-lg shadow space-y-4">
<h1 class="text-xl font-semibold">Abgemeldet</h1>
<p class="text-sm">Du bist jetzt abgemeldet.</p>
<a href="/accounts/login/"
class="inline-block px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded text-sm">Erneut anmelden</a>
</div>
</body>
</html>

View File

@ -0,0 +1,35 @@
{% load remotectl_extras %}
<!DOCTYPE html>
<html lang="en" class="h-full">
<head>
<meta charset="utf-8" />
<title>Login</title>
<meta name="viewport" content="width=device-width,initial-scale=1" />
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body
class="min-h-screen flex items-center justify-center bg-gray-100 dark:bg-gray-900 p-6 text-gray-900 dark:text-gray-100">
<div class="w-full max-w-sm bg-white dark:bg-gray-800 p-6 rounded-lg shadow">
<h1 class="text-xl font-semibold mb-5">Login</h1>
{% if form.errors %}
<div class="mb-4 text-sm text-red-600">Ungültige Zugangsdaten.</div>
{% endif %}
<form method="post" class="space-y-4">
{% csrf_token %}
<div>
<label class="block text-xs font-semibold mb-1 uppercase tracking-wide">Benutzername</label>
{{ form.username|add_class:"w-full rounded border-gray-300 dark:border-gray-600 dark:bg-gray-900" }}
</div>
<div>
<label class="block text-xs font-semibold mb-1 uppercase tracking-wide">Passwort</label>
{{ form.password|add_class:"w-full rounded border-gray-300 dark:border-gray-600 dark:bg-gray-900" }}
</div>
{% if next %}<input type="hidden" name="next" value="{{ next }}" />{% endif %}
<button class="w-full bg-blue-600 hover:bg-blue-700 text-white py-2 rounded font-medium">Anmelden</button>
</form>
</div>
</body>
</html>

View File

@ -0,0 +1,39 @@
<!DOCTYPE html>
<html lang="en" class="h-full" x-data="{dark: localStorage.getItem('dark')==='1'}" x-bind:class="dark ? 'dark' : ''"
xmlns:x="http://www.w3.org/1999/xhtml">
<head>
<meta charset="UTF-8" />
<title>{% block title %}Remote Admin{% endblock %}</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<script src="https://cdn.tailwindcss.com"></script>
<meta name="viewport" content="width=device-width, initial-scale=1" />
</head>
<body class="bg-gray-100 text-gray-900">
<nav class="bg-gray-800 text-white p-3 flex gap-4">
<a href="/" class="font-semibold">Console</a>
<a href="/hosts/">Hosts</a>
<a href="/logs/">Logs</a>
<a href="/batches/">Batches</a>
<a href="/tasks/">Tasks</a>
<a href="/manual/">Manual</a>
<a href="/readme/">README</a>
<span class="ml-auto">
{% if user.is_authenticated %}
<form method="post" action="{% url 'logout' %}" class="inline-flex items-center gap-2">
{% csrf_token %}
<span class="text-sm opacity-80">{{ user.username }}</span>
<button type="submit" class="underline hover:text-red-300">Logout</button>
</form>
{% else %}
<a href="{% url 'login' %}" class="underline">Login</a>
{% endif %}
</span>
</nav>
<main class="p-4 max-w-6xl mx-auto">
{% block content %}{% endblock %}
</main>
</body>
</html>

View File

@ -0,0 +1,12 @@
{% extends 'remotectl/base.html' %}
{% block title %}Delete Batch | Remote Admin{% endblock %}
{% block content %}
<h1 class="text-2xl font-semibold mb-6">Delete Batch Script</h1>
<p class="mb-4">Are you sure you want to delete <span class="font-semibold">{{ script.name }}</span>?</p>
<form method="post" class="space-x-3">
{% csrf_token %}
<button class="bg-red-600 hover:bg-red-700 text-white px-5 py-2 rounded shadow text-sm">Delete</button>
<a href="/batches/"
class="text-sm px-5 py-2 rounded border border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700">Cancel</a>
</form>
{% endblock %}

View File

@ -0,0 +1,28 @@
{% extends 'remotectl/base.html' %}
{% load remotectl_extras %}
{% block title %}Batch Script | Remote Admin{% endblock %}
{% block content %}
<h1 class="text-2xl font-semibold mb-6">Batch Script</h1>
<form method="post"
class="space-y-5 max-w-5xl bg-white dark:bg-gray-800 p-6 rounded-lg shadow border border-gray-200 dark:border-gray-700">
{% csrf_token %}
{{ form.non_field_errors }}
{% for field in form %}
<div>
<label class="block text-xs font-semibold mb-1 uppercase tracking-wide text-gray-600 dark:text-gray-400"
for="id_{{ field.name }}">{{ field.label }}</label>
{{ 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' %}
<p class="text-xs text-gray-500 mt-1">One command per line. Lines starting with # are ignored. Use 'cd path' to
set a working directory for subsequent steps.</p>
{% endif %}
{% for e in field.errors %}<p class="text-xs text-red-600 mt-1">{{ e }}</p>{% endfor %}
</div>
{% endfor %}
<div class="flex gap-3 pt-2">
<button class="bg-green-600 hover:bg-green-700 text-white px-5 py-2 rounded shadow text-sm">Save</button>
<a href="/batches/"
class="text-sm px-5 py-2 rounded border border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700">Cancel</a>
</div>
</form>
{% endblock %}

View File

@ -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

View File

@ -0,0 +1,38 @@
{% extends 'remotectl/base.html' %}
{% block title %}Batches | Remote Admin{% endblock %}
{% block content %}
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-semibold">Batch Scripts</h1>
<a href="/batches/new/" class="bg-blue-600 hover:bg-blue-700 text-white text-sm px-4 py-2 rounded shadow">+ New</a>
</div>
<div class="overflow-hidden rounded-lg shadow border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800">
<table class="w-full text-sm">
<thead class="bg-gray-50 dark:bg-gray-700/60 text-gray-600 dark:text-gray-300 uppercase text-xs tracking-wide">
<tr>
<th class="px-3 py-2 text-left">Name</th>
<th class="px-3 py-2 text-left">Description</th>
<th class="px-3 py-2 text-left">Updated</th>
<th class="px-3 py-2"></th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 dark:divide-gray-700">
{% for s in scripts %}
<tr class="hover:bg-blue-50 dark:hover:bg-gray-700/60">
<td class="px-3 py-2 font-medium">{{ s.name }}</td>
<td class="px-3 py-2 truncate max-w-md">{{ s.description }}</td>
<td class="px-3 py-2 text-xs">{{ s.updated_at|date:'Y-m-d H:i' }}</td>
<td class="px-3 py-2 text-right space-x-2">
<a href="/batches/{{ s.id }}/edit/" class="text-blue-600 hover:underline">Edit</a>
<a href="/batches/{{ s.id }}/delete/" hx-confirm="Delete batch '{{ s.name }}'?"
class="text-red-600 hover:underline">Del</a>
</td>
</tr>
{% empty %}
<tr>
<td colspan="4" class="px-3 py-6 text-center text-gray-500">No batch scripts.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}

View File

@ -0,0 +1,132 @@
{% extends 'remotectl/base.html' %}
{% block title %}Console | Remote Admin{% endblock %}
{% block content %}
<h1 class="text-2xl font-semibold mb-6 flex items-center gap-3"><span>Console</span><span id="statusBadge"
class="text-xs px-2 py-1 rounded bg-gray-300 dark:bg-gray-700 text-gray-800 dark:text-gray-200">Disconnected</span>
</h1>
<div class="grid md:grid-cols-3 gap-6 mb-6">
<div class="md:col-span-1 space-y-6">
<section class="p-4 rounded-lg shadow bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700">
<h2 class="font-semibold mb-3 text-sm uppercase tracking-wide text-gray-600 dark:text-gray-400">Connection
</h2>
<label class="block text-xs font-medium mb-1">Host</label>
<select id="hostSelect" class="w-full mb-3 rounded border-gray-300 dark:border-gray-600 dark:bg-gray-900">
<option value="">-- choose --</option>
{% for h in hosts %}<option value="{{ h.id }}">{{ h.name }} ({{ h.username }}@{{ h.hostname }})</option>
{% endfor %}
</select>
<div class="flex gap-2">
<button id="connectBtn"
class="flex-1 bg-blue-600 hover:bg-blue-700 text-white text-sm py-1.5 rounded">Connect</button>
<button id="disconnectBtn" disabled
class="flex-1 bg-red-600/60 hover:bg-red-600 text-white text-sm py-1.5 rounded">Disconnect</button>
</div>
</section>
<section
class="p-4 rounded-lg shadow bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 space-y-3">
<h2 class="font-semibold mb-1 text-sm uppercase tracking-wide text-gray-600 dark:text-gray-400">Task /
Command</h2>
<div>
<label class="block text-xs font-medium mb-1">Saved Task</label>
<div class="flex gap-2">
<select id="taskSelect"
class="w-full rounded border-gray-300 dark:border-gray-600 dark:bg-gray-900">
<option value="">-- none --</option>
{% for task in tasks %}<option value="{{ task.name }}">{{ task.label }}</option>{% endfor %}
</select>
<a href="/tasks/"
class="px-3 py-1.5 text-xs rounded border border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700">Manage</a>
</div>
</div>
<div>
<label class="block text-xs font-medium mb-1">Ad-hoc Command</label>
<input id="commandInput" type="text"
class="w-full rounded border-gray-300 dark:border-gray-600 dark:bg-gray-900" placeholder="uptime" />
</div>
<div class="flex gap-2 pt-1">
<button id="runBtn" disabled
class="flex-1 bg-green-600/70 hover:bg-green-600 text-white text-sm py-1.5 rounded">Run</button>
<button id="cancelBtn" disabled
class="flex-1 bg-yellow-500/60 hover:bg-yellow-500 text-white text-sm py-1.5 rounded">Cancel</button>
</div>
<div>
<label class="block text-xs font-medium mb-1 mt-2">Batch Script</label>
<select id="batchSelect" class="w-full rounded border-gray-300 dark:border-gray-600 dark:bg-gray-900">
<option value="">-- none --</option>
</select>
<div class="flex gap-2 mt-2">
<button id="runBatchBtn" disabled
class="flex-1 bg-purple-600/70 hover:bg-purple-600 text-white text-sm py-1.5 rounded">Run
Batch</button>
<a href="/batches/"
class="px-3 py-1.5 text-xs rounded border border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700">Manage</a>
</div>
</div>
</section>
<section
class="p-4 rounded-lg shadow bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-xs leading-relaxed">
<h2 class="font-semibold mb-2 text-sm uppercase tracking-wide text-gray-600 dark:text-gray-400">Status</h2>
<div id="statusBar" class="font-mono break-words">Disconnected</div>
</section>
</div>
<div class="md:col-span-2 flex flex-col">
<div class="flex items-center justify-between mb-2 text-xs text-gray-500 dark:text-gray-400">
<span>Live Output</span>
<button id="clearBtn"
class="px-2 py-0.5 border rounded border-gray-300 dark:border-gray-600 hover:bg-gray-200 dark:hover:bg-gray-700">Clear</button>
</div>
<div class="relative flex-1">
<pre id="terminal"
class="bg-black text-green-300 rounded-lg p-3 font-mono text-sm h-[560px] overflow-y-auto whitespace-pre-wrap"></pre>
</div>
</div>
</div>
<script>
const sessionId = "{{ session_id }}";
let socket = null; let running = false; let canceling = false; let batchTotal = 0; let currentStep = 0; let lastExit = null;
const termEl = document.getElementById('terminal');
function badge(status, extra='') {
const b = document.getElementById('statusBadge');
const map = { disconnected:'bg-gray-400 text-gray-900', connected:'bg-blue-500 text-white', running:'bg-amber-500 text-white', completed_ok:'bg-green-600 text-white', completed_failed:'bg-red-600 text-white', completed_error:'bg-red-700 text-white', canceled:'bg-yellow-600 text-white', canceling:'bg-yellow-500 text-white', error:'bg-red-600 text-white'};
b.textContent = status.replace('_',' ').toUpperCase() + (extra?(' '+extra):'') + (lastExit!==null?` (exit ${lastExit})`:'' );
b.className = 'text-xs px-2 py-1 rounded ' + (map[status] || 'bg-gray-500 text-white');
document.getElementById('statusBar').textContent = b.textContent;
}
function append(stream, text) { const span = document.createElement('span'); if (stream === 'stderr') span.className = 'text-red-400'; span.textContent = text; termEl.appendChild(span); termEl.scrollTop = termEl.scrollHeight; }
function setButtons(state) {
const disabledRunning = running || canceling;
document.getElementById('connectBtn').disabled = state !== 'disconnected';
document.getElementById('disconnectBtn').disabled = state === 'disconnected';
document.getElementById('runBtn').disabled = !(state === 'connected' && !disabledRunning);
document.getElementById('cancelBtn').disabled = !running || canceling;
document.getElementById('runBatchBtn').disabled = !(state === 'connected' && !disabledRunning && document.getElementById('batchSelect').value);
}
setButtons('disconnected'); badge('disconnected');
document.getElementById('clearBtn').onclick = () => { termEl.textContent = ''; };
function clearTaskBatchIf(valId, others) { if (document.getElementById(valId).value) { others.forEach(id => document.getElementById(id).value = ''); } }
document.getElementById('taskSelect').addEventListener('change', () => { clearTaskBatchIf('taskSelect', ['commandInput','batchSelect']); setButtons('connected'); });
document.getElementById('commandInput').addEventListener('input', () => { clearTaskBatchIf('commandInput', ['taskSelect','batchSelect']); setButtons('connected'); });
document.getElementById('batchSelect').addEventListener('change', () => { if (document.getElementById('batchSelect').value) { document.getElementById('taskSelect').value=''; document.getElementById('commandInput').value=''; } setButtons('connected'); });
document.getElementById('connectBtn').onclick = () => {
const hostId = document.getElementById('hostSelect').value; if (!hostId) { alert('Select a host'); return; }
socket = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + window.location.host + `/ws/ssh/${sessionId}/stream/`);
socket.onopen = () => {
badge('connected'); setButtons('connected'); Promise.all([
fetch('/batches/json/', { headers: { 'Accept': 'application/json' } }).then(r => r.ok ? r.json() : Promise.reject('Bad response')),
fetch('/tasks/json/', { headers: { 'Accept': 'application/json' } }).then(r => r.ok ? r.json() : Promise.reject('Bad response'))
]).then(([batches, tasks]) => { const batchSel = document.getElementById('batchSelect'); batchSel.innerHTML = '<option value="">-- none --</option>'; batches.forEach(b => { const o = document.createElement('option'); o.value = b.id; o.textContent = `${b.name}`; batchSel.appendChild(o); }); const taskSel = document.getElementById('taskSelect'); taskSel.querySelectorAll('option').forEach(o => { if (o.value) o.remove(); }); tasks.forEach(t => { const o = document.createElement('option'); o.value = t.name; o.textContent = t.label; taskSel.appendChild(o); }); }).catch(err => console.error('Data load failed', err));
};
socket.onmessage = (evt) => { const msg = JSON.parse(evt.data); if (msg.event === 'chunk') { append(msg.stream, msg.data); }
else if (msg.event === 'progress') { currentStep = msg.current; batchTotal = msg.total; badge('running', `STEP ${currentStep}/${batchTotal}`); }
else if (msg.event === 'started') { running = true; canceling = false; lastExit = null; badge('running'); setButtons('connected'); }
else if (msg.event === 'canceling') { canceling = true; badge('canceling'); setButtons('connected'); }
else if (msg.event === 'completed') { running = false; canceling = false; lastExit = msg.exit_code; let st = msg.status; if (st==='ok') badge('completed_ok'); else if (st==='failed') badge('completed_failed'); else if (st==='error') badge('completed_error'); else if (st==='canceled') badge('canceled'); setButtons('connected'); }
else if (msg.event === 'error') { append('stderr', `ERROR: ${msg.message}\n`); running = false; canceling = false; badge('error'); setButtons('connected'); } };
socket.onclose = () => { badge('disconnected'); setButtons('disconnected'); running = false; canceling = false; };
};
document.getElementById('runBtn').onclick = () => { if (!socket || socket.readyState !== 1) { alert('Not connected'); return; } const hostId = document.getElementById('hostSelect').value; const taskKey = document.getElementById('taskSelect').value || null; const command = document.getElementById('commandInput').value.trim() || null; if (!taskKey && !command) { alert('Enter a command or choose a task'); return; } termEl.textContent = ''; socket.send(JSON.stringify({ action: 'start', host_id: hostId, task_key: taskKey, command: command })); };
document.getElementById('runBatchBtn').onclick = () => { if (!socket || socket.readyState !== 1) { alert('Not connected'); return; } const hostId = document.getElementById('hostSelect').value; const batchId = document.getElementById('batchSelect').value; if (!batchId) { alert('Select batch'); return; } termEl.textContent=''; currentStep=0; batchTotal=0; socket.send(JSON.stringify({ action: 'start_batch', host_id: hostId, batch_id: batchId })); };
document.getElementById('cancelBtn').onclick = () => { if (socket) socket.send(JSON.stringify({ action: 'cancel' })); };
document.getElementById('disconnectBtn').onclick = () => { if (socket) { socket.send(JSON.stringify({ action: 'disconnect' })); socket.close(); } };
</script>
{% endblock %}

View File

@ -0,0 +1,29 @@
{% extends 'remotectl/base.html' %}
{% load remotectl_extras %}
{% block title %}Host | Remote Admin{% endblock %}
{% block content %}
<h1 class="text-2xl font-semibold mb-6">Host</h1>
<form method="post"
class="space-y-5 max-w-2xl bg-white dark:bg-gray-800 p-6 rounded-lg shadow border border-gray-200 dark:border-gray-700">
{% csrf_token %}
{{ form.non_field_errors }}
{% for field in form %}
<div>
<label class="block text-xs font-semibold mb-1 uppercase tracking-wide text-gray-600 dark:text-gray-400"
for="id_{{ field.name }}">{{ field.label }}</label>
{% if field.field.widget.input_type == 'checkbox' %}
<div class="flex items-center gap-2">{{ field }} {% if field.help_text %}<span class="text-xs text-gray-500">{{
field.help_text }}</span>{% endif %}</div>
{% else %}
{{ field|add_class:'w-full rounded border-gray-300 dark:border-gray-600 dark:bg-gray-900' }}
{% endif %}
{% for e in field.errors %}<p class="text-xs text-red-600 mt-1">{{ e }}</p>{% endfor %}
</div>
{% endfor %}
<div class="flex gap-3 pt-2">
<button class="bg-green-600 hover:bg-green-700 text-white px-5 py-2 rounded shadow text-sm">Save</button>
<a href="/hosts/"
class="text-sm px-5 py-2 rounded border border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700">Cancel</a>
</div>
</form>
{% endblock %}

View File

@ -0,0 +1,40 @@
{% extends 'remotectl/base.html' %}
{% block title %}Hosts | Remote Admin{% endblock %}
{% block content %}
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-semibold">Hosts</h1>
<a href="/hosts/new/"
class="inline-flex items-center gap-1 bg-blue-600 hover:bg-blue-700 text-white text-sm px-4 py-2 rounded shadow">+
New Host</a>
</div>
<div class="overflow-hidden rounded-lg shadow border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800">
<table class="w-full text-sm">
<thead class="bg-gray-50 dark:bg-gray-700/60 text-gray-600 dark:text-gray-300 uppercase text-xs tracking-wide">
<tr>
<th class="px-3 py-2 text-left">Name</th>
<th class="px-3 py-2 text-left">Host</th>
<th class="px-3 py-2 text-left">User</th>
<th class="px-3 py-2 text-left">Auth</th>
<th class="px-3 py-2"></th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 dark:divide-gray-700">
{% for h in hosts %}
<tr class="hover:bg-blue-50 dark:hover:bg-gray-700/60">
<td class="px-3 py-2 font-medium">{{ h.name }}</td>
<td class="px-3 py-2">{{ h.hostname }}:<span class="text-xs text-gray-500">{{ h.port }}</span></td>
<td class="px-3 py-2">{{ h.username }}</td>
<td class="px-3 py-2"><span class="px-2 py-0.5 rounded bg-gray-200 dark:bg-gray-600 text-xs">{{
h.get_auth_method_display }}</span></td>
<td class="px-3 py-2 text-right"><a class="text-blue-600 dark:text-blue-400 hover:underline"
href="/hosts/{{ h.id }}/edit/">Edit</a></td>
</tr>
{% empty %}
<tr>
<td colspan="5" class="px-3 py-6 text-center text-gray-500">No hosts yet.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}

View File

@ -0,0 +1,32 @@
{% extends 'remotectl/base.html' %}
{% block title %}Log {{ log.id }} | Remote Admin{% endblock %}
{% block content %}
<h1 class="text-2xl font-semibold mb-6">Log #{{ log.id }}</h1>
<div class="grid md:grid-cols-3 gap-6">
<div class="space-y-4 md:col-span-1">
<div
class="p-4 rounded-lg bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-sm space-y-1">
<div><span class="font-semibold">Host:</span> {{ log.host }}</div>
<div><span class="font-semibold">Type:</span> {{ log.run_type }}</div>
<div><span class="font-semibold">Status:</span> {{ log.status }}{% if log.failed_step %} (failed step
{{ log.failed_step }}){% endif %}</div>
<div><span class="font-semibold">Exit Code:</span> {{ log.exit_code }}</div>
<div><span class="font-semibold">Started:</span> {{ log.started_at }}</div>
<div><span class="font-semibold">Finished:</span> {{ log.finished_at }}</div>
<div><span class="font-semibold">Duration (s):</span> {% if log.finished_at %}{{ log.duration|floatformat:2
}}{% endif %}</div>
</div>
<div class="p-4 rounded-lg bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-sm">
<div class="font-semibold mb-2">Command</div>
<code class="block text-xs break-words">{{ log.command }}</code>
</div>
</div>
<div class="md:col-span-2">
<div class="p-4 rounded-lg bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700">
<h2 class="font-semibold mb-2">Output Tail</h2>
<pre
class="bg-black text-green-300 p-3 rounded-lg max-h-[560px] overflow-y-auto text-sm whitespace-pre-wrap">{{ log.output_tail }}</pre>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,49 @@
{% extends 'remotectl/base.html' %}
{% block title %}Logs | Remote Admin{% endblock %}
{% block content %}
<h1 class="text-2xl font-semibold mb-6">Logs</h1>
<div class="overflow-hidden rounded-lg shadow border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800">
<table class="w-full text-sm">
<thead class="bg-gray-50 dark:bg-gray-700/60 text-gray-600 dark:text-gray-300 uppercase text-xs tracking-wide">
<tr>
<th class="px-3 py-2 text-left">Started</th>
<th class="px-3 py-2 text-left">Host</th>
<th class="px-3 py-2 text-left">Type</th>
<th class="px-3 py-2 text-left">Command</th>
<th class="px-3 py-2 text-left">Status</th>
<th class="px-3 py-2 text-left">Exit</th>
<th class="px-3 py-2 text-left">Dur (s)</th>
<th class="px-3 py-2 text-left">User</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 dark:divide-gray-700">
{% for log in logs %}
<tr class="hover:bg-blue-50 dark:hover:bg-gray-700/60">
<td class="px-3 py-2"><a class="text-blue-600 dark:text-blue-400 hover:underline"
href="/logs/{{ log.id }}/">{{ log.started_at|date:'Y-m-d H:i:s' }}</a></td>
<td class="px-3 py-2">{{ log.host.name }}</td>
<td class="px-3 py-2">{{ log.run_type }}</td>
<td class="px-3 py-2 truncate max-w-xs">{{ log.command|truncatechars:60 }}</td>
<td class="px-3 py-2">
{% with s=log.status %}
{% if s == 'ok' %}<span class="px-2 py-0.5 rounded bg-green-600 text-white text-xs">OK</span>
{% elif s == 'failed' %}<span class="px-2 py-0.5 rounded bg-red-600 text-white text-xs">FAILED{% if log.failed_step %}#{{ log.failed_step }}{% endif %}</span>
{% elif s == 'canceled' %}<span
class="px-2 py-0.5 rounded bg-yellow-500 text-white text-xs">CANCELED</span>
{% elif s == 'error' %}<span class="px-2 py-0.5 rounded bg-pink-600 text-white text-xs">ERROR{% if log.failed_step %}#{{ log.failed_step }}{% endif %}</span>
{% else %}<span class="px-2 py-0.5 rounded bg-blue-600 text-white text-xs">RUNNING</span>{% endif %}
{% endwith %}
</td>
<td class="px-3 py-2">{{ log.exit_code }}</td>
<td class="px-3 py-2">{% if log.finished_at %}{{ log.duration|floatformat:2 }}{% endif %}</td>
<td class="px-3 py-2">{{ log.created_by }}</td>
</tr>
{% empty %}
<tr>
<td colspan="8" class="px-3 py-6 text-center text-gray-500">No logs.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}

View File

@ -0,0 +1,130 @@
{% extends 'remotectl/base.html' %}
{% block title %}Manual | Remote Admin{% endblock %}
{% block content %}
<h1 class="text-3xl font-semibold mb-6">Application Manual</h1>
<p class="mb-6 text-sm text-gray-600 dark:text-gray-400">Detailed reference for using the Remote Admin console. Use your
browser search (Ctrl+F) to find topics quickly.</p>
<div class="prose dark:prose-invert max-w-none text-sm">
<h2 id="overview">1. Overview</h2>
<p>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.</p>
<h2 id="hosts">2. Hosts</h2>
<p>Create remote hosts under <strong>Hosts</strong>. Each host stores:</p>
<ul>
<li><code>name</code>: Friendly display name</li>
<li><code>hostname</code>: DNS name or IP</li>
<li><code>port</code>: SSH port (default 22)</li>
<li><code>username</code>: SSH login user</li>
<li><code>auth_method</code>: <code>ssh_key</code> or <code>agent</code> (password not yet implemented)</li>
<li><code>key_path</code>: Optional explicit private key path (not uploaded)</li>
<li><code>strict_host_key_checking</code>: Enforce known_hosts validation</li>
</ul>
<p><strong>Example:</strong></p>
<pre>Host: prod-app-1
Hostname: 10.10.10.15
User: deploy
Auth: ssh_key
Key Path: /home/deploy/.ssh/id_ed25519</pre>
<h2 id="console">3. Console</h2>
<p>Connect to a host then choose one of:</p>
<ul>
<li><strong>Saved Task</strong>: Select a predefined, editable command.</li>
<li><strong>Ad-hoc Command</strong>: Type any shell command (e.g. <code>uname -a</code>).</li>
<li><strong>Batch Script</strong>: Multi-step script defined under Batches.</li>
</ul>
<p>While running you can cancel. Output streams in real-time; stderr lines are highlighted.</p>
<h2 id="tasks">4. Tasks</h2>
<p>Tasks are named reusable commands. Fields:</p>
<ul>
<li><code>name</code>: Unique internal key (no spaces recommended)</li>
<li><code>label</code>: Display label</li>
<li><code>command</code>: Shell snippet executed exactly on remote host</li>
</ul>
<p><strong>Example task:</strong></p>
<pre>Name: restart_app
Label: Restart App Service
Command: sudo systemctl restart app.service</pre>
<h2 id="batches">5. Batch Scripts</h2>
<p>A batch script is a list of steps (one per line). Blank lines and lines starting with <code>#</code> are ignored.
A line starting with <code>cd &lt;dir&gt;</code> sets the working directory for subsequent commands.</p>
<p><strong>Example batch:</strong></p>
<pre># Deploy sequence
cd /srv/app
./stop.sh
./build.sh
./start.sh</pre>
<p>During execution you will see markers like:</p>
<pre>&gt;&gt;&gt; [2/4] ./build.sh</pre>
<h2 id="status">6. Status & Progress</h2>
<p>Status badge meanings:</p>
<ul>
<li><strong>CONNECTED</strong>: WebSocket open, no command running</li>
<li><strong>RUNNING</strong>: Command or batch executing</li>
<li><strong>STEP X/Y</strong>: Current batch step progress</li>
<li><strong>CANCELING</strong>: Cancel requested; waiting for termination</li>
<li><strong>COMPLETED (exit n)</strong>: Finished with exit code</li>
<li><strong>FAILED / ERROR</strong>: Non-zero exit or SSH/runtime issue</li>
</ul>
<h2 id="logs">7. Logs</h2>
<p>Each execution creates a log entry with:</p>
<ul>
<li><code>run_type</code>: <code>single</code> or <code>batch</code></li>
<li><code>status</code>, <code>exit_code</code></li>
<li><code>failed_step</code> (batch only on failure)</li>
<li>Tail output (last 32K captured)</li>
<li>Timestamps and duration</li>
</ul>
<h2 id="errors">8. Errors</h2>
<p>Error events are structured as:</p>
<pre>{"event":"error","type":"ssh|runtime","message":"..."}</pre>
<p>SSH errors come from connection/auth issues; runtime errors are validation or state (e.g. duplicate run).</p>
<h2 id="cancellation">9. Cancellation</h2>
<p>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.</p>
<h2 id="security">10. Security Notes</h2>
<ul>
<li>Private keys are referenced by filesystem path only (never stored in DB).</li>
<li>Strict host key checking recommended for production.</li>
<li>All operations require authentication (Django login).</li>
</ul>
<h2 id="examples">11. Example Use Cases</h2>
<h3>Deploy Application</h3>
<pre>Task: build_assets
Command: npm run build
Batch:
cd /srv/app
./stop.sh
./deploy.sh
./start.sh</pre>
<h3>Quick Diagnostics</h3>
<pre>Ad-hoc: df -h | grep /data
Ad-hoc: sudo journalctl -u app.service -n 50</pre>
<h2 id="troubleshooting">12. Troubleshooting</h2>
<ul>
<li><strong>Permission denied</strong>: Check user, key, and file permissions.</li>
<li><strong>Host key not trusted</strong>: Add host to known_hosts or disable strict checking (dev only).</li>
<li><strong>No output</strong>: Some scripts buffer output; use unbuffered modes (e.g. <code>python -u</code>).
</li>
</ul>
<h2 id="future">13. Future Extensions</h2>
<ul>
<li>Parameterized tasks</li>
<li>Role-based permissions</li>
<li>Streaming partial log persistence</li>
</ul>
</div>
{% endblock %}

View File

@ -0,0 +1,6 @@
{% extends 'remotectl/base.html' %}
{% block title %}README | Remote Admin{% endblock %}
{% block content %}
<h1 class="text-3xl font-semibold mb-6">Project README</h1>
<div class="prose dark:prose-invert max-w-none text-sm">{{ readme_html|safe }}</div>
{% endblock %}

View File

@ -0,0 +1,12 @@
{% extends 'remotectl/base.html' %}
{% block title %}Delete Task | Remote Admin{% endblock %}
{% block content %}
<h1 class="text-2xl font-semibold mb-6">Delete Task</h1>
<p class="mb-4">Are you sure you want to delete <span class="font-semibold">{{ task.label }}</span>?</p>
<form method="post" class="space-x-3">
{% csrf_token %}
<button class="bg-red-600 hover:bg-red-700 text-white px-5 py-2 rounded shadow text-sm">Delete</button>
<a href="/tasks/"
class="text-sm px-5 py-2 rounded border border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700">Cancel</a>
</form>
{% endblock %}

View File

@ -0,0 +1,26 @@
{% extends 'remotectl/base.html' %}
{% load remotectl_extras %}
{% block title %}Task | Remote Admin{% endblock %}
{% block content %}
<h1 class="text-2xl font-semibold mb-6">Task</h1>
<form method="post"
class="space-y-5 max-w-3xl bg-white dark:bg-gray-800 p-6 rounded-lg shadow border border-gray-200 dark:border-gray-700">
{% csrf_token %}
{{ form.non_field_errors }}
{% for field in form %}
<div>
<label class="block text-xs font-semibold mb-1 uppercase tracking-wide text-gray-600 dark:text-gray-400"
for="id_{{ field.name }}">{{ field.label }}</label>
{{ 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' %}<p class="text-xs text-gray-500 mt-1">Shell snippet executed exactly as entered
on the remote host.</p>{% endif %}
{% for e in field.errors %}<p class="text-xs text-red-600 mt-1">{{ e }}</p>{% endfor %}
</div>
{% endfor %}
<div class="flex gap-3 pt-2">
<button class="bg-green-600 hover:bg-green-700 text-white px-5 py-2 rounded shadow text-sm">Save</button>
<a href="/tasks/"
class="text-sm px-5 py-2 rounded border border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700">Cancel</a>
</div>
</form>
{% endblock %}

View File

@ -0,0 +1,34 @@
{% extends 'remotectl/base.html' %}
{% block title %}Tasks | Remote Admin{% endblock %}
{% block content %}
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-semibold">Tasks</h1>
<a href="/tasks/new/" class="bg-blue-600 hover:bg-blue-700 text-white text-sm px-4 py-2 rounded shadow">+ New</a>
</div>
<div class="overflow-hidden rounded-lg shadow border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800">
<table class="w-full text-sm">
<thead class="bg-gray-50 dark:bg-gray-700/60 text-gray-600 dark:text-gray-300 uppercase text-xs tracking-wide">
<tr>
<th class="px-3 py-2 text-left">Label</th>
<th class="px-3 py-2 text-left">Command</th>
<th class="px-3 py-2"></th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 dark:divide-gray-700">
{% for t in tasks_db %}
<tr class="hover:bg-blue-50 dark:hover:bg-gray-700/60">
<td class="px-3 py-2 font-medium">{{ t.label }}</td>
<td class="px-3 py-2 text-xs font-mono whitespace-pre">{{ t.command|truncatechars:160 }}</td>
<td class="px-3 py-2 text-right space-x-2"><a href="/tasks/{{ t.id }}/edit/"
class="text-blue-600 hover:underline">Edit</a><a href="/tasks/{{ t.id }}/delete/"
hx-confirm="Delete task '{{ t.label }}'?" class="text-red-600 hover:underline">Del</a></td>
</tr>
{% empty %}
<tr>
<td colspan="3" class="px-3 py-6 text-center text-gray-500">No tasks.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}

View File

@ -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})

View File

View File

@ -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

View File

@ -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()

View File

@ -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()

View File

@ -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()

View File

@ -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

View File

@ -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()

View File

@ -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()

23
project/remotectl/urls.py Normal file
View File

@ -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/<int:pk>/edit/', views.host_edit, name='host_edit'),
path('logs/', views.log_list, name='log_list'),
path('logs/<int:pk>/', 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/<int:pk>/edit/', views.batch_edit, name='batch_edit'),
path('batches/<int:pk>/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/<int:pk>/edit/', views.task_edit, name='task_edit'),
path('tasks/<int:pk>/delete/', views.task_delete, name='task_delete'),
path('tasks/json/', views.task_list_json, name='task_list_json'),
]

155
project/remotectl/views.py Normal file
View File

@ -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})

13
pyproject.toml Normal file
View File

@ -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"

14
requirements.txt Normal file
View File

@ -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