Compare commits

..

11 Commits

Author SHA1 Message Date
glenneth fd185ed9b1 feat: Re-add password change and reset API endpoints (working version)
Added back password management APIs using handler-case (not with-error-handling):

1. /api/asteroid/user/change-password
   - Users can change their own password
   - Requires current password verification
   - Uses get-current-user() function

2. /api/asteroid/admin/reset-password
   - Admins can reset any user's password
   - No current password required
   - Returns 404 if user not found

Both APIs use handler-case for error handling to maintain consistency
with other APIs in this file. Build succeeds.

Works with password change UI in admin dashboard (admin.ctml, admin.js).
2025-11-03 20:47:11 +03:00
glenneth cb13bc9cfd fix: Revert Liquidsoap bind address to 0.0.0.0
As easilok correctly pointed out in IRC:
- Inside container: Liquidsoap must bind to 0.0.0.0 (all interfaces)
- Security is enforced by Docker port mapping: 127.0.0.1🔢1234
- This prevents external access while allowing container functionality

The docker-compose.yml mapping is what provides security, not the
bind address inside the container.

Credit: easilok for the explanation and fix approach
2025-11-03 20:44:28 +03:00
glenneth 9047414ecd revert: Revert auth-routes changes that broke compilation
Reverted to working version before password API additions.
The password APIs and UI are still in place (admin.ctml, admin.js).
Need to re-add the backend APIs more carefully.

Build now succeeds.
2025-11-03 20:40:17 +03:00
glenneth a6cc10a689 fix: Use get-current-user instead of auth:current-user
Fixed compilation error by using the correct function name.
The codebase uses get-current-user() which is defined in
user-management.lisp, not auth:current-user.

Build now completes successfully.
2025-11-03 20:28:10 +03:00
glenneth 799a614e89 fix: Use with-error-handling macro in password APIs
Changed password change and reset APIs to use with-error-handling
macro instead of handler-case for consistency with refactored codebase.

This ensures proper error handling using our custom condition system.
2025-11-03 20:23:35 +03:00
glenneth 356c6fbb49 feat: Add password change UI to admin dashboard
NEW FEATURE: Password Change Form on Admin Dashboard

TEMPLATE CHANGES (admin.ctml):
- Added 'Account Security' section after System Status
- Password change form with:
  - Current password field
  - New password field (min 8 characters)
  - Confirm password field
  - Submit button
  - Message display area for feedback

JAVASCRIPT CHANGES (admin.js):
- changeAdminPassword(event) function
  - Validates passwords match
  - Validates minimum length (8 chars)
  - Calls /api/asteroid/user/change-password
  - Shows success/error messages
  - Clears form on success

- showPasswordMessage(message, type) helper
  - Displays success/error messages
  - Auto-hides success messages after 5 seconds

USER EXPERIENCE:
1. Admin logs in with default password (asteroid123)
2. Sees 'Account Security' section at top of dashboard
3. Fills in current password and new password
4. Clicks 'Change Password'
5. Gets immediate feedback
6. Password is changed - use new password on next login

This makes it easy for admins to change the default password
without needing REPL access or curl commands.

Ref: TODO.org Problem 4 - Security improvements
2025-11-03 20:18:17 +03:00
glenneth 86eef472a9 feat: Add password change and reset API endpoints
NEW API ENDPOINTS:

1. /api/asteroid/user/change-password (authenticated users)
   - Users can change their own password
   - Requires current password verification
   - Returns 401 if current password is incorrect
   - Returns 200 on success

2. /api/asteroid/admin/reset-password (admin only)
   - Admins can reset any user's password
   - No current password required
   - Returns 404 if user not found
   - Returns 200 on success

USAGE EXAMPLES:

User changes own password:
  curl -X POST http://localhost:8080/api/asteroid/user/change-password \
    -d 'current-password=asteroid123&new-password=newsecurepass' \
    -b cookies.txt

Admin resets user password:
  curl -X POST http://localhost:8080/api/asteroid/admin/reset-password \
    -d 'username=admin&new-password=newsecurepass' \
    -b cookies.txt

This addresses the security concern about the default admin password.
Admins can now reset it via API without needing REPL access.

Ref: TODO.org Problem 4 - Default admin password
2025-11-03 20:15:45 +03:00
glenneth dbe9a06247 security: Liquidsoap now uses environment variable for Icecast password
SECURITY IMPROVEMENTS:
- Liquidsoap telnet now binds to 127.0.0.1 (was 0.0.0.0)
  - Only accessible from within the container itself
  - Defense in depth: even if another container is compromised

- Liquidsoap now uses ICECAST_SOURCE_PASSWORD environment variable
  - Reads from environment.get("ICECAST_SOURCE_PASSWORD")
  - Falls back to default for development
  - All three streams (MP3, AAC, Low-quality) use same variable

DOCUMENTATION:
- Added comment to icecast.xml explaining environment variable override
- Clarifies that docker-compose.yml environment variables take precedence

This completes the password externalization for all Docker services:
- Icecast: Uses env vars (already done)
- Liquidsoap: Now uses env vars 
- PostgreSQL: Uses env vars (already done)

Ref: TODO.org security improvements
2025-11-03 20:06:50 +03:00
glenneth ed39646ad2 docs: Update security documentation with template fix 2025-11-03 19:51:30 +03:00
glenneth 25183ea5cf security: Remove hardcoded admin credentials from login page
CRITICAL SECURITY FIX:
- Remove display of default admin username and password from login.ctml
- Login page no longer advertises 'admin' / 'asteroid123' credentials

This was the security issue Fade mentioned during b612 deployment:
'the templates with the default passwords for sure need changing'

Addresses TODO item:
- Problem 4: Templates advertising default admin password  FIXED

Ref: TODO.org line 29-30
2025-11-03 19:49:53 +03:00
glenneth ce4fced380 security: Fix Docker port bindings and externalize all passwords
CRITICAL SECURITY FIXES:
- Bind all Docker services to localhost only (127.0.0.1)
- Prevents external access to Liquidsoap telnet (port 1234)
- Prevents direct Icecast access without HAproxy (port 8000)
- Secures PostgreSQL port (5432)

DOCKER CHANGES (docker-compose.yml):
- Icecast: 127.0.0.1:8000:8000 (was 8000:8000)
- Liquidsoap: 127.0.0.1🔢1234 (was 1234:1234)
- PostgreSQL: 127.0.0.1:5432:5432 (was 5432:5432)
- All passwords now use environment variables

CONFIG TEMPLATE:
- Added ICECAST_SOURCE_PASSWORD
- Added ICECAST_RELAY_PASSWORD
- Documented all Docker password variables

Addresses TODO items from b612.asteroid.radio deployment:
- Problem 1: Liquidsoap telnet exposed  FIXED
- Problem 2: Icecast binding to 0.0.0.0  FIXED

This prevents the security issues that forced Fade to shut down
the production server. Services are now only accessible via
HAproxy on the host machine.

Ref: TODO.org lines 25-27
2025-11-03 19:47:53 +03:00
9 changed files with 218 additions and 21 deletions

View File

@ -71,23 +71,62 @@ Eliminated hardcoded Icecast admin password from codebase.
- ~*supported-formats*~~(config-supported-formats *config*)~
- ~*stream-base-url*~~(config-stream-base-url *config*)~
** Template Security Fix (~template/login.ctml~) - CRITICAL
Removed hardcoded admin credentials display from login page:
- Deleted panel showing "Default Admin Credentials"
- No longer displays username: ~admin~ / password: ~asteroid123~
- Login page is now production-safe
This was the critical issue Fade mentioned: "the templates with the default passwords for sure need changing"
** Docker Security Fixes (~docker/docker-compose.yml~) - CRITICAL
*** Port Bindings Secured
All services now bind to localhost only (127.0.0.1) instead of 0.0.0.0:
- *Icecast*: ~127.0.0.1:8000:8000~ - Use HAproxy to expose publicly
- *Liquidsoap telnet*: ~127.0.0.1:1234:1234~ - Never expose to internet
- *PostgreSQL*: ~127.0.0.1:5432:5432~ - Database access from host only
This prevents external access to:
- Liquidsoap telnet management interface (Problem 1)
- Direct Icecast access without HAproxy (Problem 2)
- PostgreSQL database port
*** Passwords Externalized
All Docker container passwords now use environment variables:
#+BEGIN_SRC yaml
environment:
- ICECAST_SOURCE_PASSWORD=${ICECAST_SOURCE_PASSWORD:-default}
- ICECAST_ADMIN_PASSWORD=${ICECAST_ADMIN_PASSWORD:-default}
- ICECAST_RELAY_PASSWORD=${ICECAST_RELAY_PASSWORD:-default}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-default}
#+END_SRC
* TODO Items Addressed
** DONE Problem 4: Templates no longer advertise default admin password
** DONE Server runtime configuration: All configuration parameterized and loaded from environment
** DONE Problem 1: Liquidsoap telnet binding secured (localhost only)
** DONE Problem 2: Icecast external binding secured (localhost only)
** TODO Problem 3: Database backend selection implemented (PostgreSQL support ready, migration needed)
* Production Deployment Issues (From b612.asteroid.radio Test)
** Critical Security (Must fix before public launch)
- [ ] *Problem 1*: Fix Liquidsoap telnet binding (currently exposed on external interface)
- [X] *Problem 1*: Fix Liquidsoap telnet binding ✅ FIXED
- Issue: ~telnet asteroid.radio 1234~ works from anywhere
- Fix: Bind to localhost only in Docker config
- Fix: Bind to ~127.0.0.1:1234:1234~ in docker-compose.yml
- [ ] *Problem 2*: Fix Icecast external binding
- [X] *Problem 2*: Fix Icecast external binding ✅ FIXED
- Issue: Icecast binding to 0.0.0.0
- Fix: Bind to localhost only, use HAproxy to proxy
- Fix: Bind to ~127.0.0.1:8000:8000~, use HAproxy to proxy
- [ ] *Problem 5*: Set up TLS/Let's Encrypt with HAproxy

View File

@ -105,3 +105,59 @@
(api-output `(("status" . "error")
("message" . ,(format nil "Error creating user: ~a" e)))
:status 500))))
;; API: Change own password (authenticated users)
(define-api asteroid/user/change-password (current-password new-password) ()
"API endpoint for users to change their own password"
(require-authentication)
(handler-case
(if (and current-password new-password)
(let* ((user (get-current-user))
(username (gethash "username" user))
(stored-hash (gethash "password-hash" user)))
;; Verify current password
(if (verify-password current-password
(if (listp stored-hash) (first stored-hash) stored-hash))
;; Current password is correct, update to new password
(if (reset-user-password username new-password)
(api-output `(("status" . "success")
("message" . "Password changed successfully")))
(api-output `(("status" . "error")
("message" . "Failed to update password"))
:status 500))
;; Current password is incorrect
(api-output `(("status" . "error")
("message" . "Current password is incorrect"))
:status 401)))
(api-output `(("status" . "error")
("message" . "Missing required fields"))
:status 400))
(error (e)
(api-output `(("status" . "error")
("message" . ,(format nil "Error changing password: ~a" e)))
:status 500))))
;; API: Reset user password (admin only)
(define-api asteroid/admin/reset-password (username new-password) ()
"API endpoint for admins to reset any user's password"
(require-role :admin)
(handler-case
(if (and username new-password)
(let ((user (find-user-by-username username)))
(if user
(if (reset-user-password username new-password)
(api-output `(("status" . "success")
("message" . ,(format nil "Password reset for user: ~a" username))))
(api-output `(("status" . "error")
("message" . "Failed to reset password"))
:status 500))
(api-output `(("status" . "error")
("message" . ,(format nil "User not found: ~a" username)))
:status 404)))
(api-output `(("status" . "error")
("message" . "Missing required fields"))
:status 400))
(error (e)
(api-output `(("status" . "error")
("message" . ,(format nil "Error resetting password: ~a" e)))
:status 500))))

View File

@ -30,6 +30,11 @@ ASTEROID_STREAM_URL=http://localhost:8000
ICECAST_ADMIN_USER=admin
ICECAST_ADMIN_PASSWORD=CHANGE_THIS_PASSWORD
# Additional Icecast passwords (used by Docker containers)
# These are for Liquidsoap source connection and relay
ICECAST_SOURCE_PASSWORD=CHANGE_THIS_PASSWORD
ICECAST_RELAY_PASSWORD=CHANGE_THIS_PASSWORD
# ============================================================================
# DATABASE CONFIGURATION
# ============================================================================

View File

@ -15,10 +15,16 @@ settings.frame.audio.channels.set(2)
settings.audio.converter.samplerate.libsamplerate.quality.set("best")
# Enable telnet server for remote control
# Bind to 0.0.0.0 inside container (Docker port mapping restricts external access)
# Security is enforced by docker-compose.yml: "127.0.0.1:1234:1234"
settings.server.telnet.set(true)
settings.server.telnet.port.set(1234)
settings.server.telnet.bind_addr.set("0.0.0.0")
# Get Icecast source password from environment variable
# Falls back to default if not set (for development)
icecast_password = environment.get("ICECAST_SOURCE_PASSWORD", default="H1tn31EhsyLrfRmo")
# Create playlist source from generated M3U file
# This file is managed by Asteroid's stream control system
# Falls back to directory scan if playlist file doesn't exist
@ -65,7 +71,7 @@ output.icecast(
%mp3(bitrate=128),
host="icecast", # Docker service name
port=8000,
password="H1tn31EhsyLrfRmo",
password=icecast_password,
mount="asteroid.mp3",
name="Asteroid Radio",
description="Music for Hackers - Streaming from the Asteroid",
@ -80,7 +86,7 @@ output.icecast(
%fdkaac(bitrate=96),
host="icecast",
port=8000,
password="H1tn31EhsyLrfRmo",
password=icecast_password,
mount="asteroid.aac",
name="Asteroid Radio (AAC)",
description="Music for Hackers - High efficiency AAC stream",
@ -95,7 +101,7 @@ output.icecast(
%mp3(bitrate=64),
host="icecast",
port=8000,
password="H1tn31EhsyLrfRmo",
password=icecast_password,
mount="asteroid-low.mp3",
name="Asteroid Radio (Low Quality)",
description="Music for Hackers - Low bandwidth stream",

View File

@ -3,13 +3,13 @@ services:
image: infiniteproject/icecast:latest
container_name: asteroid-icecast
ports:
- "8000:8000"
- "127.0.0.1:8000:8000" # Bind to localhost only - use HAproxy to expose publicly
volumes:
- ./icecast.xml:/etc/icecast.xml
environment:
- ICECAST_SOURCE_PASSWORD=H1tn31EhsyLrfRmo
- ICECAST_ADMIN_PASSWORD=asteroid_admin_2024
- ICECAST_RELAY_PASSWORD=asteroid_relay_2024
- ICECAST_SOURCE_PASSWORD=${ICECAST_SOURCE_PASSWORD:-H1tn31EhsyLrfRmo}
- ICECAST_ADMIN_PASSWORD=${ICECAST_ADMIN_PASSWORD:-asteroid_admin_2024}
- ICECAST_RELAY_PASSWORD=${ICECAST_RELAY_PASSWORD:-asteroid_relay_2024}
restart: unless-stopped
networks:
- asteroid-network
@ -20,7 +20,7 @@ services:
dockerfile: Dockerfile.liquidsoap
container_name: asteroid-liquidsoap
ports:
- "1234:1234"
- "127.0.0.1:1234:1234" # Bind telnet to localhost only - SECURITY: Never expose to internet
depends_on:
- icecast
volumes:
@ -35,11 +35,11 @@ services:
image: postgres:16-alpine
container_name: asteroid-postgres
environment:
POSTGRES_DB: asteroid
POSTGRES_USER: asteroid
POSTGRES_PASSWORD: asteroid_db_2025
POSTGRES_DB: ${POSTGRES_DB:-asteroid}
POSTGRES_USER: ${POSTGRES_USER:-asteroid}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-asteroid_db_2025}
ports:
- "5432:5432"
- "127.0.0.1:5432:5432" # Bind to localhost only
volumes:
- postgres-data:/var/lib/postgresql/data
- ./init-db.sql:/docker-entrypoint-initdb.d/init-db.sql:ro

View File

@ -14,6 +14,10 @@
</limits>
<authentication>
<!-- NOTE: These passwords are OVERRIDDEN by environment variables in docker-compose.yml
Set ICECAST_SOURCE_PASSWORD, ICECAST_ADMIN_PASSWORD, and ICECAST_RELAY_PASSWORD
in your environment or .env file for production deployments.
These defaults are only used if environment variables are not set. -->
<source-password>H1tn31EhsyLrfRmo</source-password>
<relay-password>asteroid_relay_2024</relay-password>
<admin-user>admin</admin-user>

View File

@ -658,3 +658,70 @@ async function updateLiveStreamInfo() {
}
}
}
// Password change functionality
function changeAdminPassword(event) {
event.preventDefault();
const currentPassword = document.getElementById('current-password').value;
const newPassword = document.getElementById('new-password').value;
const confirmPassword = document.getElementById('confirm-password').value;
const messageEl = document.getElementById('password-message');
// Clear previous messages
messageEl.style.display = 'none';
messageEl.className = 'message';
// Validate passwords match
if (newPassword !== confirmPassword) {
showPasswordMessage('New passwords do not match', 'error');
return;
}
// Validate password length
if (newPassword.length < 8) {
showPasswordMessage('Password must be at least 8 characters', 'error');
return;
}
// Submit password change
const formData = new URLSearchParams();
formData.append('current-password', currentPassword);
formData.append('new-password', newPassword);
fetch('/api/asteroid/user/change-password', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: formData
})
.then(response => response.json())
.then(data => {
if (data.status === 'success') {
showPasswordMessage('Password changed successfully! Please use your new password on next login.', 'success');
// Clear form
document.getElementById('change-password-form').reset();
} else {
showPasswordMessage(data.message || 'Failed to change password', 'error');
}
})
.catch(error => {
console.error('Error changing password:', error);
showPasswordMessage('Error changing password. Please try again.', 'error');
});
}
function showPasswordMessage(message, type) {
const messageEl = document.getElementById('password-message');
messageEl.textContent = message;
messageEl.className = 'message ' + type;
messageEl.style.display = 'block';
// Auto-hide success messages after 5 seconds
if (type === 'success') {
setTimeout(() => {
messageEl.style.display = 'none';
}, 5000);
}
}

View File

@ -42,6 +42,31 @@
</div>
</div>
<!-- Admin Account Security -->
<div class="admin-section">
<h2>🔒 Account Security</h2>
<div class="password-change-form">
<h3>Change Your Password</h3>
<div id="password-message" class="message" style="display: none;"></div>
<form id="change-password-form" onsubmit="changeAdminPassword(event)">
<div class="form-group">
<label for="current-password">Current Password:</label>
<input type="password" id="current-password" name="current-password" required>
</div>
<div class="form-group">
<label for="new-password">New Password:</label>
<input type="password" id="new-password" name="new-password" required minlength="8">
<small>Minimum 8 characters</small>
</div>
<div class="form-group">
<label for="confirm-password">Confirm New Password:</label>
<input type="password" id="confirm-password" name="confirm-password" required>
</div>
<button type="submit" class="btn btn-primary">Change Password</button>
</form>
</div>
</div>
<!-- Music Library Management -->
<div class="admin-section">
<h2>Music Library Management</h2>

View File

@ -37,11 +37,6 @@
<button type="submit" class="btn btn-primary" style="width: 100%;">LOGIN</button>
</div>
</form>
<div class="panel" style="margin-top: 20px; text-align: center;">
<strong style="color: #ff6600;">Default Admin Credentials:</strong><br>
Username: <br><code style="color: #00ff00;">admin</code><br>
Password: <br><code style="color: #00ff00;">asteroid123</code>
</div>
</div>
</div>
</div>