ε - C2 implementation camera

This commit is contained in:
Eun0us 2026-01-19 13:09:09 +01:00
parent a9151d4fd2
commit b931c81a13
10 changed files with 979 additions and 4 deletions

47
tools/c2/.env.example Normal file
View File

@ -0,0 +1,47 @@
# ESPILON C2 Configuration
# Copy this file to .env and adjust values
# ===================
# C2 Server
# ===================
C2_HOST=0.0.0.0
C2_PORT=2626
# ===================
# Camera Server
# ===================
# UDP receiver for camera frames
UDP_HOST=0.0.0.0
UDP_PORT=5000
UDP_BUFFER_SIZE=65535
# Web server for viewing streams
WEB_HOST=0.0.0.0
WEB_PORT=8000
# ===================
# Security
# ===================
# Token for authenticating camera frames (must match ESP firmware)
CAMERA_SECRET_TOKEN=Sup3rS3cretT0k3n
# Flask session secret (change in production!)
FLASK_SECRET_KEY=change_this_for_prod
# Web interface credentials
WEB_USERNAME=admin
WEB_PASSWORD=admin
# ===================
# Storage
# ===================
# Directory for camera frame storage (relative to c2 root)
IMAGE_DIR=static/streams
# ===================
# Video Recording
# ===================
VIDEO_ENABLED=true
VIDEO_PATH=static/streams/record.avi
VIDEO_FPS=10
VIDEO_CODEC=MJPG

View File

@ -93,7 +93,7 @@ def main():
$$$$$$$\ $$$$$$\ $$\ $$\ $$$$$$\ $$$$$$$$\ $$$$$$\ $$$$$$\ $$$$$$\
$$$$$$$$\ $$$$$$\ $$$$$$$\ $$$$$$\ $$\ $$$$$$\ $$\ $$\ $$$$$$\ $$$$$$\
$$ _____|$$ __$$\ $$ __$$\\_$$ _|$$ | $$ __$$\ $$$\ $$ | $$ __$$\ $$ __$$\
$$ _____|$$ __$$\ $$ __$$\ \_$$ _|$$ | $$ __$$\ $$$\ $$ | $$ __$$\ $$ __$$\
$$ | $$ / \__|$$ | $$ | $$ | $$ | $$ / $$ |$$$$\ $$ | $$ / \__|\__/ $$ |
$$$$$\ \$$$$$$\ $$$$$$$ | $$ | $$ | $$ | $$ |$$ $$\$$ | $$ | $$$$$$ |
$$ __| \____$$\ $$ ____/ $$ | $$ | $$ | $$ |$$ \$$$$ | $$ | $$ ____/

View File

@ -0,0 +1,3 @@
from .server import CameraServer
__all__ = ["CameraServer"]

62
tools/c2/camera/config.py Normal file
View File

@ -0,0 +1,62 @@
"""Configuration loader for camera server module - reads from .env file."""
import os
from pathlib import Path
from dotenv import load_dotenv
# Load .env file from c2 root directory
C2_ROOT = Path(__file__).parent.parent
ENV_FILE = C2_ROOT / ".env"
if ENV_FILE.exists():
load_dotenv(ENV_FILE)
else:
# Try .env.example as fallback for development
example_env = C2_ROOT / ".env.example"
if example_env.exists():
load_dotenv(example_env)
def _get_bool(key: str, default: bool = False) -> bool:
"""Get boolean value from environment."""
val = os.getenv(key, str(default)).lower()
return val in ("true", "1", "yes", "on")
def _get_int(key: str, default: int) -> int:
"""Get integer value from environment."""
try:
return int(os.getenv(key, default))
except ValueError:
return default
# C2 Server
C2_HOST = os.getenv("C2_HOST", "0.0.0.0")
C2_PORT = _get_int("C2_PORT", 2626)
# UDP Server configuration
UDP_HOST = os.getenv("UDP_HOST", "0.0.0.0")
UDP_PORT = _get_int("UDP_PORT", 5000)
UDP_BUFFER_SIZE = _get_int("UDP_BUFFER_SIZE", 65535)
# Flask Web Server configuration
WEB_HOST = os.getenv("WEB_HOST", "0.0.0.0")
WEB_PORT = _get_int("WEB_PORT", 8000)
# Security
SECRET_TOKEN = os.getenv("CAMERA_SECRET_TOKEN", "Sup3rS3cretT0k3n").encode()
FLASK_SECRET_KEY = os.getenv("FLASK_SECRET_KEY", "change_this_for_prod")
# Credentials
DEFAULT_USERNAME = os.getenv("WEB_USERNAME", "admin")
DEFAULT_PASSWORD = os.getenv("WEB_PASSWORD", "admin")
# Storage paths
IMAGE_DIR = os.getenv("IMAGE_DIR", "static/streams")
# Video recording
VIDEO_ENABLED = _get_bool("VIDEO_ENABLED", True)
VIDEO_PATH = os.getenv("VIDEO_PATH", "static/streams/record.avi")
VIDEO_FPS = _get_int("VIDEO_FPS", 10)
VIDEO_CODEC = os.getenv("VIDEO_CODEC", "MJPG")

122
tools/c2/camera/server.py Normal file
View File

@ -0,0 +1,122 @@
"""Main camera server combining UDP receiver and web server."""
from typing import Optional, Callable
from .config import UDP_HOST, UDP_PORT, WEB_HOST, WEB_PORT, IMAGE_DIR, DEFAULT_USERNAME, DEFAULT_PASSWORD
from .udp_receiver import UDPReceiver
from .web_server import WebServer
class CameraServer:
"""
Combined camera server that manages both:
- UDP receiver for incoming camera frames from ESP devices
- Web server for viewing the camera streams
"""
def __init__(self,
udp_host: str = UDP_HOST,
udp_port: int = UDP_PORT,
web_host: str = WEB_HOST,
web_port: int = WEB_PORT,
image_dir: str = IMAGE_DIR,
username: str = DEFAULT_USERNAME,
password: str = DEFAULT_PASSWORD,
on_frame: Optional[Callable] = None):
"""
Initialize the camera server.
Args:
udp_host: Host to bind UDP receiver
udp_port: Port for UDP receiver
web_host: Host to bind web server
web_port: Port for web server
image_dir: Directory to store camera frames
username: Web interface username
password: Web interface password
on_frame: Optional callback when frame is received (camera_id, frame, addr)
"""
self.udp_receiver = UDPReceiver(
host=udp_host,
port=udp_port,
image_dir=image_dir,
on_frame=on_frame
)
self.web_server = WebServer(
host=web_host,
port=web_port,
image_dir=image_dir,
username=username,
password=password
)
@property
def is_running(self) -> bool:
"""Check if both servers are running."""
return self.udp_receiver.is_running and self.web_server.is_running
@property
def udp_running(self) -> bool:
return self.udp_receiver.is_running
@property
def web_running(self) -> bool:
return self.web_server.is_running
def start(self) -> dict:
"""
Start both UDP receiver and web server.
Returns:
dict with status of each server
"""
results = {
"udp": {"started": False, "host": self.udp_receiver.host, "port": self.udp_receiver.port},
"web": {"started": False, "host": self.web_server.host, "port": self.web_server.port}
}
if self.udp_receiver.start():
results["udp"]["started"] = True
if self.web_server.start():
results["web"]["started"] = True
results["web"]["url"] = self.web_server.get_url()
return results
def stop(self) -> dict:
"""
Stop both servers.
Returns:
dict with stop status
"""
self.udp_receiver.stop()
self.web_server.stop()
return {
"udp": {"stopped": True},
"web": {"stopped": True}
}
def get_status(self) -> dict:
"""Get status of both servers."""
return {
"udp": {
"running": self.udp_receiver.is_running,
"host": self.udp_receiver.host,
"port": self.udp_receiver.port,
**self.udp_receiver.get_stats()
},
"web": {
"running": self.web_server.is_running,
"host": self.web_server.host,
"port": self.web_server.port,
"url": self.web_server.get_url() if self.web_server.is_running else None
}
}
def get_active_cameras(self) -> list:
"""Get list of active camera IDs."""
return self.udp_receiver.active_cameras

View File

@ -0,0 +1,188 @@
"""UDP server for receiving camera frames from ESP devices."""
import os
import socket
import threading
import cv2
import numpy as np
from typing import Optional, Callable
from .config import (
UDP_HOST, UDP_PORT, UDP_BUFFER_SIZE,
SECRET_TOKEN, IMAGE_DIR,
VIDEO_ENABLED, VIDEO_PATH, VIDEO_FPS, VIDEO_CODEC
)
class UDPReceiver:
"""Receives JPEG frames via UDP from ESP camera devices."""
def __init__(self,
host: str = UDP_HOST,
port: int = UDP_PORT,
image_dir: str = IMAGE_DIR,
on_frame: Optional[Callable] = None):
self.host = host
self.port = port
self.image_dir = image_dir
self.on_frame = on_frame # Callback when frame received
self._sock: Optional[socket.socket] = None
self._thread: Optional[threading.Thread] = None
self._stop_event = threading.Event()
# Video recording
self._video_writer: Optional[cv2.VideoWriter] = None
self._video_size: Optional[tuple] = None
# Statistics
self.frames_received = 0
self.invalid_tokens = 0
self.decode_errors = 0
# Active cameras tracking
self._active_cameras: dict = {} # {camera_id: last_frame_time}
os.makedirs(self.image_dir, exist_ok=True)
@property
def is_running(self) -> bool:
return self._thread is not None and self._thread.is_alive()
@property
def active_cameras(self) -> list:
"""Returns list of active camera identifiers."""
return list(self._active_cameras.keys())
def start(self) -> bool:
"""Start the UDP receiver thread."""
if self.is_running:
return False
self._stop_event.clear()
self._thread = threading.Thread(target=self._receive_loop, daemon=True)
self._thread.start()
return True
def stop(self):
"""Stop the UDP receiver and cleanup."""
self._stop_event.set()
if self._sock:
try:
self._sock.close()
except Exception:
pass
self._sock = None
if self._video_writer is not None:
self._video_writer.release()
self._video_writer = None
# Clean up frame files
self._cleanup_frames()
self._active_cameras.clear()
self.frames_received = 0
def _cleanup_frames(self):
"""Remove all .jpg files from image directory."""
try:
for f in os.listdir(self.image_dir):
if f.endswith(".jpg"):
os.remove(os.path.join(self.image_dir, f))
except Exception:
pass
def _receive_loop(self):
"""Main UDP receive loop."""
self._sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
self._sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
self._sock.bind((self.host, self.port))
self._sock.settimeout(1.0)
while not self._stop_event.is_set():
try:
data, addr = self._sock.recvfrom(UDP_BUFFER_SIZE)
except socket.timeout:
continue
except OSError:
break
# Validate token
if not data.startswith(SECRET_TOKEN):
self.invalid_tokens += 1
continue
# Remove token prefix
frame_data = data[len(SECRET_TOKEN):]
# Decode JPEG
frame = self._decode_frame(frame_data)
if frame is None:
self.decode_errors += 1
continue
self.frames_received += 1
camera_id = f"{addr[0]}_{addr[1]}"
self._active_cameras[camera_id] = True
# Save frame
self._save_frame(camera_id, frame)
# Record video if enabled
if VIDEO_ENABLED:
self._record_frame(frame)
# Callback
if self.on_frame:
self.on_frame(camera_id, frame, addr)
# Cleanup
if self._sock:
self._sock.close()
self._sock = None
if self._video_writer:
self._video_writer.release()
self._video_writer = None
def _decode_frame(self, data: bytes) -> Optional[np.ndarray]:
"""Decode JPEG data to OpenCV frame."""
try:
npdata = np.frombuffer(data, np.uint8)
frame = cv2.imdecode(npdata, cv2.IMREAD_COLOR)
return frame
except Exception:
return None
def _save_frame(self, camera_id: str, frame: np.ndarray):
"""Save frame as JPEG file."""
try:
filepath = os.path.join(self.image_dir, f"{camera_id}.jpg")
cv2.imwrite(filepath, frame)
except Exception:
pass
def _record_frame(self, frame: np.ndarray):
"""Record frame to video file."""
if self._video_writer is None:
self._video_size = (frame.shape[1], frame.shape[0])
fourcc = cv2.VideoWriter_fourcc(*VIDEO_CODEC)
video_path = os.path.join(os.path.dirname(self.image_dir), VIDEO_PATH.split('/')[-1])
self._video_writer = cv2.VideoWriter(
video_path, fourcc, VIDEO_FPS, self._video_size
)
if self._video_writer and self._video_writer.isOpened():
self._video_writer.write(frame)
def get_stats(self) -> dict:
"""Return receiver statistics."""
return {
"running": self.is_running,
"frames_received": self.frames_received,
"invalid_tokens": self.invalid_tokens,
"decode_errors": self.decode_errors,
"active_cameras": len(self._active_cameras)
}

View File

@ -0,0 +1,158 @@
"""Flask web server for camera stream display."""
import os
import logging
import threading
from flask import Flask, render_template, send_from_directory, request, redirect, url_for, session, jsonify
from werkzeug.serving import make_server
from .config import (
WEB_HOST, WEB_PORT, FLASK_SECRET_KEY,
DEFAULT_USERNAME, DEFAULT_PASSWORD, IMAGE_DIR
)
# Disable Flask/Werkzeug request logging
logging.getLogger('werkzeug').setLevel(logging.ERROR)
class WebServer:
"""Flask-based web server for viewing camera streams."""
def __init__(self,
host: str = WEB_HOST,
port: int = WEB_PORT,
image_dir: str = IMAGE_DIR,
username: str = DEFAULT_USERNAME,
password: str = DEFAULT_PASSWORD):
self.host = host
self.port = port
self.image_dir = image_dir
self.username = username
self.password = password
self._app = self._create_app()
self._server = None
self._thread = None
@property
def is_running(self) -> bool:
return self._thread is not None and self._thread.is_alive()
def _create_app(self) -> Flask:
"""Create and configure the Flask application."""
# Get the c2 root directory for templates
c2_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
template_dir = os.path.join(c2_root, "templates")
static_dir = os.path.join(c2_root, "static")
app = Flask(__name__,
template_folder=template_dir,
static_folder=static_dir)
app.secret_key = FLASK_SECRET_KEY
# Store reference to self for route handlers
web_server = self
@app.route("/login", methods=["GET", "POST"])
def login():
error = None
if request.method == "POST":
username = request.form.get("username")
password = request.form.get("password")
if username == web_server.username and password == web_server.password:
session["logged_in"] = True
return redirect(url_for("index"))
else:
error = "Invalid credentials."
return render_template("login.html", error=error)
@app.route("/logout")
def logout():
session.pop("logged_in", None)
return redirect(url_for("login"))
@app.route("/")
def index():
if not session.get("logged_in"):
return redirect(url_for("login"))
# List available camera images
full_image_dir = os.path.join(c2_root, web_server.image_dir)
try:
image_files = sorted([
f for f in os.listdir(full_image_dir)
if f.endswith(".jpg")
])
except FileNotFoundError:
image_files = []
if not image_files:
image_files = []
return render_template("index.html", image_files=image_files)
@app.route("/streams/<filename>")
def stream_image(filename):
full_image_dir = os.path.join(c2_root, web_server.image_dir)
return send_from_directory(full_image_dir, filename)
@app.route("/api/cameras")
def api_cameras():
"""API endpoint to get list of active cameras."""
if not session.get("logged_in"):
return jsonify({"error": "Unauthorized"}), 401
full_image_dir = os.path.join(c2_root, web_server.image_dir)
try:
cameras = [
f.replace(".jpg", "")
for f in os.listdir(full_image_dir)
if f.endswith(".jpg")
]
except FileNotFoundError:
cameras = []
return jsonify({"cameras": cameras})
@app.route("/api/stats")
def api_stats():
"""API endpoint for server statistics."""
if not session.get("logged_in"):
return jsonify({"error": "Unauthorized"}), 401
full_image_dir = os.path.join(c2_root, web_server.image_dir)
try:
camera_count = len([
f for f in os.listdir(full_image_dir)
if f.endswith(".jpg")
])
except FileNotFoundError:
camera_count = 0
return jsonify({
"active_cameras": camera_count,
"server_running": True
})
return app
def start(self) -> bool:
"""Start the web server in a background thread."""
if self.is_running:
return False
self._server = make_server(self.host, self.port, self._app, threaded=True)
self._thread = threading.Thread(target=self._server.serve_forever, daemon=True)
self._thread.start()
return True
def stop(self):
"""Stop the web server."""
if self._server:
self._server.shutdown()
self._server = None
self._thread = None
def get_url(self) -> str:
"""Get the server URL."""
return f"http://{self.host}:{self.port}"

View File

@ -1,11 +1,13 @@
import readline
import os
import time
from typing import Optional
from utils.display import Display
from cli.help import HelpManager
from core.transport import Transport
from proto.c2_pb2 import Command
from camera import CameraServer
DEV_MODE = True
@ -17,7 +19,10 @@ class CLI:
self.groups = groups
self.transport = transport
self.help_manager = HelpManager(commands, DEV_MODE)
self.active_commands = {} # {request_id: {"device_id": ..., "command_name": ..., "start_time": ..., "status": "running"}}
self.active_commands = {} # {request_id: {"device_id": ..., "command_name": ..., "start_time": ..., "status": "running"}}
# Camera server instance
self.camera_server: Optional[CameraServer] = None
readline.parse_and_bind("tab: complete")
readline.set_completer(self._complete)
@ -31,7 +36,7 @@ class CLI:
options = []
if len(parts) == 1:
options = ["send", "list", "group", "help", "clear", "exit", "active_commands"]
options = ["send", "list", "group", "help", "clear", "exit", "active_commands", "camera"]
elif parts[0] == "send":
if len(parts) == 2: # Completing target (device ID, 'all', 'group')
@ -42,6 +47,10 @@ class CLI:
options = self.commands.list()
# Add more logic here if commands have arguments that can be tab-completed
elif parts[0] == "camera":
if len(parts) == 2:
options = ["start", "stop", "status"]
elif parts[0] == "group":
if len(parts) == 2: # Completing group action
options = ["add", "remove", "list", "show"]
@ -93,11 +102,15 @@ class CLI:
if action == "send":
self._handle_send(parts)
continue
if action == "active_commands":
self._handle_active_commands()
continue
if action == "camera":
self._handle_camera(parts[1:])
continue
Display.error("Unknown command")
# ================= HANDLERS =================
@ -287,3 +300,58 @@ class CLI:
cmd_info["status"],
elapsed_time
])
def _handle_camera(self, parts):
if not parts:
Display.error("Usage: camera <start|stop|status>")
return
cmd = parts[0]
if cmd == "start":
if self.camera_server and self.camera_server.is_running:
Display.system_message("Camera server is already running.")
return
self.camera_server = CameraServer()
result = self.camera_server.start()
if result["udp"]["started"]:
Display.system_message(f"UDP receiver started on {result['udp']['host']}:{result['udp']['port']}")
else:
Display.error("UDP receiver failed to start (already running?)")
if result["web"]["started"]:
Display.system_message(f"Web server started at {result['web']['url']}")
else:
Display.error("Web server failed to start (already running?)")
elif cmd == "stop":
if not self.camera_server:
Display.system_message("Camera server is not running.")
return
self.camera_server.stop()
Display.system_message("Camera server stopped.")
self.camera_server = None
elif cmd == "status":
if not self.camera_server:
Display.system_message("Camera server is not running.")
return
status = self.camera_server.get_status()
Display.system_message("Camera Server Status:")
Display.system_message(f" UDP Receiver: {'Running' if status['udp']['running'] else 'Stopped'}")
if status['udp']['running']:
Display.system_message(f" - Host: {status['udp']['host']}:{status['udp']['port']}")
Display.system_message(f" - Frames received: {status['udp']['frames_received']}")
Display.system_message(f" - Active cameras: {status['udp']['active_cameras']}")
Display.system_message(f" Web Server: {'Running' if status['web']['running'] else 'Stopped'}")
if status['web']['running']:
Display.system_message(f" - URL: {status['web']['url']}")
else:
Display.error("Invalid camera command. Use: start, stop, status")

View File

@ -0,0 +1,212 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Cameras - ESPILON</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #0d1117;
color: #c9d1d9;
min-height: 100vh;
}
header {
background: #161b22;
border-bottom: 1px solid #30363d;
padding: 12px 24px;
display: flex;
justify-content: space-between;
align-items: center;
}
.logo {
font-size: 16px;
font-weight: 600;
letter-spacing: 1px;
}
.header-right {
display: flex;
align-items: center;
gap: 16px;
}
.status {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: #8b949e;
}
.status-dot {
width: 8px;
height: 8px;
background: #3fb950;
border-radius: 50%;
}
.logout {
color: #8b949e;
text-decoration: none;
font-size: 14px;
}
.logout:hover {
color: #c9d1d9;
}
main {
padding: 24px;
}
.toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.title {
font-size: 14px;
color: #8b949e;
}
.title span {
color: #c9d1d9;
font-weight: 500;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(380px, 1fr));
gap: 16px;
}
.card {
background: #161b22;
border: 1px solid #30363d;
border-radius: 6px;
overflow: hidden;
}
.card-header {
padding: 10px 14px;
border-bottom: 1px solid #30363d;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 13px;
}
.card-header .name {
font-family: monospace;
color: #c9d1d9;
}
.card-header .badge {
font-size: 11px;
color: #3fb950;
background: #3fb95026;
padding: 2px 8px;
border-radius: 10px;
}
.card-body {
background: #010409;
min-height: 220px;
display: flex;
align-items: center;
justify-content: center;
}
.card-body img {
width: 100%;
height: auto;
display: block;
}
.empty {
text-align: center;
padding: 80px 20px;
color: #8b949e;
}
.empty h2 {
font-size: 16px;
font-weight: 500;
color: #c9d1d9;
margin-bottom: 8px;
}
.empty p {
font-size: 14px;
}
</style>
</head>
<body>
<header>
<div class="logo">ESPILON</div>
<div class="header-right">
<div class="status">
<div class="status-dot"></div>
<span id="camera-count">{{ image_files|length }}</span> camera(s)
</div>
<a href="/logout" class="logout">Logout</a>
</div>
</header>
<main>
<div class="toolbar">
<div class="title">Cameras <span>Live Feed</span></div>
</div>
{% if image_files %}
<div class="grid" id="grid">
{% for img in image_files %}
<div class="card">
<div class="card-header">
<span class="name">{{ img.replace('.jpg', '').replace('_', ':') }}</span>
<span class="badge">LIVE</span>
</div>
<div class="card-body">
<img src="/streams/{{ img }}?t=0" data-src="/streams/{{ img }}">
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="empty">
<h2>No active cameras</h2>
<p>Waiting for ESP devices to send frames on UDP port 5000</p>
</div>
{% endif %}
</main>
<script>
function refresh() {
const t = Date.now();
document.querySelectorAll('.card-body img').forEach(img => {
img.src = img.dataset.src + '?t=' + t;
});
}
async function checkCameras() {
try {
const res = await fetch('/api/cameras');
const data = await res.json();
const current = document.querySelectorAll('.card').length;
document.getElementById('camera-count').textContent = data.cameras.length;
if (data.cameras.length !== current) location.reload();
} catch (e) {}
}
setInterval(refresh, 100);
setInterval(checkCameras, 5000);
</script>
</body>
</html>

View File

@ -0,0 +1,115 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login - ESPILON</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #0d1117;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.login-box {
background: #161b22;
border: 1px solid #30363d;
border-radius: 6px;
padding: 32px;
width: 100%;
max-width: 340px;
}
.logo {
text-align: center;
margin-bottom: 24px;
color: #c9d1d9;
font-size: 20px;
font-weight: 600;
letter-spacing: 1px;
}
.error {
background: #f8514926;
border: 1px solid #f85149;
color: #f85149;
padding: 10px 12px;
border-radius: 6px;
margin-bottom: 16px;
font-size: 14px;
}
.form-group {
margin-bottom: 16px;
}
label {
display: block;
color: #c9d1d9;
font-size: 14px;
margin-bottom: 8px;
}
input[type="text"],
input[type="password"] {
width: 100%;
padding: 10px 12px;
background: #0d1117;
border: 1px solid #30363d;
border-radius: 6px;
color: #c9d1d9;
font-size: 14px;
}
input:focus {
outline: none;
border-color: #58a6ff;
box-shadow: 0 0 0 3px rgba(88, 166, 255, 0.15);
}
.btn {
width: 100%;
padding: 10px 16px;
background: #238636;
border: 1px solid #238636;
border-radius: 6px;
color: #fff;
font-size: 14px;
font-weight: 500;
cursor: pointer;
}
.btn:hover {
background: #2ea043;
}
</style>
</head>
<body>
<div class="login-box">
<div class="logo">ESPILON</div>
{% if error %}
<div class="error">{{ error }}</div>
{% endif %}
<form method="post">
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" name="username" required autofocus>
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" name="password" required>
</div>
<button type="submit" class="btn">Sign in</button>
</form>
</div>
</body>
</html>