first commit
This commit is contained in:
commit
ab1a42091d
7
.env.example
Normal file
7
.env.example
Normal 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
6
.gitignore
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.env
|
||||
.venv/
|
||||
db.sqlite3
|
||||
staticfiles/
|
||||
1
Procfile.dev
Normal file
1
Procfile.dev
Normal file
@ -0,0 +1 @@
|
||||
web: daphne -b 127.0.0.1 -p 8000 config.asgi:application
|
||||
259
README.md
Normal file
259
README.md
Normal 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 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`).
|
||||
|
||||
---
|
||||
|
||||
15
manage.py
Normal file
15
manage.py
Normal 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
0
project/__init__.py
Normal file
0
project/config/__init__.py
Normal file
0
project/config/__init__.py
Normal file
22
project/config/asgi.py
Normal file
22
project/config/asgi.py
Normal 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)),
|
||||
})
|
||||
6
project/config/routing.py
Normal file
6
project/config/routing.py
Normal 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()),
|
||||
]
|
||||
85
project/config/settings.py
Normal file
85
project/config/settings.py
Normal 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
10
project/config/urls.py
Normal 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
11
project/config/wsgi.py
Normal 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()
|
||||
0
project/remotectl/__init__.py
Normal file
0
project/remotectl/__init__.py
Normal file
13
project/remotectl/admin.py
Normal file
13
project/remotectl/admin.py
Normal 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",)
|
||||
5
project/remotectl/apps.py
Normal file
5
project/remotectl/apps.py
Normal file
@ -0,0 +1,5 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
class RemoteCtlConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'project.remotectl'
|
||||
230
project/remotectl/consumers.py
Normal file
230
project/remotectl/consumers.py
Normal 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
|
||||
27
project/remotectl/forms.py
Normal file
27
project/remotectl/forms.py
Normal 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}),
|
||||
}
|
||||
103
project/remotectl/migrations/0001_initial.py
Normal file
103
project/remotectl/migrations/0001_initial.py
Normal 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"],
|
||||
},
|
||||
),
|
||||
]
|
||||
49
project/remotectl/migrations/0002_batchscript.py
Normal file
49
project/remotectl/migrations/0002_batchscript.py
Normal 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"],
|
||||
},
|
||||
),
|
||||
]
|
||||
48
project/remotectl/migrations/0003_commandtask.py
Normal file
48
project/remotectl/migrations/0003_commandtask.py
Normal 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"],
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -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
|
||||
),
|
||||
),
|
||||
]
|
||||
0
project/remotectl/migrations/__init__.py
Normal file
0
project/remotectl/migrations/__init__.py
Normal file
118
project/remotectl/models.py
Normal file
118
project/remotectl/models.py
Normal 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
|
||||
0
project/remotectl/services/__init__.py
Normal file
0
project/remotectl/services/__init__.py
Normal file
100
project/remotectl/services/ssh_client.py
Normal file
100
project/remotectl/services/ssh_client.py
Normal 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
|
||||
38
project/remotectl/services/tasks.py
Normal file
38
project/remotectl/services/tasks.py
Normal 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)
|
||||
1
project/remotectl/static/remotectl/app.js
Normal file
1
project/remotectl/static/remotectl/app.js
Normal file
@ -0,0 +1 @@
|
||||
// Placeholder for future JS (ANSI parsing, etc.)
|
||||
20
project/remotectl/templates/registration/logged_out.html
Normal file
20
project/remotectl/templates/registration/logged_out.html
Normal 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>
|
||||
35
project/remotectl/templates/registration/login.html
Normal file
35
project/remotectl/templates/registration/login.html
Normal 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>
|
||||
39
project/remotectl/templates/remotectl/base.html
Normal file
39
project/remotectl/templates/remotectl/base.html
Normal 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>
|
||||
12
project/remotectl/templates/remotectl/batch_delete.html
Normal file
12
project/remotectl/templates/remotectl/batch_delete.html
Normal 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 %}
|
||||
28
project/remotectl/templates/remotectl/batch_form.html
Normal file
28
project/remotectl/templates/remotectl/batch_form.html
Normal 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 %}
|
||||
@ -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
|
||||
38
project/remotectl/templates/remotectl/batch_list.html
Normal file
38
project/remotectl/templates/remotectl/batch_list.html
Normal 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 %}
|
||||
132
project/remotectl/templates/remotectl/console.html
Normal file
132
project/remotectl/templates/remotectl/console.html
Normal 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 %}
|
||||
29
project/remotectl/templates/remotectl/host_form.html
Normal file
29
project/remotectl/templates/remotectl/host_form.html
Normal 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 %}
|
||||
40
project/remotectl/templates/remotectl/host_list.html
Normal file
40
project/remotectl/templates/remotectl/host_list.html
Normal 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 %}
|
||||
32
project/remotectl/templates/remotectl/log_detail.html
Normal file
32
project/remotectl/templates/remotectl/log_detail.html
Normal 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 %}
|
||||
49
project/remotectl/templates/remotectl/log_list.html
Normal file
49
project/remotectl/templates/remotectl/log_list.html
Normal 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 %}
|
||||
130
project/remotectl/templates/remotectl/manual.html
Normal file
130
project/remotectl/templates/remotectl/manual.html
Normal 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 <dir></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>>>> [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 %}
|
||||
6
project/remotectl/templates/remotectl/readme.html
Normal file
6
project/remotectl/templates/remotectl/readme.html
Normal 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 %}
|
||||
12
project/remotectl/templates/remotectl/task_delete.html
Normal file
12
project/remotectl/templates/remotectl/task_delete.html
Normal 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 %}
|
||||
26
project/remotectl/templates/remotectl/task_form.html
Normal file
26
project/remotectl/templates/remotectl/task_form.html
Normal 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 %}
|
||||
34
project/remotectl/templates/remotectl/task_list.html
Normal file
34
project/remotectl/templates/remotectl/task_list.html
Normal 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 %}
|
||||
0
project/remotectl/templatetags/__init__.py
Normal file
0
project/remotectl/templatetags/__init__.py
Normal file
10
project/remotectl/templatetags/remotectl_extras.py
Normal file
10
project/remotectl/templatetags/remotectl_extras.py
Normal 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})
|
||||
0
project/remotectl/tests/__init__.py
Normal file
0
project/remotectl/tests/__init__.py
Normal file
11
project/remotectl/tests/conftest.py
Normal file
11
project/remotectl/tests/conftest.py
Normal 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
|
||||
20
project/remotectl/tests/test_batch.py
Normal file
20
project/remotectl/tests/test_batch.py
Normal 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()
|
||||
81
project/remotectl/tests/test_crud.py
Normal file
81
project/remotectl/tests/test_crud.py
Normal 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()
|
||||
14
project/remotectl/tests/test_dummy_consumer.py
Normal file
14
project/remotectl/tests/test_dummy_consumer.py
Normal 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()
|
||||
16
project/remotectl/tests/test_models.py
Normal file
16
project/remotectl/tests/test_models.py
Normal 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
|
||||
77
project/remotectl/tests/test_ws_execution.py
Normal file
77
project/remotectl/tests/test_ws_execution.py
Normal 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()
|
||||
167
project/remotectl/tests/test_ws_execution_more.py
Normal file
167
project/remotectl/tests/test_ws_execution_more.py
Normal 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
23
project/remotectl/urls.py
Normal 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
155
project/remotectl/views.py
Normal 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
13
pyproject.toml
Normal 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
14
requirements.txt
Normal 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
|
||||
Loading…
x
Reference in New Issue
Block a user