This commit is contained in:
2026-03-13 13:22:46 +00:00
parent cf7b70b21e
commit 97a9f3fd9a
7 changed files with 571 additions and 35 deletions
+56
View File
@@ -0,0 +1,56 @@
# Changelog
All notable changes to the Update Server project will be documented in this file.
## [1.2.0] - 2026-03-13
### Added
- **Admin GUI** - New web-based administration interface for managing bot versions
- Available on port 5566 (separate from API)
- Beautiful, responsive dashboard with add/edit/delete functionality
- Real-time version list with edit and delete buttons
- Success/error message feedback
- Robot emoji (🤖) favicon
- **Dual-server architecture** - API and Admin interfaces now run independently
- API Server on port 5555
- Admin Server on port 5566
- Both can be secured separately using reverse proxies
- **CORS support** - Cross-origin requests enabled for admin GUI
- Added Flask-CORS dependency
- Allows admin GUI (port 5566) to communicate with API (port 5555)
### Changed
- **Default ports updated**
- API Server: 5000 → 5555
- Admin Server: N/A → 5566 (new)
- **Docker configuration**
- Updated Dockerfile to expose both ports
- Changed CMD from gunicorn to Python for dual-threading support
- Updated docker-compose.yml with new port mappings
### Technical Details
- Refactored Flask app into two separate instances
- Implemented threading for concurrent server execution
- Added `save_versions()` function for admin API
- Embedded HTML admin interface with inline CSS and JavaScript
- Admin API endpoints: `/admin/api/add` and `/admin/api/delete`
### Documentation
- Updated README.md with new features and configuration
- Added Nginx reverse proxy examples for Authentik integration
- Documented admin GUI usage and API endpoints
### Security Notes
- Admin GUI has no built-in authentication (as designed)
- Recommended to protect admin port (5566) with reverse proxy authentication (e.g., Authentik)
- API port (5555) can remain public for bot communication
## [1.1.0] - Previous Release
- Initial release with API-only functionality
- Bot version tracking and serving
- Health check endpoint
- Logging of version check requests
+4 -4
View File
@@ -10,8 +10,8 @@ RUN pip install --no-cache-dir -r requirements.txt
COPY app.py . COPY app.py .
COPY versions.json . COPY versions.json .
# Expose port # Expose ports (API on 5000, Admin on 5001)
EXPOSE 5000 EXPOSE 5000 5001
# Run the application # Run the application with both API and Admin servers
CMD ["gunicorn", "-w", "4", "-b", "0.0.0.0:5000", "app:app"] CMD ["python", "app.py"]
+81 -15
View File
@@ -2,6 +2,13 @@
Flask-based update server that bots query to check for new versions and send modmail notifications when updates are available. Flask-based update server that bots query to check for new versions and send modmail notifications when updates are available.
## Features
- **API Server** (Port 5555) - Bots query this for version information
- **Admin GUI** (Port 5566) - Web interface to easily manage bot versions
- Simple JSON-based version storage
- Health check endpoint
## Setup ## Setup
### Local Development ### Local Development
@@ -14,32 +21,32 @@ Flask-based update server that bots query to check for new versions and send mod
```bash ```bash
python app.py python app.py
``` ```
The server will run on `http://localhost:5000` - API Server: http://localhost:5555
- Admin GUI: http://localhost:5566
### Production Deployment ### Production Deployment
#### Using Gunicorn
```bash
pip install -r requirements.txt
gunicorn -w 4 -b 0.0.0.0:5000 app:app
```
#### Using Docker #### Using Docker
```bash ```bash
docker build -t update-server . docker build -t update-server .
docker run -p 5000:5000 update-server docker run -p 5555:5555 -p 5566:5566 update-server
``` ```
#### Using Docker Compose #### Using Docker Compose
From the parent directory: From the parent directory:
```bash ```bash
docker-compose up update-server docker-compose up botupdateserver
``` ```
This exposes:
- Port 5555 for API calls (public-facing)
- Port 5566 for Admin GUI (should be protected, e.g., behind Authentik)
### Production with Nginx (HTTPS) ### Production with Nginx (HTTPS)
Configure nginx as a reverse proxy to handle HTTPS on port 443: Configure nginx as a reverse proxy:
```nginx ```nginx
# Public API endpoint
server { server {
listen 443 ssl http2; listen 443 ssl http2;
server_name updts.slfhstd.uk; server_name updts.slfhstd.uk;
@@ -48,7 +55,27 @@ server {
ssl_certificate_key /path/to/key.pem; ssl_certificate_key /path/to/key.pem;
location / { location / {
proxy_pass http://localhost:5000; proxy_pass http://localhost:5555;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
# Admin GUI with authentication (Authentik, etc.)
server {
listen 443 ssl http2;
server_name updts-admin.slfhstd.uk;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
# Protect with Authentik (or your auth system)
auth_request /auth;
location / {
proxy_pass http://localhost:5566;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@@ -59,7 +86,18 @@ server {
## Managing Versions ## Managing Versions
Edit `versions.json` to add, update, or remove bots: ### Using the Admin GUI
1. Navigate to `http://localhost:5566` (or your admin domain for production)
2. Fill in the bot details:
- **Bot Name**: The name of the bot (e.g., `TestPostsBot`)
- **Version**: The current version (e.g., `0.2`)
- **Changelog URL**: (Optional) Link to the changelog
3. Click "Save Version"
4. View all current versions in the right panel
5. Edit by clicking "Edit" on any bot, or delete with "Delete"
### Manual Edit (versions.json)
If preferred, directly edit `versions.json`:
```json ```json
{ {
@@ -74,10 +112,9 @@ Edit `versions.json` to add, update, or remove bots:
} }
``` ```
When you update the version here, all connected bots will detect the change and send modmail to their respective subreddits automatically. ## API Endpoints (Port 5555)
## API Endpoints
### Public API
- `GET /` - Server info and available endpoints - `GET /` - Server info and available endpoints
- `GET /health` - Health check - `GET /health` - Health check
- `GET /api/versions` - Get all bot versions - `GET /api/versions` - Get all bot versions
@@ -96,10 +133,39 @@ Response:
} }
``` ```
### Admin API (Port 5566)
**Note:** These endpoints should be protected. Secure with Authentik or another authentication method.
#### Add/Update Version
```bash
curl -X POST http://localhost:5566/admin/api/add \
-H "Content-Type: application/json" \
-d '{
"bot_name": "TestPostsBot",
"version": "0.3",
"changelog_url": "https://github.com/yourrepo/releases/tag/v0.3"
}'
```
#### Delete Version
```bash
curl -X POST http://localhost:5566/admin/api/delete \
-H "Content-Type: application/json" \
-d '{"bot_name": "TestPostsBot"}'
```
## Logs ## Logs
Update check requests are logged to `update_checks.log` with timestamps and bot names. Update check requests are logged to `update_checks.log` with timestamps and bot names.
## Architecture
- **API Server (Port 5555)**: Public endpoint for bots to check versions
- **Admin GUI (Port 5566)**: Web interface for managing versions
- **Single versioning source**: Both use the same `versions.json` file
- **No authentication built-in**: Use a reverse proxy (Authentik, etc.) for security
## Hosting Options ## Hosting Options
- **Heroku** - Easy deployment with free tier - **Heroku** - Easy deployment with free tier
+420 -14
View File
@@ -3,17 +3,24 @@
Update Server for Bots Update Server for Bots
Serves version information to bots checking for updates. Serves version information to bots checking for updates.
""" """
from flask import Flask, jsonify, request from flask import Flask, jsonify, request, render_template_string
from flask_cors import CORS
import json import json
import os import os
from datetime import datetime from datetime import datetime
import threading
app = Flask(__name__)
# Load version data from JSON file # Load version data from JSON file
VERSIONS_FILE = 'versions.json' VERSIONS_FILE = 'versions.json'
LOG_FILE = 'update_checks.log' LOG_FILE = 'update_checks.log'
# Create API app
api_app = Flask(__name__, static_folder=None)
CORS(api_app) # Enable CORS for cross-origin requests from admin GUI
# Create Admin app
admin_app = Flask(__name__)
def load_versions(): def load_versions():
"""Load version info from file.""" """Load version info from file."""
@@ -27,6 +34,17 @@ def load_versions():
return {} return {}
def save_versions(versions):
"""Save version info to file."""
try:
with open(VERSIONS_FILE, 'w') as f:
json.dump(versions, f, indent=2)
return True
except Exception as e:
print(f"Error saving versions.json: {e}")
return False
def log_check(bot_name): def log_check(bot_name):
"""Log update check requests.""" """Log update check requests."""
try: try:
@@ -36,7 +54,9 @@ def log_check(bot_name):
print(f"Error logging check: {e}") print(f"Error logging check: {e}")
@app.route('/api/version/<bot_name>', methods=['GET']) # ====== API ROUTES (Port 5555) ======
@api_app.route('/api/version/<bot_name>', methods=['GET'])
def get_version(bot_name): def get_version(bot_name):
"""Get latest version for a specific bot.""" """Get latest version for a specific bot."""
log_check(bot_name) log_check(bot_name)
@@ -48,19 +68,19 @@ def get_version(bot_name):
return jsonify({"error": "Bot not found"}), 404 return jsonify({"error": "Bot not found"}), 404
@app.route('/api/versions', methods=['GET']) @api_app.route('/api/versions', methods=['GET'])
def get_all_versions(): def get_all_versions():
"""Get all bot versions.""" """Get all bot versions."""
return jsonify(load_versions()) return jsonify(load_versions())
@app.route('/health', methods=['GET']) @api_app.route('/health', methods=['GET'])
def health(): def health():
"""Health check endpoint.""" """Health check endpoint."""
return jsonify({"status": "ok"}) return jsonify({"status": "ok"})
@app.route('/', methods=['GET']) @api_app.route('/', methods=['GET'])
def index(): def index():
"""Index page with server info.""" """Index page with server info."""
versions = load_versions() versions = load_versions()
@@ -77,19 +97,405 @@ def index():
}) })
@app.errorhandler(404) @api_app.errorhandler(404)
def not_found(error): def api_not_found(error):
"""Handle 404 errors.""" """Handle 404 errors."""
return jsonify({"error": "Endpoint not found"}), 404 return jsonify({"error": "Endpoint not found"}), 404
@app.errorhandler(500) @api_app.errorhandler(500)
def internal_error(error): def api_internal_error(error):
"""Handle 500 errors.""" """Handle 500 errors."""
return jsonify({"error": "Internal server error"}), 500 return jsonify({"error": "Internal server error"}), 500
# ====== ADMIN ROUTES (Port 5566) ======
ADMIN_HTML = """
<!DOCTYPE html>
<html>
<head>
<title>Update Server Admin</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='0.9em' font-size='90'>🤖</text></svg>">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 1000px;
margin: 0 auto;
}
.header {
text-align: center;
color: white;
margin-bottom: 30px;
}
.header h1 {
font-size: 2.5em;
margin-bottom: 10px;
}
.content {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
}
.card {
background: white;
border-radius: 8px;
padding: 25px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
}
.card h2 {
margin-bottom: 20px;
color: #333;
font-size: 1.5em;
border-bottom: 2px solid #667eea;
padding-bottom: 10px;
}
.form-group {
margin-bottom: 15px;
}
label {
display: block;
margin-bottom: 5px;
color: #555;
font-weight: 500;
}
input, textarea {
width: 100%;
padding: 10px;
border: 1px solid #ddd;
border-radius: 4px;
font-family: inherit;
font-size: 14px;
}
input:focus, textarea:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
textarea {
resize: vertical;
min-height: 80px;
}
button {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 10px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
font-weight: 600;
font-size: 14px;
transition: transform 0.2s;
}
button:hover {
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(102, 126, 234, 0.3);
}
.bot-list {
list-style: none;
}
.bot-item {
background: #f8f9fa;
padding: 15px;
margin-bottom: 10px;
border-radius: 4px;
border-left: 4px solid #667eea;
display: flex;
justify-content: space-between;
align-items: center;
}
.bot-info {
flex: 1;
}
.bot-name {
font-weight: 600;
color: #333;
margin-bottom: 5px;
}
.bot-version {
color: #666;
font-size: 13px;
}
.bot-actions {
display: flex;
gap: 10px;
}
.btn-small {
padding: 6px 12px;
font-size: 12px;
margin: 0;
}
.btn-danger {
background: #dc3545;
}
.btn-danger:hover {
box-shadow: 0 5px 15px rgba(220, 53, 69, 0.3);
}
.message {
padding: 12px;
border-radius: 4px;
margin-bottom: 15px;
display: none;
}
.message.success {
display: block;
background: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.message.error {
display: block;
background: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
@media (max-width: 768px) {
.content {
grid-template-columns: 1fr;
}
.header h1 {
font-size: 1.8em;
}
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🤖 Update Server Admin</h1>
<p>Manage bot versions easily</p>
</div>
<div class="content">
<!-- Add/Edit Bot Form -->
<div class="card">
<h2>Add/Edit Version</h2>
<div id="formMessage" class="message"></div>
<form id="versionForm">
<div class="form-group">
<label for="botName">Bot Name *</label>
<input type="text" id="botName" name="botName" placeholder="e.g., TestPostsBot" required>
</div>
<div class="form-group">
<label for="version">Version *</label>
<input type="text" id="version" name="version" placeholder="e.g., 0.2" required>
</div>
<div class="form-group">
<label for="changelogUrl">Changelog URL</label>
<input type="url" id="changelogUrl" name="changelogUrl" placeholder="https://example.com/releases">
</div>
<button type="submit">Save Version</button>
</form>
</div>
<!-- Bot List -->
<div class="card">
<h2>Current Versions</h2>
<ul id="botList" class="bot-list">
<li style="text-align: center; color: #999; padding: 20px;">Loading...</li>
</ul>
</div>
</div>
</div>
<script>
const API_URL = window.location.protocol + '//' + window.location.hostname + ':5555';
// Load bot versions on page load
function loadBots() {
fetch(API_URL + '/api/versions')
.then(r => r.json())
.then(data => {
const list = document.getElementById('botList');
if (Object.keys(data).length === 0) {
list.innerHTML = '<li style="text-align: center; color: #999; padding: 20px;">No bots configured yet</li>';
return;
}
list.innerHTML = Object.entries(data).map(([name, info]) => `
<li class="bot-item">
<div class="bot-info">
<div class="bot-name">${name}</div>
<div class="bot-version">Version: ${info.version}</div>
${info.changelog_url ? `<div class="bot-version">Changelog: ${info.changelog_url}</div>` : ''}
</div>
<div class="bot-actions">
<button class="btn-small" onclick="editBot('${name}')">Edit</button>
<button class="btn-small btn-danger" onclick="deleteBot('${name}')">Delete</button>
</div>
</li>
`).join('');
});
}
function editBot(name) {
fetch(API_URL + '/api/version/' + name)
.then(r => r.json())
.then(data => {
document.getElementById('botName').value = name;
document.getElementById('version').value = data.version;
document.getElementById('changelogUrl').value = data.changelog_url || '';
document.getElementById('botName').disabled = true;
document.getElementById('versionForm').offsetTop && document.getElementById('versionForm').scrollIntoView({behavior: 'smooth'});
});
}
function deleteBot(name) {
if (confirm('Are you sure you want to delete ' + name + '?')) {
fetch('/admin/api/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ bot_name: name })
})
.then(r => r.json())
.then(data => {
showMessage(data.message || 'Deleted', data.success ? 'success' : 'error');
loadBots();
})
.catch(e => showMessage('Error: ' + e, 'error'));
}
}
function showMessage(msg, type) {
const el = document.getElementById('formMessage');
el.textContent = msg;
el.className = 'message ' + type;
setTimeout(() => el.className = 'message', 5000);
}
document.getElementById('versionForm').addEventListener('submit', function(e) {
e.preventDefault();
const data = {
bot_name: document.getElementById('botName').value,
version: document.getElementById('version').value,
changelog_url: document.getElementById('changelogUrl').value
};
fetch('/admin/api/add', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
})
.then(r => r.json())
.then(data => {
showMessage(data.message || 'Saved successfully!', data.success ? 'success' : 'error');
if (data.success) {
document.getElementById('versionForm').reset();
document.getElementById('botName').disabled = false;
loadBots();
}
})
.catch(e => showMessage('Error: ' + e, 'error'));
});
// Load bots on page load
loadBots();
// Refresh every 10 seconds
setInterval(loadBots, 10000);
</script>
</body>
</html>
"""
@admin_app.route('/', methods=['GET'])
def admin_index():
"""Admin dashboard."""
return render_template_string(ADMIN_HTML)
@admin_app.route('/admin/api/add', methods=['POST'])
def admin_add_version():
"""Add or update a bot version."""
try:
data = request.get_json()
bot_name = data.get('bot_name', '').strip()
version = data.get('version', '').strip()
changelog_url = data.get('changelog_url', '').strip()
if not bot_name or not version:
return jsonify({"success": False, "message": "Bot name and version are required"}), 400
versions = load_versions()
versions[bot_name] = {
"version": version,
"changelog_url": changelog_url if changelog_url else versions.get(bot_name, {}).get('changelog_url', '')
}
if save_versions(versions):
return jsonify({"success": True, "message": f"Version saved for {bot_name}"})
else:
return jsonify({"success": False, "message": "Failed to save version"}), 500
except Exception as e:
return jsonify({"success": False, "message": f"Error: {str(e)}"}), 500
@admin_app.route('/admin/api/delete', methods=['POST'])
def admin_delete_version():
"""Delete a bot version."""
try:
data = request.get_json()
bot_name = data.get('bot_name', '').strip()
if not bot_name:
return jsonify({"success": False, "message": "Bot name is required"}), 400
versions = load_versions()
if bot_name in versions:
del versions[bot_name]
if save_versions(versions):
return jsonify({"success": True, "message": f"{bot_name} deleted"})
return jsonify({"success": False, "message": "Bot not found"}), 404
except Exception as e:
return jsonify({"success": False, "message": f"Error: {str(e)}"}), 500
@admin_app.errorhandler(404)
def admin_not_found(error):
"""Handle 404 errors."""
return jsonify({"error": "Endpoint not found"}), 404
@admin_app.errorhandler(500)
def admin_internal_error(error):
"""Handle 500 errors."""
return jsonify({"error": "Internal server error"}), 500
# ====== RUN BOTH APPS ======
def run_api():
"""Run API on port 5555."""
print("[API] Starting on port 5555...")
api_app.run(host='0.0.0.0', port=5555, debug=False)
def run_admin():
"""Run Admin on port 5566."""
print("[ADMIN] Starting on port 5566...")
admin_app.run(host='0.0.0.0', port=5566, debug=False)
if __name__ == '__main__': if __name__ == '__main__':
print("Starting Update Server...") versions_count = len(load_versions())
print(f"Loaded {len(load_versions())} bots from versions.json") print(f"[STARTUP] Loaded {versions_count} bots from versions.json")
app.run(host='0.0.0.0', port=5000, debug=False)
# Run both apps in separate threads
api_thread = threading.Thread(target=run_api, daemon=True)
admin_thread = threading.Thread(target=run_admin, daemon=True)
api_thread.start()
admin_thread.start()
# Keep main thread alive
try:
api_thread.join()
except KeyboardInterrupt:
print("\n[SHUTDOWN] Stopping servers...")
+3 -2
View File
@@ -1,9 +1,10 @@
services: services:
botupdateserver: botupdateserver:
image: slfhstd.uk/slfhstd/botupdateserver:latest image: slfhstd.uk/slfhstd/botupdateserver:dev
container_name: botupdateserver container_name: botupdateserver
ports: ports:
- "5000:5000" - "5555:5555"
- "5566:5566"
volumes: volumes:
- ./versions/versions.json:/app/versions.json - ./versions/versions.json:/app/versions.json
restart: unless-stopped restart: unless-stopped
+1
View File
@@ -1,3 +1,4 @@
Flask==2.3.0 Flask==2.3.0
Flask-CORS==4.0.0
Werkzeug==2.3.0 Werkzeug==2.3.0
gunicorn==21.2.0 gunicorn==21.2.0
+6
View File
@@ -0,0 +1,6 @@
{
"TestPostsBot": {
"version": "0.2",
"changelog_url": "https://slfhstd.uk/slfhstd/TestPostsBot/releases"
}
}