diff --git a/PRIVACY.txt b/PRIVACY.txt new file mode 100644 index 0000000..fb5bc78 --- /dev/null +++ b/PRIVACY.txt @@ -0,0 +1,425 @@ +DOCKHAND PRIVACY POLICY + +Last Updated: December 14, 2025 +Effective Date: December 14, 2025 + +================================================================================ + +1. INTRODUCTION + +This Privacy Policy describes how Finsys Jaroslaw Krochmalski ("Finsys," "we," +"us," or "our") handles data in connection with the Dockhand software +application ("Software"). This Policy applies to all users of the Software. + +Finsys is committed to protecting your privacy and ensuring transparency +about our data practices. This Policy explains that the Software operates +entirely locally on your infrastructure with no data transmitted to Finsys. + + +2. DATA CONTROLLER INFORMATION + +Finsys Jaroslaw Krochmalski +ul. Borki 6 +05-119 Jozefow +Poland + +VAT ID: PL7121835977 +REGON: 061576391 + +Email: enterprise@dockhand.pro +Website: https://dockhand.pro + +For the purpose of the General Data Protection Regulation (GDPR) and other +applicable data protection laws, Finsys is NOT the data controller for any +personal data processed through your installation of the Software. You (the +user or your organization) are the data controller for all data stored in +your Software installation. + + +3. OUR FUNDAMENTAL PRINCIPLE: LOCAL-ONLY DATA + +The Software is designed with privacy as a core principle: + +- ALL DATA STAYS LOCAL: The Software stores all data exclusively on your + infrastructure (your servers, your databases, your storage). + +- NO DATA TRANSMISSION: The Software does not transmit any data to Finsys + servers, third-party servers, or any external services. + +- NO TELEMETRY: The Software contains no telemetry, analytics, usage + tracking, crash reporting, or any other data collection mechanisms. + +- FULLY SELF-CONTAINED: The Software operates entirely within your + infrastructure without requiring any connection to Finsys systems. + +- FINSYS HAS NO ACCESS: Finsys cannot access, view, retrieve, or process + any data stored in your Software installation. + + +4. DATA PROCESSED BY THE SOFTWARE + +When you use the Software, the following types of data may be stored +LOCALLY on your infrastructure: + +4.1 User Account Data +- Usernames and email addresses +- Password hashes (never stored in plain text) +- Multi-factor authentication (MFA) secrets (Enterprise Edition) +- User profile information and avatars +- Role assignments and permissions (Enterprise Edition) + +4.2 Authentication Data +- Session tokens and cookies +- OIDC/SSO tokens and provider configurations +- LDAP/Active Directory connection settings (Enterprise Edition) +- API tokens for remote access + +4.3 Docker Environment Data +- Docker host connection details (URLs, ports, socket paths) +- Docker container information (names, IDs, configurations) +- Container logs and metrics +- Image and volume data +- Network configurations +- Compose stack definitions + +4.4 Git Integration Data +- Git repository URLs and credentials +- SSH keys and access tokens +- Deployment webhooks + +4.5 Registry Data +- Docker registry URLs and credentials +- Image pull/push history + +4.6 Activity and Audit Data +- User activity logs +- Container events and operations +- Audit trails (Enterprise Edition) + +4.7 Application Settings +- General configuration preferences +- Notification channel settings (SMTP, webhooks) +- Scheduled task configurations + +All of the above data is stored exclusively in your local database +(SQLite or PostgreSQL) and on your local filesystem. None of this data +is transmitted to or accessible by Finsys. + + +5. HOW DATA IS STORED + +5.1 Database Storage + +The Software uses either SQLite or PostgreSQL as configured by you: +- SQLite: Data stored in a local file on your server +- PostgreSQL: Data stored in your PostgreSQL database instance + +5.2 File Storage + +Certain data is stored in the local filesystem: +- Compose stack files +- Uploaded files (e.g., user avatars) +- Temporary files during operations + +5.3 Encryption + +- Passwords are hashed using secure algorithms (Argon2id) +- Sensitive credentials may be encrypted at rest depending on your + database configuration +- You are responsible for implementing disk encryption, database + encryption, and network security for your infrastructure + + +6. YOUR RESPONSIBILITIES AS DATA CONTROLLER + +Since all data is stored locally on your infrastructure, YOU are the +data controller for purposes of GDPR and other data protection laws. +As data controller, you are responsible for: + +6.1 Legal Basis for Processing +Ensuring you have a valid legal basis for processing personal data of +your users (e.g., consent, legitimate interest, contractual necessity). + +6.2 Data Subject Rights +Responding to data subject requests including: +- Right of access (Article 15 GDPR) +- Right to rectification (Article 16 GDPR) +- Right to erasure (Article 17 GDPR) +- Right to restriction of processing (Article 18 GDPR) +- Right to data portability (Article 20 GDPR) +- Right to object (Article 21 GDPR) + +6.3 Security Measures +Implementing appropriate technical and organizational measures to +protect personal data, including: +- Access controls and authentication +- Encryption of data at rest and in transit +- Regular security updates and patches +- Backup and disaster recovery procedures +- Network security (firewalls, VPNs, etc.) + +6.4 Data Retention +Establishing and implementing appropriate data retention policies. + +6.5 Breach Notification +Notifying supervisory authorities and affected individuals in case +of a personal data breach, as required by applicable law. + +6.6 Privacy Notices +Providing appropriate privacy notices to your users regarding how +their data is processed within the Software. + + +7. DATA WE DO NOT COLLECT + +To be absolutely clear, Finsys does NOT collect, receive, access, or +process ANY of the following: + +- Your identity or contact information (unless you contact us directly) +- Your Docker infrastructure information +- Your container configurations or data +- Your user accounts or credentials +- Your activity logs or audit trails +- Your git repositories or deployment data +- Usage statistics or analytics +- Error reports or crash data +- Any telemetry or diagnostic data +- Any data whatsoever from your Software installation + + +8. WHEN FINSYS MAY RECEIVE DATA + +The only circumstances in which Finsys may receive data from you are: + +8.1 Direct Communication +When you voluntarily contact us via email (enterprise@dockhand.pro), +we receive and process the information you provide (name, email address, +message content). This data is processed for the purpose of responding +to your inquiry based on our legitimate interest in providing customer +support. + +8.2 License Purchase + +When you purchase an Enterprise Edition license, we collect and process: + +Data Collected: +- Name and/or company name +- Email address +- Billing address +- Payment information (processed by payment provider) +- Licensed hostname/identifier + +Legal Basis (GDPR Article 6): +- Contract performance (Art. 6(1)(b)) - to fulfill the license agreement +- Legal obligation (Art. 6(1)(c)) - for invoicing and tax records + +How We Use This Data: +- To issue and deliver your License Key +- To send license renewal reminders +- To provide support related to your license +- To comply with tax and accounting obligations + +Data Retention: +- License and invoice records: 7 years (Polish tax law requirement) +- Email correspondence: 3 years after last contact + +Data Sharing: +- Payment processor (for payment transactions only) +- No other third parties +- No marketing or advertising use + +8.3 Website Visits +If you visit our website (https://dockhand.pro), standard web server +logs may be collected. See our website privacy policy for details. + + +9. LICENSE KEY DATA + +Enterprise Edition License Keys contain: +- Customer name (as registered) +- Licensed hostname or identifier +- Expiration date +- Cryptographic signature + +This information is embedded in the License Key itself and stored +locally in your Software installation. Finsys retains a record of +issued licenses for license management purposes. + + +10. INTERNATIONAL DATA TRANSFERS + +Since all Software data is stored locally on your infrastructure, no +international data transfers occur through the Software itself. + +If your infrastructure is located outside the European Economic Area +(EEA), you are responsible for ensuring appropriate safeguards for +any personal data stored therein. + + +11. DATA RETENTION + +11.1 Software Data +You control the retention of all data in your Software installation. +The Software does not automatically delete data unless you configure +retention policies or manually delete data. + +11.2 Communication Data +If you contact us directly, we retain correspondence for as long as +necessary to respond to your inquiry and for our records, typically +not exceeding 3 years unless required for legal purposes. + +11.3 License Records +We retain license purchase and activation records for the duration +required by tax and accounting regulations (typically 5-7 years). + + +12. CHILDREN'S PRIVACY + +The Software is not intended for use by children under 16 years of age. +We do not knowingly collect personal data from children. If you are a +parent or guardian and believe your child has provided personal data +to us through direct communication, please contact us. + + +13. THIRD-PARTY SERVICES + +13.1 Software Integrations + +The Software may connect to third-party services as configured by you: +- Docker registries +- Git repositories (GitHub, GitLab, etc.) +- OIDC/SSO providers +- LDAP/Active Directory servers +- Notification services (SMTP, Discord, Slack, etc.) + +These connections are initiated by you, configured by you, and occur +between your infrastructure and these third-party services. Finsys is +not involved in these connections and has no access to the data +exchanged. The privacy policies of these third-party services apply +to your use of them. + +13.2 No Hidden Third-Party Data Sharing + +The Software does not share any data with third parties on our behalf. +There are no embedded analytics services, advertising networks, or +data brokers within the Software. + + +14. SECURITY + +14.1 Software Security + +We implement security measures in the Software design: +- Secure password hashing (Argon2id) +- Session management with secure tokens +- Input validation and sanitization +- Protection against common web vulnerabilities + +14.2 Your Security Responsibilities + +Since all data is stored on your infrastructure, you are responsible +for: +- Keeping the Software updated +- Securing your server and database +- Implementing network security measures +- Managing user access and authentication +- Creating and securing backups + + +15. CHANGES TO THIS PRIVACY POLICY + +We may update this Privacy Policy from time to time. Material changes +will be communicated through: +- Updated "Last Updated" date at the top of this Policy +- Notice on our website +- Notice within the Software (for significant changes) + +We encourage you to review this Privacy Policy periodically. + + +16. GDPR COMPLIANCE + +Finsys complies with the General Data Protection Regulation (EU) 2016/679. + +Summary of Our Data Processing: +- We only collect personal data (email, name) when you purchase a license +- Legal basis: Contract performance and legal obligation +- Data is stored securely in the EU (Poland) +- Retention: 7 years for tax records, 3 years for correspondence +- No automated decision-making or profiling +- No data sold or shared for marketing purposes + +Your GDPR Rights (Articles 15-22): +You have the right to access, rectify, erase, restrict processing, +data portability, and object to processing of your personal data. + +To exercise any of these rights, contact: enterprise@dockhand.pro +We will respond within 30 days as required by GDPR. + + +17. YOUR RIGHTS + +If you are located in the European Economic Area (EEA), United Kingdom, +or other jurisdiction with data protection laws, you have rights +regarding personal data we hold about you (from direct communications +or license purchases): + +- Access: Request access to personal data we hold about you +- Rectification: Request correction of inaccurate data +- Erasure: Request deletion of your data +- Restriction: Request restriction of processing +- Portability: Request a copy of your data in portable format +- Objection: Object to processing based on legitimate interests +- Complaint: Lodge a complaint with a supervisory authority + +To exercise these rights, contact us at enterprise@dockhand.pro. + +Note: These rights apply to data WE hold (from direct communication or +license purchases), not to data in YOUR Software installation. For data +in your installation, YOU are the data controller and responsible for +handling such requests from your users. + + +18. SUPERVISORY AUTHORITY + +If you are located in Poland, the relevant supervisory authority is: + +Urzad Ochrony Danych Osobowych (UODO) +ul. Stawki 2 +00-193 Warszawa +Poland +https://uodo.gov.pl + +If you are located in another EEA country, you may contact your local +data protection authority. + + +19. CONTACT US + +For any privacy-related questions, concerns, or requests: + +Finsys Jaroslaw Krochmalski +ul. Borki 6 +05-119 Jozefow +Poland + +Email: enterprise@dockhand.pro +Website: https://dockhand.pro + + +================================================================================ +SUMMARY + +Dockhand is a privacy-respecting application: +- All data stays on YOUR infrastructure +- NO data is sent to Finsys servers +- NO telemetry or analytics +- YOU are the data controller for your installation +- Finsys has NO access to your data + +We believe privacy is a fundamental right, and we have designed Dockhand +to respect that right by ensuring you maintain complete control over your +data at all times. +================================================================================ + +Copyright (c) 2025-2026 Finsys Jaroslaw Krochmalski. All rights reserved. diff --git a/scripts/build-subprocesses.ts b/scripts/build-subprocesses.ts new file mode 100644 index 0000000..35958e5 --- /dev/null +++ b/scripts/build-subprocesses.ts @@ -0,0 +1,31 @@ +/** + * Build subprocess scripts as standalone bundles for production. + * + * Subprocesses run via Bun.spawn and need all dependencies bundled + * since they can't access the SvelteKit build output's chunked modules. + */ + +const subprocesses = ['metrics-subprocess', 'event-subprocess']; + +console.log('[build-subprocesses] Bundling subprocess scripts...'); + +for (const name of subprocesses) { + const result = await Bun.build({ + entrypoints: [`./src/lib/server/subprocesses/${name}.ts`], + outdir: './build/subprocesses', + target: 'bun', + minify: false + }); + + if (!result.success) { + console.error(`[build-subprocesses] Failed to bundle ${name}:`); + for (const log of result.logs) { + console.error(log); + } + process.exit(1); + } + + console.log(`[build-subprocesses] Bundled ${name}.js`); +} + +console.log('[build-subprocesses] Done'); diff --git a/scripts/emergency/backup-db.sh b/scripts/emergency/backup-db.sh new file mode 100755 index 0000000..bde5e45 --- /dev/null +++ b/scripts/emergency/backup-db.sh @@ -0,0 +1,20 @@ +#!/bin/sh +# +# Emergency script to backup the database +# Automatically detects database type (SQLite or PostgreSQL) +# +# Usage: +# docker exec -it dockhand /app/scripts/emergency/backup-db.sh [output_dir] +# +# Example: +# docker exec -it dockhand /app/scripts/emergency/backup-db.sh /app/data/backups +# + +SCRIPT_DIR="$(dirname "$0")" + +# Detect database type +if [ -n "$DATABASE_URL" ] && (echo "$DATABASE_URL" | grep -qE '^postgres(ql)?://'); then + exec "$SCRIPT_DIR/postgres/backup-db.sh" "$@" +else + exec "$SCRIPT_DIR/sqlite/backup-db.sh" "$@" +fi diff --git a/scripts/emergency/clear-sessions.sh b/scripts/emergency/clear-sessions.sh new file mode 100755 index 0000000..efe7e21 --- /dev/null +++ b/scripts/emergency/clear-sessions.sh @@ -0,0 +1,17 @@ +#!/bin/sh +# +# Emergency script to clear all user sessions +# Automatically detects database type (SQLite or PostgreSQL) +# +# Usage: +# docker exec -it dockhand /app/scripts/emergency/clear-sessions.sh +# + +SCRIPT_DIR="$(dirname "$0")" + +# Detect database type +if [ -n "$DATABASE_URL" ] && (echo "$DATABASE_URL" | grep -qE '^postgres(ql)?://'); then + exec "$SCRIPT_DIR/postgres/clear-sessions.sh" "$@" +else + exec "$SCRIPT_DIR/sqlite/clear-sessions.sh" "$@" +fi diff --git a/scripts/emergency/create-admin.sh b/scripts/emergency/create-admin.sh new file mode 100755 index 0000000..35b94fc --- /dev/null +++ b/scripts/emergency/create-admin.sh @@ -0,0 +1,20 @@ +#!/bin/sh +# +# Emergency script to create an admin user +# Automatically detects database type (SQLite or PostgreSQL) +# +# Usage: +# docker exec -it dockhand /app/scripts/emergency/create-admin.sh +# +# Default credentials: admin / admin123 +# CHANGE THE PASSWORD IMMEDIATELY after logging in! +# + +SCRIPT_DIR="$(dirname "$0")" + +# Detect database type +if [ -n "$DATABASE_URL" ] && (echo "$DATABASE_URL" | grep -qE '^postgres(ql)?://'); then + exec "$SCRIPT_DIR/postgres/create-admin.sh" "$@" +else + exec "$SCRIPT_DIR/sqlite/create-admin.sh" "$@" +fi diff --git a/scripts/emergency/disable-auth.sh b/scripts/emergency/disable-auth.sh new file mode 100755 index 0000000..c9d25a2 --- /dev/null +++ b/scripts/emergency/disable-auth.sh @@ -0,0 +1,17 @@ +#!/bin/sh +# +# Emergency script to disable authentication +# Automatically detects database type (SQLite or PostgreSQL) +# +# Usage: +# docker exec -it dockhand /app/scripts/emergency/disable-auth.sh +# + +SCRIPT_DIR="$(dirname "$0")" + +# Detect database type +if [ -n "$DATABASE_URL" ] && (echo "$DATABASE_URL" | grep -qE '^postgres(ql)?://'); then + exec "$SCRIPT_DIR/postgres/disable-auth.sh" "$@" +else + exec "$SCRIPT_DIR/sqlite/disable-auth.sh" "$@" +fi diff --git a/scripts/emergency/export-stacks.sh b/scripts/emergency/export-stacks.sh new file mode 100755 index 0000000..04c3095 --- /dev/null +++ b/scripts/emergency/export-stacks.sh @@ -0,0 +1,94 @@ +#!/bin/sh +# +# Emergency script to export all compose stacks +# Exports docker-compose.yml files from the stacks directory +# +# Usage: +# docker exec -it dockhand /app/scripts/export-stacks.sh [output_dir] +# +# Example: +# docker exec -it dockhand /app/scripts/export-stacks.sh /tmp/stacks-backup +# +# Default output: /app/data/stacks-export +# + +set -e + +echo "========================================" +echo " Dockhand - Export Compose Stacks" +echo "========================================" +echo "" + +# Default paths +STACKS_DIR="${DOCKHAND_STACKS:-/home/dockhand/.dockhand/stacks}" +OUTPUT_DIR="${1:-/app/data/stacks-export}" + +# Check if running locally (not in Docker) +if [ ! -d "$STACKS_DIR" ] && [ -d "$HOME/.dockhand/stacks" ]; then + STACKS_DIR="$HOME/.dockhand/stacks" +fi + +if [ ! -d "$STACKS_DIR" ]; then + echo "Error: Stacks directory not found at $STACKS_DIR" + exit 1 +fi + +# Count stacks +STACK_COUNT=$(find "$STACKS_DIR" -maxdepth 1 -type d ! -path "$STACKS_DIR" 2>/dev/null | wc -l | tr -d ' ') + +echo "This script will export all compose stacks." +echo "" +echo "Stacks directory: $STACKS_DIR" +echo "Output directory: $OUTPUT_DIR" +echo "Stacks found: $STACK_COUNT" +echo "" + +if [ "$STACK_COUNT" -eq "0" ]; then + echo "No stacks found to export." + exit 0 +fi + +printf "Continue? [y/N]: " +read CONFIRM + +case "$CONFIRM" in + [yY]|[yY][eE][sS]) + ;; + *) + echo "Aborted." + exit 0 + ;; +esac + +echo "" + +# Create output directory +mkdir -p "$OUTPUT_DIR" + +echo "Exporting stacks..." +echo "" + +# Export each stack +find "$STACKS_DIR" -maxdepth 1 -type d ! -path "$STACKS_DIR" | while read stack_dir; do + STACK_NAME=$(basename "$stack_dir") + COMPOSE_FILE="$stack_dir/docker-compose.yml" + + if [ -f "$COMPOSE_FILE" ]; then + mkdir -p "$OUTPUT_DIR/$STACK_NAME" + cp "$COMPOSE_FILE" "$OUTPUT_DIR/$STACK_NAME/" + + # Also copy .env file if exists + if [ -f "$stack_dir/.env" ]; then + cp "$stack_dir/.env" "$OUTPUT_DIR/$STACK_NAME/" + fi + + echo " Exported: $STACK_NAME" + fi +done + +echo "" +echo "Export complete!" +echo "Stacks exported to: $OUTPUT_DIR" +echo "" +echo "To copy from Docker container to host:" +echo " docker cp dockhand:$OUTPUT_DIR ./stacks-backup" diff --git a/scripts/emergency/list-users.sh b/scripts/emergency/list-users.sh new file mode 100755 index 0000000..b68e901 --- /dev/null +++ b/scripts/emergency/list-users.sh @@ -0,0 +1,17 @@ +#!/bin/sh +# +# Emergency script to list all users +# Automatically detects database type (SQLite or PostgreSQL) +# +# Usage: +# docker exec -it dockhand /app/scripts/emergency/list-users.sh +# + +SCRIPT_DIR="$(dirname "$0")" + +# Detect database type +if [ -n "$DATABASE_URL" ] && (echo "$DATABASE_URL" | grep -qE '^postgres(ql)?://'); then + exec "$SCRIPT_DIR/postgres/list-users.sh" "$@" +else + exec "$SCRIPT_DIR/sqlite/list-users.sh" "$@" +fi diff --git a/scripts/emergency/postgres/backup-db.sh b/scripts/emergency/postgres/backup-db.sh new file mode 100755 index 0000000..ccc490f --- /dev/null +++ b/scripts/emergency/postgres/backup-db.sh @@ -0,0 +1,101 @@ +#!/bin/sh +# +# PostgreSQL: Emergency script to backup the database +# Creates a timestamped dump of the database +# +# Usage: +# docker exec -it dockhand /app/scripts/emergency/postgres/backup-db.sh [output_dir] +# +# Example: +# docker exec -it dockhand /app/scripts/emergency/postgres/backup-db.sh /app/data/backups +# +# Default output: /app/data +# +# Requires: DATABASE_URL environment variable +# + +set -e + +echo "========================================" +echo " Dockhand - Backup Database (PostgreSQL)" +echo "========================================" +echo "" + +# Check DATABASE_URL +if [ -z "$DATABASE_URL" ]; then + echo "Error: DATABASE_URL environment variable not set" + echo "" + echo "Example: DATABASE_URL=postgres://user:pass@host:5432/dockhand" + exit 1 +fi + +OUTPUT_DIR="${1:-/app/data}" + +# Parse DATABASE_URL +# Format: postgres://user:password@host:port/database +DB_URL="$DATABASE_URL" +DB_URL="${DB_URL#postgres://}" +DB_URL="${DB_URL#postgresql://}" + +# Extract credentials +DB_USER="${DB_URL%%:*}" +DB_URL="${DB_URL#*:}" +DB_PASS="${DB_URL%%@*}" +DB_URL="${DB_URL#*@}" +DB_HOST="${DB_URL%%:*}" +DB_URL="${DB_URL#*:}" +DB_PORT="${DB_URL%%/*}" +DB_NAME="${DB_URL#*/}" +DB_NAME="${DB_NAME%%\?*}" + +# Generate backup filename with timestamp +TIMESTAMP=$(date +%Y%m%d_%H%M%S) +BACKUP_FILE="$OUTPUT_DIR/dockhand_backup_$TIMESTAMP.sql" + +echo "This script will create a backup of the database." +echo "" +echo "Host: $DB_HOST:$DB_PORT" +echo "Database: $DB_NAME" +echo "Backup: $BACKUP_FILE" +echo "" +printf "Continue? [y/N]: " +read CONFIRM + +case "$CONFIRM" in + [yY]|[yY][eE][sS]) + ;; + *) + echo "Aborted." + exit 0 + ;; +esac + +echo "" + +# Create output directory if needed +mkdir -p "$OUTPUT_DIR" + +echo "Creating database backup..." + +# Use pg_dump to create backup +export PGPASSWORD="$DB_PASS" +if command -v pg_dump >/dev/null 2>&1; then + pg_dump -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -F p -f "$BACKUP_FILE" +else + echo "Error: pg_dump not found" + echo "Install PostgreSQL client tools to use this script" + exit 1 +fi + +if [ $? -eq 0 ] && [ -f "$BACKUP_FILE" ]; then + SIZE=$(ls -lh "$BACKUP_FILE" | awk '{print $5}') + echo "" + echo "Backup created successfully!" + echo "Size: $SIZE" + echo "" + echo "To copy from Docker container to host:" + echo " docker cp dockhand:$BACKUP_FILE ./dockhand_backup_$TIMESTAMP.sql" +else + echo "Error: Failed to create backup" + exit 1 +fi diff --git a/scripts/emergency/postgres/clear-sessions.sh b/scripts/emergency/postgres/clear-sessions.sh new file mode 100755 index 0000000..3621bc4 --- /dev/null +++ b/scripts/emergency/postgres/clear-sessions.sh @@ -0,0 +1,75 @@ +#!/bin/sh +# +# PostgreSQL: Emergency script to clear all user sessions +# Use this to force all users to re-login +# +# Usage: +# docker exec -it dockhand /app/scripts/emergency/postgres/clear-sessions.sh +# +# Requires: DATABASE_URL environment variable +# + +set -e + +echo "========================================" +echo " Dockhand - Clear All Sessions (PostgreSQL)" +echo "========================================" +echo "" +echo "This script will clear all user sessions," +echo "forcing all users to log in again." +echo "" + +# Check DATABASE_URL +if [ -z "$DATABASE_URL" ]; then + echo "Error: DATABASE_URL environment variable not set" + echo "" + echo "Example: DATABASE_URL=postgres://user:pass@host:5432/dockhand" + exit 1 +fi + +# Parse DATABASE_URL +DB_URL="$DATABASE_URL" +DB_URL="${DB_URL#postgres://}" +DB_URL="${DB_URL#postgresql://}" + +DB_USER="${DB_URL%%:*}" +DB_URL="${DB_URL#*:}" +DB_PASS="${DB_URL%%@*}" +DB_URL="${DB_URL#*@}" +DB_HOST="${DB_URL%%:*}" +DB_URL="${DB_URL#*:}" +DB_PORT="${DB_URL%%/*}" +DB_NAME="${DB_URL#*/}" +DB_NAME="${DB_NAME%%\?*}" + +export PGPASSWORD="$DB_PASS" + +COUNT=$(psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -t -c "SELECT COUNT(*) FROM sessions;" 2>/dev/null | tr -d ' ') + +echo "Database: $DB_HOST:$DB_PORT/$DB_NAME" +echo "Active sessions: $COUNT" +echo "" +printf "Continue? [y/N]: " +read CONFIRM + +case "$CONFIRM" in + [yY]|[yY][eE][sS]) + ;; + *) + echo "Aborted." + exit 0 + ;; +esac + +echo "" +echo "Clearing all user sessions..." +psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -c "DELETE FROM sessions;" + +if [ $? -eq 0 ]; then + echo "" + echo "Cleared $COUNT session(s) successfully." + echo "All users will need to log in again." +else + echo "Error: Failed to clear sessions" + exit 1 +fi diff --git a/scripts/emergency/postgres/create-admin.sh b/scripts/emergency/postgres/create-admin.sh new file mode 100755 index 0000000..528a534 --- /dev/null +++ b/scripts/emergency/postgres/create-admin.sh @@ -0,0 +1,117 @@ +#!/bin/sh +# +# PostgreSQL: Emergency script to create an admin user +# Use this if you're locked out of Dockhand and need to create a new admin +# +# Usage: +# docker exec -it dockhand /app/scripts/emergency/postgres/create-admin.sh +# +# Default credentials: admin / admin123 +# CHANGE THE PASSWORD IMMEDIATELY after logging in! +# +# Requires: DATABASE_URL environment variable +# + +set -e + +echo "========================================" +echo " Dockhand - Create Admin User (PostgreSQL)" +echo "========================================" +echo "" +echo "This script will create an admin user with:" +echo " Username: admin" +echo " Password: admin123" +echo "" +echo "If user 'admin' already exists, password will" +echo "be reset and admin privileges restored." +echo "" + +# Check DATABASE_URL +if [ -z "$DATABASE_URL" ]; then + echo "Error: DATABASE_URL environment variable not set" + echo "" + echo "Example: DATABASE_URL=postgres://user:pass@host:5432/dockhand" + exit 1 +fi + +# Parse DATABASE_URL +DB_URL="$DATABASE_URL" +DB_URL="${DB_URL#postgres://}" +DB_URL="${DB_URL#postgresql://}" + +DB_USER="${DB_URL%%:*}" +DB_URL="${DB_URL#*:}" +DB_PASS="${DB_URL%%@*}" +DB_URL="${DB_URL#*@}" +DB_HOST="${DB_URL%%:*}" +DB_URL="${DB_URL#*:}" +DB_PORT="${DB_URL%%/*}" +DB_NAME="${DB_URL#*/}" +DB_NAME="${DB_NAME%%\?*}" + +export PGPASSWORD="$DB_PASS" + +echo "Database: $DB_HOST:$DB_PORT/$DB_NAME" +echo "" +printf "Continue? [y/N]: " +read CONFIRM + +case "$CONFIRM" in + [yY]|[yY][eE][sS]) + ;; + *) + echo "Aborted." + exit 0 + ;; +esac + +# Username and password +USERNAME="admin" +# Password: admin123 +# This is an argon2id hash of "admin123" - generated with default argon2 settings +PASSWORD_HASH='$argon2id$v=19$m=65536,t=3,p=4$Jq4am2SfyYKmc0PAHe+yzg$cq/27vK/Qg2eZb/jMDy0ExLDhOG+58cKAximxpG5Dss' + +echo "" +echo "Creating admin user..." + +# Check if admin user already exists +EXISTING=$(psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -t -c "SELECT COUNT(*) FROM users WHERE username='$USERNAME';" 2>/dev/null | tr -d ' ') + +if [ "$EXISTING" -gt "0" ]; then + echo "User '$USERNAME' already exists." + echo "Resetting password and ensuring active status..." + psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -c "UPDATE users SET password_hash='$PASSWORD_HASH', is_active=true WHERE username='$USERNAME';" + USER_ID=$(psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -t -c "SELECT id FROM users WHERE username='$USERNAME';" 2>/dev/null | tr -d ' ') +else + echo "Creating new admin user..." + psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -c "INSERT INTO users (username, password_hash, is_active, auth_provider, created_at, updated_at) VALUES ('$USERNAME', '$PASSWORD_HASH', true, 'local', NOW(), NOW());" + USER_ID=$(psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -t -c "SELECT id FROM users WHERE username='$USERNAME';" 2>/dev/null | tr -d ' ') + echo "Admin user created successfully." +fi + +# Get the Admin role ID (it's a system role) +ADMIN_ROLE_ID=$(psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -t -c "SELECT id FROM roles WHERE name='Admin';" 2>/dev/null | tr -d ' ') + +if [ -z "$ADMIN_ROLE_ID" ]; then + echo "Warning: Admin role not found in database." + echo "The user was created but may not have admin privileges." + echo "Please check Settings > Auth > Roles after logging in." +else + # Check if user already has Admin role + HAS_ROLE=$(psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -t -c "SELECT COUNT(*) FROM user_roles WHERE user_id=$USER_ID AND role_id=$ADMIN_ROLE_ID;" 2>/dev/null | tr -d ' ') + + if [ "$HAS_ROLE" -eq "0" ]; then + echo "Assigning Admin role..." + psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -c "INSERT INTO user_roles (user_id, role_id, created_at) VALUES ($USER_ID, $ADMIN_ROLE_ID, NOW());" + echo "Admin role assigned." + else + echo "User already has Admin role." + fi +fi + +echo "" +echo "Credentials:" +echo " Username: admin" +echo " Password: admin123" +echo "" +echo "WARNING: Change the password immediately after logging in!" diff --git a/scripts/emergency/postgres/disable-auth.sh b/scripts/emergency/postgres/disable-auth.sh new file mode 100755 index 0000000..bf3f562 --- /dev/null +++ b/scripts/emergency/postgres/disable-auth.sh @@ -0,0 +1,74 @@ +#!/bin/sh +# +# PostgreSQL: Emergency script to disable authentication +# Use this if you're locked out of Dockhand +# +# Usage: +# docker exec -it dockhand /app/scripts/emergency/postgres/disable-auth.sh +# +# Requires: DATABASE_URL environment variable +# + +set -e + +echo "========================================" +echo " Dockhand - Disable Authentication (PostgreSQL)" +echo "========================================" +echo "" +echo "This script will disable authentication," +echo "allowing access to Dockhand without login." +echo "" + +# Check DATABASE_URL +if [ -z "$DATABASE_URL" ]; then + echo "Error: DATABASE_URL environment variable not set" + echo "" + echo "Example: DATABASE_URL=postgres://user:pass@host:5432/dockhand" + exit 1 +fi + +# Parse DATABASE_URL +DB_URL="$DATABASE_URL" +DB_URL="${DB_URL#postgres://}" +DB_URL="${DB_URL#postgresql://}" + +DB_USER="${DB_URL%%:*}" +DB_URL="${DB_URL#*:}" +DB_PASS="${DB_URL%%@*}" +DB_URL="${DB_URL#*@}" +DB_HOST="${DB_URL%%:*}" +DB_URL="${DB_URL#*:}" +DB_PORT="${DB_URL%%/*}" +DB_NAME="${DB_URL#*/}" +DB_NAME="${DB_NAME%%\?*}" + +export PGPASSWORD="$DB_PASS" + +echo "Database: $DB_HOST:$DB_PORT/$DB_NAME" +echo "" +printf "Continue? [y/N]: " +read CONFIRM + +case "$CONFIRM" in + [yY]|[yY][eE][sS]) + ;; + *) + echo "Aborted." + exit 0 + ;; +esac + +echo "" +echo "Disabling authentication..." +psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -c "UPDATE auth_settings SET auth_enabled = false WHERE id = 1;" + +if [ $? -eq 0 ]; then + echo "" + echo "Authentication disabled successfully." + echo "You can now access Dockhand without logging in." + echo "" + echo "Remember to re-enable authentication in Settings after regaining access." +else + echo "Error: Failed to disable authentication" + exit 1 +fi diff --git a/scripts/emergency/postgres/list-users.sh b/scripts/emergency/postgres/list-users.sh new file mode 100755 index 0000000..9ec1d1f --- /dev/null +++ b/scripts/emergency/postgres/list-users.sh @@ -0,0 +1,94 @@ +#!/bin/sh +# +# PostgreSQL: Emergency script to list all users +# Shows username, admin status, active status, and last login +# +# Usage: +# docker exec -it dockhand /app/scripts/emergency/postgres/list-users.sh +# +# Requires: DATABASE_URL environment variable +# + +set -e + +echo "========================================" +echo " Dockhand - List Users (PostgreSQL)" +echo "========================================" +echo "" + +# Check DATABASE_URL +if [ -z "$DATABASE_URL" ]; then + echo "Error: DATABASE_URL environment variable not set" + echo "" + echo "Example: DATABASE_URL=postgres://user:pass@host:5432/dockhand" + exit 1 +fi + +# Parse DATABASE_URL +DB_URL="$DATABASE_URL" +DB_URL="${DB_URL#postgres://}" +DB_URL="${DB_URL#postgresql://}" + +DB_USER="${DB_URL%%:*}" +DB_URL="${DB_URL#*:}" +DB_PASS="${DB_URL%%@*}" +DB_URL="${DB_URL#*@}" +DB_HOST="${DB_URL%%:*}" +DB_URL="${DB_URL#*:}" +DB_PORT="${DB_URL%%/*}" +DB_NAME="${DB_URL#*/}" +DB_NAME="${DB_NAME%%\?*}" + +export PGPASSWORD="$DB_PASS" + +# Get user count +USER_COUNT=$(psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -t -c "SELECT COUNT(*) FROM users;" 2>/dev/null | tr -d ' ') + +if [ "$USER_COUNT" -eq "0" ]; then + echo "No users found." + exit 0 +fi + +# Get Admin role ID for checking admin status +ADMIN_ROLE_ID=$(psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -t -c "SELECT id FROM roles WHERE name='Admin';" 2>/dev/null | tr -d ' ') + +# Print header +printf "%-4s %-20s %-8s %-8s %-6s %s\n" "ID" "Username" "Admin" "Active" "MFA" "Last Login" +printf "%-4s %-20s %-8s %-8s %-6s %s\n" "----" "--------------------" "--------" "--------" "------" "-------------------" + +# List users (check admin status via user_roles table) +psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -t -A -F '|' -c "SELECT id, username, is_active, mfa_enabled, COALESCE(last_login::text, 'Never') FROM users ORDER BY id;" 2>/dev/null | while IFS='|' read id username is_active mfa_enabled last_login; do + # Check if user has Admin role + if [ -n "$ADMIN_ROLE_ID" ]; then + HAS_ADMIN=$(psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -t -c "SELECT COUNT(*) FROM user_roles WHERE user_id=$id AND role_id=$ADMIN_ROLE_ID;" 2>/dev/null | tr -d ' ') + if [ "$HAS_ADMIN" -gt "0" ]; then + admin_str="Yes" + else + admin_str="No" + fi + else + admin_str="N/A" + fi + + # Convert boolean values (PostgreSQL returns t/f) + if [ "$is_active" = "t" ]; then + active_str="Yes" + else + active_str="No" + fi + + if [ "$mfa_enabled" = "t" ]; then + mfa_str="Yes" + else + mfa_str="No" + fi + + printf "%-4s %-20s %-8s %-8s %-6s %s\n" "$id" "$username" "$admin_str" "$active_str" "$mfa_str" "$last_login" +done + +echo "" +echo "Total: $USER_COUNT user(s)" + +# Show session count +SESSION_COUNT=$(psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -t -c "SELECT COUNT(*) FROM sessions;" 2>/dev/null | tr -d ' ') +echo "Active sessions: $SESSION_COUNT" diff --git a/scripts/emergency/postgres/reset-db.sh b/scripts/emergency/postgres/reset-db.sh new file mode 100755 index 0000000..7fd8d98 --- /dev/null +++ b/scripts/emergency/postgres/reset-db.sh @@ -0,0 +1,118 @@ +#!/bin/sh +# +# PostgreSQL: Emergency script to factory reset the database +# WARNING: This will DELETE ALL DATA including users, settings, and activity logs! +# +# Usage: +# docker exec -it dockhand /app/scripts/emergency/postgres/reset-db.sh +# +# Requires: DATABASE_URL environment variable +# + +set -e + +echo "========================================" +echo " Dockhand - Factory Reset Database (PostgreSQL)" +echo "========================================" +echo "" +echo "WARNING: This will DELETE ALL DATA!" +echo "" +echo "This includes:" +echo " - All users and their settings" +echo " - All sessions" +echo " - Authentication settings" +echo " - Activity logs" +echo " - Environment configurations" +echo " - OIDC/SSO settings" +echo "" +echo "The database tables will be truncated." +echo "" + +# Check DATABASE_URL +if [ -z "$DATABASE_URL" ]; then + echo "Error: DATABASE_URL environment variable not set" + echo "" + echo "Example: DATABASE_URL=postgres://user:pass@host:5432/dockhand" + exit 1 +fi + +# Parse DATABASE_URL +DB_URL="$DATABASE_URL" +DB_URL="${DB_URL#postgres://}" +DB_URL="${DB_URL#postgresql://}" + +DB_USER="${DB_URL%%:*}" +DB_URL="${DB_URL#*:}" +DB_PASS="${DB_URL%%@*}" +DB_URL="${DB_URL#*@}" +DB_HOST="${DB_URL%%:*}" +DB_URL="${DB_URL#*:}" +DB_PORT="${DB_URL%%/*}" +DB_NAME="${DB_URL#*/}" +DB_NAME="${DB_NAME%%\?*}" + +export PGPASSWORD="$DB_PASS" + +echo "Database: $DB_HOST:$DB_PORT/$DB_NAME" +echo "" +printf "Continue? [y/N]: " +read CONFIRM + +case "$CONFIRM" in + [yY]|[yY][eE][sS]) + ;; + *) + echo "Aborted." + exit 0 + ;; +esac + +echo "" +echo "Creating backup before reset..." +TIMESTAMP=$(date +%Y%m%d_%H%M%S) +BACKUP_FILE="/app/data/dockhand_backup_pre_reset_$TIMESTAMP.sql" +if command -v pg_dump >/dev/null 2>&1; then + pg_dump -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -F p -f "$BACKUP_FILE" 2>/dev/null || true + if [ -f "$BACKUP_FILE" ]; then + echo "Backup saved to: $BACKUP_FILE" + fi +fi + +echo "" +echo "Truncating all tables..." + +# Truncate all tables in the correct order (respecting foreign keys) +psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" < +# +# Example: +# docker exec -it dockhand /app/scripts/emergency/postgres/reset-password.sh admin MyNewPassword123 +# +# Requires: DATABASE_URL environment variable +# + +set -e + +echo "========================================" +echo " Dockhand - Reset User Password (PostgreSQL)" +echo "========================================" +echo "" + +# Check arguments +if [ -z "$1" ] || [ -z "$2" ]; then + echo "Usage: $0 " + echo "" + echo "Example:" + echo " $0 admin MyNewPassword123" + exit 1 +fi + +USERNAME="$1" +NEW_PASSWORD="$2" + +# Validate password length +if [ ${#NEW_PASSWORD} -lt 8 ]; then + echo "Error: Password must be at least 8 characters" + exit 1 +fi + +# Check DATABASE_URL +if [ -z "$DATABASE_URL" ]; then + echo "Error: DATABASE_URL environment variable not set" + echo "" + echo "Example: DATABASE_URL=postgres://user:pass@host:5432/dockhand" + exit 1 +fi + +# Parse DATABASE_URL +DB_URL="$DATABASE_URL" +DB_URL="${DB_URL#postgres://}" +DB_URL="${DB_URL#postgresql://}" + +DB_USER="${DB_URL%%:*}" +DB_URL="${DB_URL#*:}" +DB_PASS="${DB_URL%%@*}" +DB_URL="${DB_URL#*@}" +DB_HOST="${DB_URL%%:*}" +DB_URL="${DB_URL#*:}" +DB_PORT="${DB_URL%%/*}" +DB_NAME="${DB_URL#*/}" +DB_NAME="${DB_NAME%%\?*}" + +export PGPASSWORD="$DB_PASS" + +# Check if user exists +EXISTING=$(psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -t -c "SELECT COUNT(*) FROM users WHERE username='$USERNAME';" 2>/dev/null | tr -d ' ') + +if [ "$EXISTING" -eq "0" ]; then + echo "Error: User '$USERNAME' not found" + echo "" + echo "Available users:" + psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -t -c "SELECT username FROM users;" 2>/dev/null | while read user; do + user=$(echo "$user" | tr -d ' ') + if [ -n "$user" ]; then + echo " - $user" + fi + done + exit 1 +fi + +echo "This script will reset the password for user '$USERNAME'." +echo "" +echo "Database: $DB_HOST:$DB_PORT/$DB_NAME" +echo "Username: $USERNAME" +echo "" +printf "Continue? [y/N]: " +read CONFIRM + +case "$CONFIRM" in + [yY]|[yY][eE][sS]) + ;; + *) + echo "Aborted." + exit 0 + ;; +esac + +# Generate password hash using node (argon2 is available in the app) +echo "" +echo "Generating password hash..." + +# Check if node and argon2 are available +if command -v node >/dev/null 2>&1; then + # Try to use argon2 from node_modules + PASSWORD_HASH=$(node -e " + try { + const argon2 = require('argon2'); + argon2.hash('$NEW_PASSWORD').then(h => console.log(h)).catch(e => process.exit(1)); + } catch(e) { + process.exit(1); + } + " 2>/dev/null) + + if [ -z "$PASSWORD_HASH" ]; then + echo "Error: Could not generate password hash (argon2 not available)" + echo "This script requires Node.js with argon2 module" + exit 1 + fi +else + echo "Error: Node.js is required to generate password hash" + exit 1 +fi + +echo "Resetting password for user '$USERNAME'..." +psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -c "UPDATE users SET password_hash='$PASSWORD_HASH', updated_at=NOW() WHERE username='$USERNAME';" + +if [ $? -eq 0 ]; then + echo "" + echo "Password reset successfully for user '$USERNAME'" + echo "" + # Invalidate sessions + USER_ID=$(psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -t -c "SELECT id FROM users WHERE username='$USERNAME';" 2>/dev/null | tr -d ' ') + psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -c "DELETE FROM sessions WHERE user_id=$USER_ID;" 2>/dev/null || true + echo "All existing sessions have been invalidated." + echo "The user can now log in with the new password." +else + echo "Error: Failed to reset password" + exit 1 +fi diff --git a/scripts/emergency/postgres/restore-db.sh b/scripts/emergency/postgres/restore-db.sh new file mode 100755 index 0000000..8676a8f --- /dev/null +++ b/scripts/emergency/postgres/restore-db.sh @@ -0,0 +1,117 @@ +#!/bin/sh +# +# PostgreSQL: Emergency script to restore the database from a backup +# WARNING: This will overwrite the current database! +# +# Usage: +# docker exec -it dockhand /app/scripts/emergency/postgres/restore-db.sh +# +# Example: +# docker exec -it dockhand /app/scripts/emergency/postgres/restore-db.sh /app/data/dockhand_backup_20240115_120000.sql +# +# To copy backup into container first: +# docker cp ./dockhand_backup.sql dockhand:/app/data/ +# +# Requires: DATABASE_URL environment variable +# + +set -e + +echo "========================================" +echo " Dockhand - Restore Database (PostgreSQL)" +echo "========================================" +echo "" + +# Check argument +if [ -z "$1" ]; then + echo "Usage: $0 " + echo "" + echo "Example:" + echo " $0 /app/data/dockhand_backup_20240115_120000.sql" + echo "" + echo "To copy backup into container first:" + echo " docker cp ./dockhand_backup.sql dockhand:/app/data/" + exit 1 +fi + +BACKUP_FILE="$1" + +# Check DATABASE_URL +if [ -z "$DATABASE_URL" ]; then + echo "Error: DATABASE_URL environment variable not set" + echo "" + echo "Example: DATABASE_URL=postgres://user:pass@host:5432/dockhand" + exit 1 +fi + +# Parse DATABASE_URL +DB_URL="$DATABASE_URL" +DB_URL="${DB_URL#postgres://}" +DB_URL="${DB_URL#postgresql://}" + +DB_USER="${DB_URL%%:*}" +DB_URL="${DB_URL#*:}" +DB_PASS="${DB_URL%%@*}" +DB_URL="${DB_URL#*@}" +DB_HOST="${DB_URL%%:*}" +DB_URL="${DB_URL#*:}" +DB_PORT="${DB_URL%%/*}" +DB_NAME="${DB_URL#*/}" +DB_NAME="${DB_NAME%%\?*}" + +export PGPASSWORD="$DB_PASS" + +# Check if backup file exists +if [ ! -f "$BACKUP_FILE" ]; then + echo "Error: Backup file not found: $BACKUP_FILE" + exit 1 +fi + +# Get backup file size +BACKUP_SIZE=$(ls -lh "$BACKUP_FILE" | awk '{print $5}') + +echo "WARNING: This will overwrite the current database!" +echo "" +echo "Database: $DB_HOST:$DB_PORT/$DB_NAME" +echo "Backup to restore: $BACKUP_FILE ($BACKUP_SIZE)" +echo "" +printf "Continue? [y/N]: " +read CONFIRM + +case "$CONFIRM" in + [yY]|[yY][eE][sS]) + ;; + *) + echo "Aborted." + exit 0 + ;; +esac + +# Create backup of current database before restoring +echo "" +echo "Creating backup of current database..." +TIMESTAMP=$(date +%Y%m%d_%H%M%S) +PRE_RESTORE_BACKUP="/app/data/dockhand_pre_restore_$TIMESTAMP.sql" +if command -v pg_dump >/dev/null 2>&1; then + pg_dump -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -F p -f "$PRE_RESTORE_BACKUP" 2>/dev/null || true + if [ -f "$PRE_RESTORE_BACKUP" ]; then + echo "Current database backed up to: $PRE_RESTORE_BACKUP" + fi +fi + +echo "" +echo "Restoring database..." + +# Drop and recreate all tables by running the backup +psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -f "$BACKUP_FILE" + +if [ $? -eq 0 ]; then + echo "" + echo "Database restored successfully!" + echo "" + echo "Restart Dockhand to apply changes:" + echo " docker restart dockhand" +else + echo "Error: Failed to restore database" + exit 1 +fi diff --git a/scripts/emergency/reset-db.sh b/scripts/emergency/reset-db.sh new file mode 100755 index 0000000..cc88591 --- /dev/null +++ b/scripts/emergency/reset-db.sh @@ -0,0 +1,18 @@ +#!/bin/sh +# +# Emergency script to factory reset the database +# Automatically detects database type (SQLite or PostgreSQL) +# WARNING: This will DELETE ALL DATA! +# +# Usage: +# docker exec -it dockhand /app/scripts/emergency/reset-db.sh +# + +SCRIPT_DIR="$(dirname "$0")" + +# Detect database type +if [ -n "$DATABASE_URL" ] && (echo "$DATABASE_URL" | grep -qE '^postgres(ql)?://'); then + exec "$SCRIPT_DIR/postgres/reset-db.sh" "$@" +else + exec "$SCRIPT_DIR/sqlite/reset-db.sh" "$@" +fi diff --git a/scripts/emergency/reset-password.sh b/scripts/emergency/reset-password.sh new file mode 100755 index 0000000..a41fad9 --- /dev/null +++ b/scripts/emergency/reset-password.sh @@ -0,0 +1,20 @@ +#!/bin/sh +# +# Emergency script to reset a user's password +# Automatically detects database type (SQLite or PostgreSQL) +# +# Usage: +# docker exec -it dockhand /app/scripts/emergency/reset-password.sh +# +# Example: +# docker exec -it dockhand /app/scripts/emergency/reset-password.sh admin MyNewPassword123 +# + +SCRIPT_DIR="$(dirname "$0")" + +# Detect database type +if [ -n "$DATABASE_URL" ] && (echo "$DATABASE_URL" | grep -qE '^postgres(ql)?://'); then + exec "$SCRIPT_DIR/postgres/reset-password.sh" "$@" +else + exec "$SCRIPT_DIR/sqlite/reset-password.sh" "$@" +fi diff --git a/scripts/emergency/restore-db.sh b/scripts/emergency/restore-db.sh new file mode 100755 index 0000000..db0575e --- /dev/null +++ b/scripts/emergency/restore-db.sh @@ -0,0 +1,21 @@ +#!/bin/sh +# +# Emergency script to restore the database from a backup +# Automatically detects database type (SQLite or PostgreSQL) +# WARNING: This will overwrite the current database! +# +# Usage: +# docker exec -it dockhand /app/scripts/emergency/restore-db.sh +# +# Example: +# docker exec -it dockhand /app/scripts/emergency/restore-db.sh /app/data/dockhand_backup_20240115_120000.db +# + +SCRIPT_DIR="$(dirname "$0")" + +# Detect database type +if [ -n "$DATABASE_URL" ] && (echo "$DATABASE_URL" | grep -qE '^postgres(ql)?://'); then + exec "$SCRIPT_DIR/postgres/restore-db.sh" "$@" +else + exec "$SCRIPT_DIR/sqlite/restore-db.sh" "$@" +fi diff --git a/scripts/emergency/sqlite/backup-db.sh b/scripts/emergency/sqlite/backup-db.sh new file mode 100755 index 0000000..7242ef9 --- /dev/null +++ b/scripts/emergency/sqlite/backup-db.sh @@ -0,0 +1,88 @@ +#!/bin/sh +# +# SQLite: Emergency script to backup the database +# Creates a timestamped copy of the database file +# +# Usage: +# docker exec -it dockhand /app/scripts/emergency/sqlite/backup-db.sh [output_dir] +# +# Example: +# docker exec -it dockhand /app/scripts/emergency/sqlite/backup-db.sh /app/data/backups +# +# Default output: /app/data (same directory as database) +# + +set -e + +echo "========================================" +echo " Dockhand - Backup Database (SQLite)" +echo "========================================" +echo "" + +# Default database path +DB_PATH="${DOCKHAND_DB:-/app/data/db/dockhand.db}" +OUTPUT_DIR="${1:-$(dirname "$DB_PATH")}" + +# Check if running locally (not in Docker) +if [ ! -f "$DB_PATH" ] && [ -f "./data/db/dockhand.db" ]; then + DB_PATH="./data/db/dockhand.db" + OUTPUT_DIR="${1:-./data/db}" +fi + +if [ ! -f "$DB_PATH" ]; then + echo "Error: Database not found at $DB_PATH" + echo "Set DOCKHAND_DB environment variable to specify the database path" + exit 1 +fi + +# Generate backup filename with timestamp +TIMESTAMP=$(date +%Y%m%d_%H%M%S) +BACKUP_FILE="$OUTPUT_DIR/dockhand_backup_$TIMESTAMP.db" + +# Get database size +DB_SIZE=$(ls -lh "$DB_PATH" | awk '{print $5}') + +echo "This script will create a backup of the database." +echo "" +echo "Source: $DB_PATH ($DB_SIZE)" +echo "Backup: $BACKUP_FILE" +echo "" +printf "Continue? [y/N]: " +read CONFIRM + +case "$CONFIRM" in + [yY]|[yY][eE][sS]) + ;; + *) + echo "Aborted." + exit 0 + ;; +esac + +echo "" + +# Create output directory if needed +mkdir -p "$OUTPUT_DIR" + +echo "Creating database backup..." + +# Use sqlite3 backup command for safe backup (handles WAL mode) +if command -v sqlite3 >/dev/null 2>&1; then + sqlite3 "$DB_PATH" ".backup '$BACKUP_FILE'" +else + # Fallback to file copy if sqlite3 not available + cp "$DB_PATH" "$BACKUP_FILE" +fi + +if [ $? -eq 0 ] && [ -f "$BACKUP_FILE" ]; then + SIZE=$(ls -lh "$BACKUP_FILE" | awk '{print $5}') + echo "" + echo "Backup created successfully!" + echo "Size: $SIZE" + echo "" + echo "To copy from Docker container to host:" + echo " docker cp dockhand:$BACKUP_FILE ./dockhand_backup_$TIMESTAMP.db" +else + echo "Error: Failed to create backup" + exit 1 +fi diff --git a/scripts/emergency/sqlite/clear-sessions.sh b/scripts/emergency/sqlite/clear-sessions.sh new file mode 100755 index 0000000..914c8ed --- /dev/null +++ b/scripts/emergency/sqlite/clear-sessions.sh @@ -0,0 +1,62 @@ +#!/bin/sh +# +# SQLite: Emergency script to clear all user sessions +# Use this to force all users to re-login +# +# Usage: +# docker exec -it dockhand /app/scripts/emergency/sqlite/clear-sessions.sh +# + +set -e + +echo "========================================" +echo " Dockhand - Clear All Sessions (SQLite)" +echo "========================================" +echo "" +echo "This script will clear all user sessions," +echo "forcing all users to log in again." +echo "" + +# Default database path +DB_PATH="${DOCKHAND_DB:-/app/data/db/dockhand.db}" + +# Check if running locally (not in Docker) +if [ ! -f "$DB_PATH" ] && [ -f "./data/db/dockhand.db" ]; then + DB_PATH="./data/db/dockhand.db" +fi + +if [ ! -f "$DB_PATH" ]; then + echo "Error: Database not found at $DB_PATH" + echo "Set DOCKHAND_DB environment variable to specify the database path" + exit 1 +fi + +COUNT=$(sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM sessions;") + +echo "Database: $DB_PATH" +echo "Active sessions: $COUNT" +echo "" +printf "Continue? [y/N]: " +read CONFIRM + +case "$CONFIRM" in + [yY]|[yY][eE][sS]) + ;; + *) + echo "Aborted." + exit 0 + ;; +esac + +echo "" +echo "Clearing all user sessions..." +sqlite3 "$DB_PATH" "DELETE FROM sessions;" + +if [ $? -eq 0 ]; then + echo "" + echo "Cleared $COUNT session(s) successfully." + echo "All users will need to log in again." +else + echo "Error: Failed to clear sessions" + exit 1 +fi diff --git a/scripts/emergency/sqlite/create-admin.sh b/scripts/emergency/sqlite/create-admin.sh new file mode 100755 index 0000000..91ff9c7 --- /dev/null +++ b/scripts/emergency/sqlite/create-admin.sh @@ -0,0 +1,104 @@ +#!/bin/sh +# +# SQLite: Emergency script to create an admin user +# Use this if you're locked out of Dockhand and need to create a new admin +# +# Usage: +# docker exec -it dockhand /app/scripts/emergency/sqlite/create-admin.sh +# +# Default credentials: admin / admin123 +# CHANGE THE PASSWORD IMMEDIATELY after logging in! +# + +set -e + +echo "========================================" +echo " Dockhand - Create Admin User (SQLite)" +echo "========================================" +echo "" +echo "This script will create an admin user with:" +echo " Username: admin" +echo " Password: admin123" +echo "" +echo "If user 'admin' already exists, password will" +echo "be reset and admin privileges restored." +echo "" + +# Default database path +DB_PATH="${DOCKHAND_DB:-/app/data/db/dockhand.db}" + +# Check if running locally (not in Docker) +if [ ! -f "$DB_PATH" ] && [ -f "./data/db/dockhand.db" ]; then + DB_PATH="./data/db/dockhand.db" +fi + +if [ ! -f "$DB_PATH" ]; then + echo "Error: Database not found at $DB_PATH" + echo "Set DOCKHAND_DB environment variable to specify the database path" + exit 1 +fi + +echo "Database: $DB_PATH" +echo "" +printf "Continue? [y/N]: " +read CONFIRM + +case "$CONFIRM" in + [yY]|[yY][eE][sS]) + ;; + *) + echo "Aborted." + exit 0 + ;; +esac + +# Username and password +USERNAME="admin" +# Password: admin123 +# This is an argon2id hash of "admin123" - generated with default argon2 settings +PASSWORD_HASH='$argon2id$v=19$m=65536,t=3,p=4$Jq4am2SfyYKmc0PAHe+yzg$cq/27vK/Qg2eZb/jMDy0ExLDhOG+58cKAximxpG5Dss' + +echo "" +echo "Creating admin user..." + +# Check if admin user already exists +EXISTING=$(sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM users WHERE username='$USERNAME';") + +if [ "$EXISTING" -gt "0" ]; then + echo "User '$USERNAME' already exists." + echo "Resetting password and ensuring active status..." + sqlite3 "$DB_PATH" "UPDATE users SET password_hash='$PASSWORD_HASH', is_active=1 WHERE username='$USERNAME';" + USER_ID=$(sqlite3 "$DB_PATH" "SELECT id FROM users WHERE username='$USERNAME';") +else + echo "Creating new admin user..." + sqlite3 "$DB_PATH" "INSERT INTO users (username, password_hash, is_active, auth_provider, created_at, updated_at) VALUES ('$USERNAME', '$PASSWORD_HASH', 1, 'local', datetime('now'), datetime('now'));" + USER_ID=$(sqlite3 "$DB_PATH" "SELECT id FROM users WHERE username='$USERNAME';") + echo "Admin user created successfully." +fi + +# Get the Admin role ID (it's a system role) +ADMIN_ROLE_ID=$(sqlite3 "$DB_PATH" "SELECT id FROM roles WHERE name='Admin';") + +if [ -z "$ADMIN_ROLE_ID" ]; then + echo "Warning: Admin role not found in database." + echo "The user was created but may not have admin privileges." + echo "Please check Settings > Auth > Roles after logging in." +else + # Check if user already has Admin role + HAS_ROLE=$(sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM user_roles WHERE user_id=$USER_ID AND role_id=$ADMIN_ROLE_ID;") + + if [ "$HAS_ROLE" -eq "0" ]; then + echo "Assigning Admin role..." + sqlite3 "$DB_PATH" "INSERT INTO user_roles (user_id, role_id, created_at) VALUES ($USER_ID, $ADMIN_ROLE_ID, datetime('now'));" + echo "Admin role assigned." + else + echo "User already has Admin role." + fi +fi + +echo "" +echo "Credentials:" +echo " Username: admin" +echo " Password: admin123" +echo "" +echo "WARNING: Change the password immediately after logging in!" diff --git a/scripts/emergency/sqlite/disable-auth.sh b/scripts/emergency/sqlite/disable-auth.sh new file mode 100755 index 0000000..1ebcc49 --- /dev/null +++ b/scripts/emergency/sqlite/disable-auth.sh @@ -0,0 +1,61 @@ +#!/bin/sh +# +# SQLite: Emergency script to disable authentication +# Use this if you're locked out of Dockhand +# +# Usage: +# docker exec -it dockhand /app/scripts/emergency/sqlite/disable-auth.sh +# + +set -e + +echo "========================================" +echo " Dockhand - Disable Authentication (SQLite)" +echo "========================================" +echo "" +echo "This script will disable authentication," +echo "allowing access to Dockhand without login." +echo "" + +# Default database path +DB_PATH="${DOCKHAND_DB:-/app/data/db/dockhand.db}" + +# Check if running locally (not in Docker) +if [ ! -f "$DB_PATH" ] && [ -f "./data/db/dockhand.db" ]; then + DB_PATH="./data/db/dockhand.db" +fi + +if [ ! -f "$DB_PATH" ]; then + echo "Error: Database not found at $DB_PATH" + echo "Set DOCKHAND_DB environment variable to specify the database path" + exit 1 +fi + +echo "Database: $DB_PATH" +echo "" +printf "Continue? [y/N]: " +read CONFIRM + +case "$CONFIRM" in + [yY]|[yY][eE][sS]) + ;; + *) + echo "Aborted." + exit 0 + ;; +esac + +echo "" +echo "Disabling authentication..." +sqlite3 "$DB_PATH" "UPDATE auth_settings SET auth_enabled = 0 WHERE id = 1;" + +if [ $? -eq 0 ]; then + echo "" + echo "Authentication disabled successfully." + echo "You can now access Dockhand without logging in." + echo "" + echo "Remember to re-enable authentication in Settings after regaining access." +else + echo "Error: Failed to disable authentication" + exit 1 +fi diff --git a/scripts/emergency/sqlite/list-users.sh b/scripts/emergency/sqlite/list-users.sh new file mode 100755 index 0000000..9df5c25 --- /dev/null +++ b/scripts/emergency/sqlite/list-users.sh @@ -0,0 +1,80 @@ +#!/bin/sh +# +# SQLite: Emergency script to list all users +# Shows username, admin status, active status, and last login +# +# Usage: +# docker exec -it dockhand /app/scripts/emergency/sqlite/list-users.sh +# + +set -e + +echo "========================================" +echo " Dockhand - List Users (SQLite)" +echo "========================================" +echo "" + +# Default database path +DB_PATH="${DOCKHAND_DB:-/app/data/db/dockhand.db}" + +# Check if running locally (not in Docker) +if [ ! -f "$DB_PATH" ] && [ -f "./data/db/dockhand.db" ]; then + DB_PATH="./data/db/dockhand.db" +fi + +if [ ! -f "$DB_PATH" ]; then + echo "Error: Database not found at $DB_PATH" + echo "Set DOCKHAND_DB environment variable to specify the database path" + exit 1 +fi + +# Get user count +USER_COUNT=$(sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM users;") + +if [ "$USER_COUNT" -eq "0" ]; then + echo "No users found." + exit 0 +fi + +# Get Admin role ID for checking admin status +ADMIN_ROLE_ID=$(sqlite3 "$DB_PATH" "SELECT id FROM roles WHERE name='Admin';" 2>/dev/null || echo "") + +# Print header +printf "%-4s %-20s %-8s %-8s %-6s %s\n" "ID" "Username" "Admin" "Active" "MFA" "Last Login" +printf "%-4s %-20s %-8s %-8s %-6s %s\n" "----" "--------------------" "--------" "--------" "------" "-------------------" + +# List users (check admin status via user_roles table) +sqlite3 -separator '|' "$DB_PATH" "SELECT id, username, is_active, mfa_enabled, COALESCE(last_login, 'Never') FROM users ORDER BY id;" | while IFS='|' read id username is_active mfa_enabled last_login; do + # Check if user has Admin role + if [ -n "$ADMIN_ROLE_ID" ]; then + HAS_ADMIN=$(sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM user_roles WHERE user_id=$id AND role_id=$ADMIN_ROLE_ID;") + if [ "$HAS_ADMIN" -gt "0" ]; then + admin_str="Yes" + else + admin_str="No" + fi + else + admin_str="N/A" + fi + + if [ "$is_active" = "1" ]; then + active_str="Yes" + else + active_str="No" + fi + + if [ "$mfa_enabled" = "1" ]; then + mfa_str="Yes" + else + mfa_str="No" + fi + + printf "%-4s %-20s %-8s %-8s %-6s %s\n" "$id" "$username" "$admin_str" "$active_str" "$mfa_str" "$last_login" +done + +echo "" +echo "Total: $USER_COUNT user(s)" + +# Show session count +SESSION_COUNT=$(sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM sessions;") +echo "Active sessions: $SESSION_COUNT" diff --git a/scripts/emergency/sqlite/reset-db.sh b/scripts/emergency/sqlite/reset-db.sh new file mode 100755 index 0000000..d53532b --- /dev/null +++ b/scripts/emergency/sqlite/reset-db.sh @@ -0,0 +1,73 @@ +#!/bin/sh +# +# SQLite: Emergency script to factory reset the database +# WARNING: This will DELETE ALL DATA including users, settings, and activity logs! +# +# Usage: +# docker exec -it dockhand /app/scripts/emergency/sqlite/reset-db.sh +# + +set -e + +echo "========================================" +echo " Dockhand - Factory Reset Database (SQLite)" +echo "========================================" +echo "" +echo "WARNING: This will DELETE ALL DATA!" +echo "" +echo "This includes:" +echo " - All users and their settings" +echo " - All sessions" +echo " - Authentication settings" +echo " - Activity logs" +echo " - Environment configurations" +echo " - OIDC/SSO settings" +echo "" +echo "The database will be recreated on next startup." +echo "" + +# Default database path +DB_PATH="${DOCKHAND_DB:-/app/data/db/dockhand.db}" + +# Check if running locally (not in Docker) +if [ ! -f "$DB_PATH" ] && [ -f "./data/db/dockhand.db" ]; then + DB_PATH="./data/db/dockhand.db" +fi + +if [ ! -f "$DB_PATH" ]; then + echo "Error: Database not found at $DB_PATH" + echo "Nothing to reset." + exit 0 +fi + +echo "Database: $DB_PATH" +echo "" +printf "Continue? [y/N]: " +read CONFIRM + +case "$CONFIRM" in + [yY]|[yY][eE][sS]) + ;; + *) + echo "Aborted." + exit 0 + ;; +esac + +echo "" +echo "Creating backup before reset..." +BACKUP_FILE="${DB_PATH}.backup.$(date +%Y%m%d_%H%M%S)" +cp "$DB_PATH" "$BACKUP_FILE" +echo "Backup saved to: $BACKUP_FILE" + +echo "" +echo "Deleting database..." +rm -f "$DB_PATH" +rm -f "${DB_PATH}-wal" +rm -f "${DB_PATH}-shm" + +echo "" +echo "Database deleted successfully." +echo "" +echo "Restart Dockhand to recreate a fresh database:" +echo " docker restart dockhand" diff --git a/scripts/emergency/sqlite/reset-password.sh b/scripts/emergency/sqlite/reset-password.sh new file mode 100755 index 0000000..9a38214 --- /dev/null +++ b/scripts/emergency/sqlite/reset-password.sh @@ -0,0 +1,123 @@ +#!/bin/sh +# +# SQLite: Emergency script to reset a user's password +# Use this if a user is locked out and needs a password reset +# +# Usage: +# docker exec -it dockhand /app/scripts/emergency/sqlite/reset-password.sh +# +# Example: +# docker exec -it dockhand /app/scripts/emergency/sqlite/reset-password.sh admin MyNewPassword123 +# + +set -e + +echo "========================================" +echo " Dockhand - Reset User Password (SQLite)" +echo "========================================" +echo "" + +# Check arguments +if [ -z "$1" ] || [ -z "$2" ]; then + echo "Usage: $0 " + echo "" + echo "Example:" + echo " $0 admin MyNewPassword123" + exit 1 +fi + +USERNAME="$1" +NEW_PASSWORD="$2" + +# Validate password length +if [ ${#NEW_PASSWORD} -lt 8 ]; then + echo "Error: Password must be at least 8 characters" + exit 1 +fi + +# Default database path +DB_PATH="${DOCKHAND_DB:-/app/data/db/dockhand.db}" + +# Check if running locally (not in Docker) +if [ ! -f "$DB_PATH" ] && [ -f "./data/db/dockhand.db" ]; then + DB_PATH="./data/db/dockhand.db" +fi + +if [ ! -f "$DB_PATH" ]; then + echo "Error: Database not found at $DB_PATH" + echo "Set DOCKHAND_DB environment variable to specify the database path" + exit 1 +fi + +# Check if user exists +EXISTING=$(sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM users WHERE username='$USERNAME';") + +if [ "$EXISTING" -eq "0" ]; then + echo "Error: User '$USERNAME' not found" + echo "" + echo "Available users:" + sqlite3 "$DB_PATH" "SELECT username FROM users;" | while read user; do + echo " - $user" + done + exit 1 +fi + +echo "This script will reset the password for user '$USERNAME'." +echo "" +echo "Database: $DB_PATH" +echo "Username: $USERNAME" +echo "" +printf "Continue? [y/N]: " +read CONFIRM + +case "$CONFIRM" in + [yY]|[yY][eE][sS]) + ;; + *) + echo "Aborted." + exit 0 + ;; +esac + +# Generate password hash using node (argon2 is available in the app) +echo "" +echo "Generating password hash..." + +# Check if node and argon2 are available +if command -v node >/dev/null 2>&1; then + # Try to use argon2 from node_modules + PASSWORD_HASH=$(node -e " + try { + const argon2 = require('argon2'); + argon2.hash('$NEW_PASSWORD').then(h => console.log(h)).catch(e => process.exit(1)); + } catch(e) { + process.exit(1); + } + " 2>/dev/null) + + if [ -z "$PASSWORD_HASH" ]; then + echo "Error: Could not generate password hash (argon2 not available)" + echo "This script requires Node.js with argon2 module" + exit 1 + fi +else + echo "Error: Node.js is required to generate password hash" + exit 1 +fi + +echo "Resetting password for user '$USERNAME'..." +sqlite3 "$DB_PATH" "UPDATE users SET password_hash='$PASSWORD_HASH', updated_at=datetime('now') WHERE username='$USERNAME';" + +if [ $? -eq 0 ]; then + echo "" + echo "Password reset successfully for user '$USERNAME'" + echo "" + # Invalidate sessions + USER_ID=$(sqlite3 "$DB_PATH" "SELECT id FROM users WHERE username='$USERNAME';") + sqlite3 "$DB_PATH" "DELETE FROM sessions WHERE user_id=$USER_ID;" 2>/dev/null || true + echo "All existing sessions have been invalidated." + echo "The user can now log in with the new password." +else + echo "Error: Failed to reset password" + exit 1 +fi diff --git a/scripts/emergency/sqlite/restore-db.sh b/scripts/emergency/sqlite/restore-db.sh new file mode 100755 index 0000000..96aa9e6 --- /dev/null +++ b/scripts/emergency/sqlite/restore-db.sh @@ -0,0 +1,106 @@ +#!/bin/sh +# +# SQLite: Emergency script to restore the database from a backup +# WARNING: This will overwrite the current database! +# +# Usage: +# docker exec -it dockhand /app/scripts/emergency/sqlite/restore-db.sh +# +# Example: +# docker exec -it dockhand /app/scripts/emergency/sqlite/restore-db.sh /app/data/dockhand_backup_20240115_120000.db +# +# To copy backup into container first: +# docker cp ./dockhand_backup.db dockhand:/app/data/ +# + +set -e + +echo "========================================" +echo " Dockhand - Restore Database (SQLite)" +echo "========================================" +echo "" + +# Check argument +if [ -z "$1" ]; then + echo "Usage: $0 " + echo "" + echo "Example:" + echo " $0 /app/data/dockhand_backup_20240115_120000.db" + echo "" + echo "To copy backup into container first:" + echo " docker cp ./dockhand_backup.db dockhand:/app/data/" + exit 1 +fi + +BACKUP_FILE="$1" + +# Default database path +DB_PATH="${DOCKHAND_DB:-/app/data/db/dockhand.db}" + +# Check if running locally (not in Docker) +if [ ! -f "$DB_PATH" ] && [ -f "./data/db/dockhand.db" ]; then + DB_PATH="./data/db/dockhand.db" +fi + +# Check if backup file exists +if [ ! -f "$BACKUP_FILE" ]; then + echo "Error: Backup file not found: $BACKUP_FILE" + exit 1 +fi + +# Verify it's a valid SQLite database +if ! sqlite3 "$BACKUP_FILE" "SELECT 1;" >/dev/null 2>&1; then + echo "Error: File is not a valid SQLite database: $BACKUP_FILE" + exit 1 +fi + +# Get backup file size +BACKUP_SIZE=$(ls -lh "$BACKUP_FILE" | awk '{print $5}') + +echo "WARNING: This will overwrite the current database!" +echo "" +echo "Current database: $DB_PATH" +echo "Backup to restore: $BACKUP_FILE ($BACKUP_SIZE)" +echo "" +printf "Continue? [y/N]: " +read CONFIRM + +case "$CONFIRM" in + [yY]|[yY][eE][sS]) + ;; + *) + echo "Aborted." + exit 0 + ;; +esac + +# Create backup of current database before restoring +if [ -f "$DB_PATH" ]; then + TIMESTAMP=$(date +%Y%m%d_%H%M%S) + PRE_RESTORE_BACKUP="${DB_PATH}.pre-restore.$TIMESTAMP" + echo "" + echo "Creating backup of current database..." + cp "$DB_PATH" "$PRE_RESTORE_BACKUP" + echo "Current database backed up to: $PRE_RESTORE_BACKUP" +fi + +echo "" +echo "Restoring database..." + +# Remove WAL files if they exist +rm -f "${DB_PATH}-wal" +rm -f "${DB_PATH}-shm" + +# Copy backup to database location +cp "$BACKUP_FILE" "$DB_PATH" + +if [ $? -eq 0 ]; then + echo "" + echo "Database restored successfully!" + echo "" + echo "Restart Dockhand to apply changes:" + echo " docker restart dockhand" +else + echo "Error: Failed to restore database" + exit 1 +fi diff --git a/scripts/generate-changelog-page.ts b/scripts/generate-changelog-page.ts new file mode 100644 index 0000000..6ab60d1 --- /dev/null +++ b/scripts/generate-changelog-page.ts @@ -0,0 +1,164 @@ +#!/usr/bin/env bun +/** + * Generate changelog section in webpage/index.html from src/lib/data/changelog.json + * This ensures a single source of truth for release information + */ + +import { readFileSync, writeFileSync } from 'fs'; +import { join } from 'path'; + +const ROOT_DIR = join(import.meta.dir, '..'); +const CHANGELOG_PATH = join(ROOT_DIR, 'src/lib/data/changelog.json'); +const INDEX_PATH = join(ROOT_DIR, 'webpage/index.html'); + +interface ChangelogEntry { + version: string; + date: string; + changes: Array<{ type: 'feature' | 'fix'; text: string }>; + imageTag: string; +} + +// SVG icons for change types +const FEATURE_SVG = ``; + +const FIX_SVG = ``; + +const TOGGLE_SVG = ``; + +const COPY_SVG = ``; + +function formatDate(dateStr: string): string { + const date = new Date(dateStr); + return date.toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric' + }); +} + +function generateChangeItem(change: { type: 'feature' | 'fix'; text: string }): string { + const pillClass = change.type === 'feature' ? 'changelog-pill-feature' : 'changelog-pill-fix'; + const svg = change.type === 'feature' ? FEATURE_SVG : FIX_SVG; + const label = change.type === 'feature' ? 'New' : 'Fix'; + return `
  • ${svg}${label}${change.text}
  • `; +} + +function generateLatestEntry(entry: ChangelogEntry): string { + const changes = entry.changes.map(generateChangeItem).join('\n'); + const version = entry.version.startsWith('v') ? entry.version : `v${entry.version}`; + + return ` +
    +
    +
    +

    ${version}

    + Latest +
    + ${formatDate(entry.date)} +
    +
      +${changes} +
    +
    + Docker image: + ${entry.imageTag} + + or + fnsys/dockhand:latest + +
    +
    `; +} + +function generateCollapsibleEntry(entry: ChangelogEntry): string { + const changes = entry.changes.map(generateChangeItem).join('\n'); + const version = entry.version.startsWith('v') ? entry.version : `v${entry.version}`; + + return ` +
    +
    +
    +

    ${version}

    + ${TOGGLE_SVG} +
    + ${formatDate(entry.date)} +
    +
    +
      +${changes} +
    +
    + Docker image: + ${entry.imageTag} + +
    +
    +
    `; +} + +function generateChangelogSection(entries: ChangelogEntry[]): string { + if (entries.length === 0) { + return ''; + } + + const [latest, ...rest] = entries; + const latestHtml = generateLatestEntry(latest); + const restHtml = rest.map(generateCollapsibleEntry).join('\n'); + + return ` +
    +
    +
    + +

    Release history

    +

    Track our progress and see what's new in each version. Spoiler: it gets better every time.

    +
    +
    +${latestHtml} +${restHtml} +
    +
    +
    `; +} + +// Read changelog.json +console.log('Reading changelog from:', CHANGELOG_PATH); +const changelog: ChangelogEntry[] = JSON.parse(readFileSync(CHANGELOG_PATH, 'utf-8')); +console.log(`Found ${changelog.length} changelog entries`); + +// Read index.html +console.log('Reading index.html from:', INDEX_PATH); +let indexHtml = readFileSync(INDEX_PATH, 'utf-8'); + +// Generate new changelog section +const newChangelogSection = generateChangelogSection(changelog); + +// Replace changelog section using regex +// Match from "" to the closing "" before "" +const changelogRegex = / [\s\S]*?<\/section>(?=\s*\n\s*)/; + +if (!changelogRegex.test(indexHtml)) { + console.error('ERROR: Could not find changelog section in index.html'); + console.error('Looking for pattern: ... followed by '); + process.exit(1); +} + +indexHtml = indexHtml.replace(changelogRegex, newChangelogSection); + +// Also update softwareVersion in JSON-LD schema +if (changelog.length > 0) { + const latestVersion = changelog[0].version; + // Match "softwareVersion": "X.X" or "softwareVersion": "X.X.X" + const versionRegex = /"softwareVersion":\s*"[\d.]+"/; + if (versionRegex.test(indexHtml)) { + indexHtml = indexHtml.replace(versionRegex, `"softwareVersion": "${latestVersion}"`); + console.log(`Updated softwareVersion to: ${latestVersion}`); + } +} + +// Write back to index.html +writeFileSync(INDEX_PATH, indexHtml); +console.log(''); +console.log('Generated changelog in webpage/index.html'); +console.log(` - Latest version: v${changelog[0]?.version || 'unknown'}`); +console.log(` - Total entries: ${changelog.length}`); diff --git a/scripts/generate-legal-pages.ts b/scripts/generate-legal-pages.ts new file mode 100644 index 0000000..5056190 --- /dev/null +++ b/scripts/generate-legal-pages.ts @@ -0,0 +1,137 @@ +#!/usr/bin/env bun +/** + * Generate static HTML pages for License and Privacy from .txt files + * This ensures a single source of truth for legal documents + */ + +import { readFileSync, writeFileSync } from 'fs'; +import { join } from 'path'; + +const ROOT_DIR = join(import.meta.dir, '..'); +const WEBPAGE_DIR = join(ROOT_DIR, 'webpage'); + +function escapeHtml(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>'); +} + +function generateHtmlPage(title: string, content: string): string { + return ` + + + + + ${title} - Dockhand + + + + +
    +
    + + Dockhand + + ← Back to home +
    + +

    ${title}

    + +
    +
    ${escapeHtml(content)}
    +
    + + +
    + +`; +} + +// Read the source files +const licenseContent = readFileSync(join(ROOT_DIR, 'LICENSE.txt'), 'utf-8'); +const privacyContent = readFileSync(join(ROOT_DIR, 'PRIVACY.txt'), 'utf-8'); + +// Generate HTML pages +const licenseHtml = generateHtmlPage('License Terms and Conditions', licenseContent); +const privacyHtml = generateHtmlPage('Privacy Policy', privacyContent); + +// Write to webpage directory +writeFileSync(join(WEBPAGE_DIR, 'license.html'), licenseHtml); +writeFileSync(join(WEBPAGE_DIR, 'privacy.html'), privacyHtml); + +console.log('Generated legal pages:'); +console.log(' - webpage/license.html'); +console.log(' - webpage/privacy.html'); diff --git a/scripts/patch-build.ts b/scripts/patch-build.ts new file mode 100644 index 0000000..e10ecbc --- /dev/null +++ b/scripts/patch-build.ts @@ -0,0 +1,575 @@ +/** + * Post-build script to fix svelte-adapter-bun WebSocket issue + * The adapter calls server.websocket() which doesn't exist in SvelteKit. + * + * IMPORTANT: Terminal WebSocket logic is shared with vite.config.ts + * Core functions like resolveDockerTarget are defined in: + * src/lib/server/ws-terminal-shared.ts + * + * When updating WebSocket terminal handling, update the shared module + * and this file will use the same logic at build time. + */ + +import { join } from 'node:path'; + +const BUILD_DIR = join(import.meta.dir, '../build'); + +async function patchHandler() { + const handlerPath = join(BUILD_DIR, 'handler.js'); + const handlerFile = Bun.file(handlerPath); + + if (!await handlerFile.exists()) { + console.error('handler.js not found'); + process.exit(1); + } + + let content = await handlerFile.text(); + + // Replace broken server.websocket() call + content = content.replace( + 'const websocket = server.websocket();', + 'const websocket = null;' + ); + + // Add WebSocket upgrade detection before ssr handler + const ssrIndex = content.indexOf('var ssr = async (request, bunServer) => {'); + if (ssrIndex > -1) { + const upgradeCode = ` +var handleUpgrade = (request, bunServer) => { + const url = new URL(request.url); + const isUpgrade = request.headers.get('connection')?.toLowerCase().includes('upgrade') && + request.headers.get('upgrade')?.toLowerCase() === 'websocket'; + if (!isUpgrade) return null; + + // Handle terminal exec WebSocket + if (url.pathname.includes('/api/containers/') && url.pathname.includes('/exec')) { + const pathParts = url.pathname.split('/'); + const containerIdIndex = pathParts.indexOf('containers') + 1; + const containerId = pathParts[containerIdIndex]; + const shell = url.searchParams.get('shell') || '/bin/sh'; + const user = url.searchParams.get('user') || 'root'; + const envId = url.searchParams.get('envId') ? parseInt(url.searchParams.get('envId'), 10) : undefined; + if (bunServer.upgrade(request, { data: { type: 'terminal', containerId, shell, user, envId } })) { + return new Response(null, { status: 101 }); + } + } + + // Handle Hawser Edge WebSocket + if (url.pathname === '/api/hawser/connect') { + if (bunServer.upgrade(request, { data: { type: 'hawser' } })) { + return new Response(null, { status: 101 }); + } + } + + return null; +}; +`; + content = content.slice(0, ssrIndex) + upgradeCode + content.slice(ssrIndex); + } + + // Modify handler to check for upgrade first + content = content.replace( + 'return ssr(request, server2);', + 'const upgradeResponse = handleUpgrade(request, server2); if (upgradeResponse) return upgradeResponse; return ssr(request, server2);' + ); + + await Bun.write(handlerPath, content); + console.log('✓ Patched handler.js'); +} + +async function patchIndex() { + const indexPath = join(BUILD_DIR, 'index.js'); + const indexFile = Bun.file(indexPath); + + if (!await indexFile.exists()) { + console.error('index.js not found'); + process.exit(1); + } + + let content = await indexFile.text(); + + const wsHandler = ` +import { existsSync as _existsSync } from 'fs'; +import { homedir as _homedir } from 'os'; +import { Database as _Database } from 'bun:sqlite'; +import { SQL as _SQL } from 'bun'; +import { join as _join } from 'path'; + +// Database connection (supports both SQLite and PostgreSQL) +let _db = null; +let _isPostgres = false; +function _getDb() { + if (!_db) { + const dbUrl = process.env.DATABASE_URL; + if (dbUrl && (dbUrl.startsWith('postgres://') || dbUrl.startsWith('postgresql://'))) { + _db = new _SQL(dbUrl); + _isPostgres = true; + } else { + const _dbPath = _join(process.cwd(), 'data', 'db', 'dockhand.db'); + if (_existsSync(_dbPath)) { + _db = new _Database(_dbPath); + } + } + } + return _db; +} + +async function _getEnvironment(id) { + const db = _getDb(); + if (!db) return null; + let row; + if (_isPostgres) { + const result = await db.unsafe('SELECT * FROM environments WHERE id = $1', [id]); + row = result[0]; + } else { + row = db.prepare('SELECT * FROM environments WHERE id = ?').get(id); + } + return row ? { ...row, is_local: Boolean(row.is_local), connection_type: row.connection_type, hawser_token: row.hawser_token } : null; +} + +function detectDockerSocket() { + if (process.env.DOCKER_SOCKET && _existsSync(process.env.DOCKER_SOCKET)) return process.env.DOCKER_SOCKET; + if (process.env.DOCKER_HOST?.startsWith('unix://')) { + const p = process.env.DOCKER_HOST.replace('unix://', ''); + if (_existsSync(p)) return p; + } + for (const s of ['/var/run/docker.sock', _homedir() + '/.docker/run/docker.sock', _homedir() + '/.orbstack/run/docker.sock', '/run/docker.sock']) { + if (_existsSync(s)) return s; + } + return '/var/run/docker.sock'; +} +const dockerSocketPath = detectDockerSocket(); +console.log('Detected Docker socket at:', dockerSocketPath); + +const dockerStreams = new Map(); +let _wsConnCounter = 0; + +async function _getDockerTarget(envId) { + if (!envId) return { type: 'unix', socket: dockerSocketPath }; + const env = await _getEnvironment(envId); + if (!env) return { type: 'unix', socket: dockerSocketPath }; + // Check for socket connection type (local Unix socket) + if (env.is_local || env.connection_type === 'socket' || !env.connection_type) { + return { type: 'unix', socket: env.socket_path || dockerSocketPath }; + } + if (env.connection_type === 'hawser-edge') return { type: 'hawser-edge', environmentId: envId }; + return { type: 'tcp', host: env.host, port: env.port || 2375, hawserToken: env.connection_type === 'hawser-standard' ? env.hawser_token : undefined }; +} + +async function createExec(containerId, cmd, user, target) { + const headers = { 'Content-Type': 'application/json' }; + const fetchOpts = { + method: 'POST', + headers, + body: JSON.stringify({ AttachStdin: true, AttachStdout: true, AttachStderr: true, Tty: true, Cmd: cmd, User: user }) + }; + let url; + if (target.type === 'unix') { + url = 'http://localhost/containers/' + containerId + '/exec'; + fetchOpts.unix = target.socket; + } else { + url = 'http://' + target.host + ':' + target.port + '/containers/' + containerId + '/exec'; + if (target.hawserToken) headers['X-Hawser-Token'] = target.hawserToken; + } + const res = await fetch(url, fetchOpts); + if (!res.ok) throw new Error('Failed to create exec: ' + (await res.text())); + return res.json(); +} + +async function resizeExec(execId, cols, rows, target) { + try { + const fetchOpts = { method: 'POST' }; + let url; + if (target.type === 'unix') { + url = 'http://localhost/exec/' + execId + '/resize?h=' + rows + '&w=' + cols; + fetchOpts.unix = target.socket; + } else { + url = 'http://' + target.host + ':' + target.port + '/exec/' + execId + '/resize?h=' + rows + '&w=' + cols; + if (target.hawserToken) fetchOpts.headers = { 'X-Hawser-Token': target.hawserToken }; + } + await fetch(url, fetchOpts); + } catch {} +} + +// ============ Hawser Edge Support ============ +// Global edge connections map (shared with hawser.ts via globalThis) +if (!globalThis.__hawserEdgeConnections) globalThis.__hawserEdgeConnections = new Map(); +const _edgeConnections = globalThis.__hawserEdgeConnections; + +// Map WebSocket to environmentId for quick lookup +const _wsToEnvId = new Map(); + +// Edge exec sessions (execId -> frontend WebSocket) +const _edgeExecSessions = new Map(); + +// Validate Hawser token against database +async function _validateHawserToken(token) { + const db = _getDb(); + if (!db) return { valid: false }; + let tokens; + if (_isPostgres) { + tokens = await db.unsafe('SELECT * FROM hawser_tokens WHERE is_active = true'); + } else { + tokens = db.prepare('SELECT * FROM hawser_tokens WHERE is_active = 1').all(); + } + for (const t of tokens) { + try { + const isValid = await Bun.password.verify(token, t.token); + if (isValid) { + if (_isPostgres) { + await db.unsafe('UPDATE hawser_tokens SET last_used = NOW() WHERE id = $1', [t.id]); + } else { + db.prepare('UPDATE hawser_tokens SET last_used = datetime(\\'now\\') WHERE id = ?').run(t.id); + } + return { valid: true, environmentId: t.environment_id, tokenId: t.id }; + } + } catch {} + } + return { valid: false }; +} + +// Update environment status in database +async function _updateEnvStatus(envId, conn) { + const db = _getDb(); + if (!db) return; + try { + if (conn) { + if (_isPostgres) { + await db.unsafe('UPDATE environments SET hawser_last_seen = NOW(), hawser_agent_id = $1, hawser_agent_name = $2, hawser_version = $3, hawser_capabilities = $4 WHERE id = $5', + [conn.agentId, conn.agentName, conn.agentVersion, JSON.stringify(conn.capabilities || []), envId]); + } else { + db.prepare('UPDATE environments SET hawser_last_seen = datetime(\\'now\\'), hawser_agent_id = ?, hawser_agent_name = ?, hawser_version = ?, hawser_capabilities = ? WHERE id = ?') + .run(conn.agentId, conn.agentName, conn.agentVersion, JSON.stringify(conn.capabilities || []), envId); + } + } else { + if (_isPostgres) { + await db.unsafe('UPDATE environments SET hawser_last_seen = NOW() WHERE id = $1', [envId]); + } else { + db.prepare('UPDATE environments SET hawser_last_seen = datetime(\\'now\\') WHERE id = ?').run(envId); + } + } + } catch {} +} + +// Handle Hawser Edge protocol messages +async function _handleHawserMessage(ws, msg) { + if (msg.type === 'hello') { + console.log('[Hawser] Hello from agent:', msg.agentName, '(' + msg.agentId + ')'); + const validation = await _validateHawserToken(msg.token); + if (!validation.valid) { + console.log('[Hawser] Invalid token'); + ws.send(JSON.stringify({ type: 'error', error: 'Invalid token' })); + ws.close(); + return; + } + const envId = validation.environmentId; + const existing = _edgeConnections.get(envId); + if (existing) { + const pendingCount = existing.pendingRequests.size; + const streamCount = existing.pendingStreamRequests.size; + console.log('[Hawser] Replacing existing connection for env', envId, '- rejecting', pendingCount, 'pending requests and', streamCount, 'stream requests'); + // Reject all pending requests before closing + for (const [requestId, pending] of existing.pendingRequests) { + clearTimeout(pending.timeout); + pending.reject(new Error('Connection replaced by new agent')); + } + for (const [requestId, pending] of existing.pendingStreamRequests) { + pending.onEnd?.('Connection replaced by new agent'); + } + existing.pendingRequests.clear(); + existing.pendingStreamRequests.clear(); + existing.ws.close(1000, 'Replaced'); + _wsToEnvId.delete(existing.ws); + } + const conn = { + ws, environmentId: envId, agentId: msg.agentId, agentName: msg.agentName, + agentVersion: msg.version || 'unknown', dockerVersion: msg.dockerVersion || 'unknown', + hostname: msg.hostname || 'unknown', capabilities: msg.capabilities || [], + connectedAt: new Date(), lastHeartbeat: new Date(), + pendingRequests: new Map(), pendingStreamRequests: new Map(), + pingInterval: null + }; + _edgeConnections.set(envId, conn); + _wsToEnvId.set(ws, envId); + await _updateEnvStatus(envId, conn); + ws.send(JSON.stringify({ type: 'welcome', environmentId: envId, message: 'Connected to Dockhand' })); + // Start server-side ping interval to keep connection alive through Traefik/proxies (5s) + conn.pingInterval = setInterval(() => { + try { ws.send(JSON.stringify({ type: 'ping', timestamp: Date.now() })); } + catch { if (conn.pingInterval) { clearInterval(conn.pingInterval); conn.pingInterval = null; } } + }, 5000); + console.log('[Hawser] Agent', msg.agentName, 'connected for env', envId); + } else if (msg.type === 'ping') { + const envId = _wsToEnvId.get(ws); + if (envId) { const c = _edgeConnections.get(envId); if (c) c.lastHeartbeat = new Date(); } + ws.send(JSON.stringify({ type: 'pong', timestamp: Date.now() })); + } else if (msg.type === 'pong') { + const envId = _wsToEnvId.get(ws); + if (envId) { const c = _edgeConnections.get(envId); if (c) c.lastHeartbeat = new Date(); } + } else if (msg.type === 'response') { + const envId = _wsToEnvId.get(ws); + if (!envId) { + console.warn('[Hawser] Response from unknown WebSocket, requestId=' + msg.requestId); + return; + } + const conn = _edgeConnections.get(envId); + if (conn) { + const pending = conn.pendingRequests.get(msg.requestId); + if (pending) { + clearTimeout(pending.timeout); + conn.pendingRequests.delete(msg.requestId); + pending.resolve({ statusCode: msg.statusCode, headers: msg.headers || {}, body: msg.body || '', isBinary: msg.isBinary || false }); + } else { + console.warn('[Hawser] Response for unknown request ' + msg.requestId + ' on env ' + envId); + } + } + } else if (msg.type === 'stream') { + const envId = _wsToEnvId.get(ws); + if (!envId) { + console.warn('[Hawser] Stream data from unknown WebSocket, requestId=' + msg.requestId); + return; + } + const conn = _edgeConnections.get(envId); + if (conn?.pendingStreamRequests) { + const pending = conn.pendingStreamRequests.get(msg.requestId); + if (pending) { + pending.onData(msg.data, msg.stream); + } else { + console.warn('[Hawser] Stream data for unknown request ' + msg.requestId + ' on env ' + envId); + } + } + } else if (msg.type === 'stream_end') { + const envId = _wsToEnvId.get(ws); + if (!envId) { + console.warn('[Hawser] Stream end from unknown WebSocket, requestId=' + msg.requestId); + return; + } + const conn = _edgeConnections.get(envId); + if (conn?.pendingStreamRequests) { + const pending = conn.pendingStreamRequests.get(msg.requestId); + if (pending) { + conn.pendingStreamRequests.delete(msg.requestId); + pending.onEnd(msg.reason); + } else { + console.warn('[Hawser] Stream end for unknown request ' + msg.requestId + ' on env ' + envId); + } + } + } else if (msg.type === 'exec_ready') { + const session = _edgeExecSessions.get(msg.execId); + if (session?.ws?.readyState === 1) console.log('[Hawser] Exec ready:', msg.execId); + } else if (msg.type === 'exec_output') { + const session = _edgeExecSessions.get(msg.execId); + if (session?.ws?.readyState === 1) { + const data = Buffer.from(msg.data, 'base64').toString('utf-8'); + session.ws.send(JSON.stringify({ type: 'output', data })); + } + } else if (msg.type === 'exec_end') { + const session = _edgeExecSessions.get(msg.execId); + if (session) { + console.log('[Hawser] Exec ended:', msg.execId); + if (session.ws?.readyState === 1) { session.ws.send(JSON.stringify({ type: 'exit' })); session.ws.close(); } + _edgeExecSessions.delete(msg.execId); + } + } else if (msg.type === 'container_event') { + const envId = _wsToEnvId.get(ws); + if (envId && msg.event) { + // Call the global handler registered by hawser.ts + if (globalThis.__hawserHandleContainerEvent) { + globalThis.__hawserHandleContainerEvent(envId, msg.event).catch((err) => { + console.error('[Hawser] Error handling container event:', err); + }); + } + } + } else if (msg.type === 'metrics') { + // Metrics from agent - save to database for dashboard graphs + const envId = _wsToEnvId.get(ws); + if (envId && msg.metrics) { + if (globalThis.__hawserHandleMetrics) { + globalThis.__hawserHandleMetrics(envId, msg.metrics).catch((err) => { + console.error('[Hawser] Error saving metrics:', err); + }); + } + } + } +} + +// Expose send function for hawser.ts module +globalThis.__hawserSendMessage = (envId, message) => { + const conn = _edgeConnections.get(envId); + if (!conn?.ws) return false; + try { conn.ws.send(message); return true; } catch { return false; } +}; + +// ============ Combined WebSocket Handler ============ +const combinedWebsocket = { + async open(ws) { + const connType = ws.data?.type; + + // Hawser Edge connection - wait for hello message + if (connType === 'hawser') { + console.log('[Hawser] New connection pending authentication'); + return; + } + + // Terminal connection + const connId = 'ws-' + (++_wsConnCounter); + ws.data = ws.data || {}; + ws.data.connId = connId; + const { containerId, shell, user, envId } = ws.data; + if (!containerId) { ws.send(JSON.stringify({ type: 'error', message: 'No container ID' })); ws.close(); return; } + const target = await _getDockerTarget(envId); + console.log('[WS] Open:', connId, containerId, 'target:', target.type); + + // Handle Hawser Edge terminal + if (target.type === 'hawser-edge') { + const conn = _edgeConnections.get(target.environmentId); + if (!conn) { ws.send(JSON.stringify({ type: 'error', message: 'Edge agent not connected' })); ws.close(); return; } + const execId = crypto.randomUUID(); + _edgeExecSessions.set(execId, { ws, execId, environmentId: target.environmentId }); + ws.data.edgeExecId = execId; + conn.ws.send(JSON.stringify({ type: 'exec_start', execId, containerId, cmd: shell || '/bin/sh', user: user || 'root', cols: 120, rows: 30 })); + return; + } + + try { + const exec = await createExec(containerId, [shell || '/bin/sh'], user || 'root', target); + const execId = exec.Id; + let dockerStream; + let headersStripped = false; + let isChunked = false; + const socketHandler = { + data(socket, data) { + if (ws.readyState === 1) { + let text = new TextDecoder().decode(data); + if (!headersStripped) { + if (text.toLowerCase().includes('transfer-encoding: chunked')) isChunked = true; + const i = text.indexOf('\\r\\n\\r\\n'); + if (i > -1) { text = text.slice(i + 4); headersStripped = true; } + else if (text.startsWith('HTTP/')) return; + } + if (isChunked && text) text = text.replace(/^[0-9a-fA-F]+\\r\\n/gm, '').replace(/\\r\\n$/g, ''); + if (text) ws.send(JSON.stringify({ type: 'output', data: text })); + } + }, + close() { if (ws.readyState === 1) { ws.send(JSON.stringify({ type: 'exit' })); ws.close(); } }, + error() {}, + open(socket) { + const body = JSON.stringify({ Detach: false, Tty: true }); + const tokenHeader = target.type === 'tcp' && target.hawserToken ? 'X-Hawser-Token: ' + target.hawserToken + '\\r\\n' : ''; + socket.write('POST /exec/' + execId + '/start HTTP/1.1\\r\\nHost: localhost\\r\\nContent-Type: application/json\\r\\n' + tokenHeader + 'Connection: Upgrade\\r\\nUpgrade: tcp\\r\\nContent-Length: ' + body.length + '\\r\\n\\r\\n' + body); + } + }; + if (target.type === 'unix') { + dockerStream = await Bun.connect({ unix: target.socket, socket: socketHandler }); + } else { + dockerStream = await Bun.connect({ hostname: target.host, port: target.port, socket: socketHandler }); + } + dockerStreams.set(connId, { stream: dockerStream, execId, target }); + } catch (e) { ws.send(JSON.stringify({ type: 'error', message: e.message })); ws.close(); } + }, + async message(ws, message) { + const connType = ws.data?.type; + + // Hawser Edge message + if (connType === 'hawser') { + try { + let msgStr = typeof message === 'string' ? message : message instanceof ArrayBuffer ? new TextDecoder().decode(message) : Buffer.isBuffer(message) ? message.toString('utf-8') : new TextDecoder().decode(new Uint8Array(message)); + const msg = JSON.parse(msgStr); + await _handleHawserMessage(ws, msg); + } catch (e) { + console.error('[Hawser] Error:', e.message); + ws.send(JSON.stringify({ type: 'error', error: e.message })); + } + return; + } + + // Edge exec session input + const edgeExecId = ws.data?.edgeExecId; + if (edgeExecId) { + const session = _edgeExecSessions.get(edgeExecId); + if (session) { + const conn = _edgeConnections.get(session.environmentId); + if (conn) { + try { + const msg = JSON.parse(message.toString()); + if (msg.type === 'input') conn.ws.send(JSON.stringify({ type: 'exec_input', execId: edgeExecId, data: Buffer.from(msg.data).toString('base64') })); + else if (msg.type === 'resize') conn.ws.send(JSON.stringify({ type: 'exec_resize', execId: edgeExecId, cols: msg.cols, rows: msg.rows })); + } catch {} + } + } + return; + } + + // Terminal message + const connId = ws.data?.connId; + if (!connId) return; + const d = dockerStreams.get(connId); + if (!d) return; + try { + const msg = JSON.parse(message.toString()); + if (msg.type === 'input' && d.stream) d.stream.write(msg.data); + else if (msg.type === 'resize' && d.execId) resizeExec(d.execId, msg.cols, msg.rows, d.target); + } catch { if (d.stream) d.stream.write(message); } + }, + close(ws) { + const connType = ws.data?.type; + + // Hawser Edge disconnection + if (connType === 'hawser') { + const envId = _wsToEnvId.get(ws); + if (envId) { + const conn = _edgeConnections.get(envId); + if (conn) { + console.log('[Hawser] Agent disconnected:', conn.agentId); + // Clear server-side ping interval + if (conn.pingInterval) { clearInterval(conn.pingInterval); conn.pingInterval = null; } + for (const [, p] of conn.pendingRequests) { clearTimeout(p.timeout); p.reject(new Error('Connection closed')); } + for (const [, p] of conn.pendingStreamRequests) { p.onEnd('Connection closed'); } + _edgeConnections.delete(envId); + _updateEnvStatus(envId, null); + } + _wsToEnvId.delete(ws); + } + return; + } + + // Edge exec session close + const edgeExecId = ws.data?.edgeExecId; + if (edgeExecId) { + const session = _edgeExecSessions.get(edgeExecId); + if (session) { + const conn = _edgeConnections.get(session.environmentId); + if (conn) conn.ws.send(JSON.stringify({ type: 'exec_end', execId: edgeExecId, reason: 'user_closed' })); + _edgeExecSessions.delete(edgeExecId); + } + return; + } + + // Terminal close + const connId = ws.data?.connId; + if (!connId) return; + const d = dockerStreams.get(connId); + if (d?.stream) d.stream.end(); + dockerStreams.delete(connId); + } +}; +`; + + const insertPoint = content.indexOf('var path = env('); + if (insertPoint > -1) { + content = content.slice(0, insertPoint) + wsHandler + content.slice(insertPoint); + } + + content = content.replace( + 'var { fetch: handlerFetch, websocket } = getHandler();', + 'var { fetch: handlerFetch, websocket: _ } = getHandler(); var websocket = combinedWebsocket;' + ); + + await Bun.write(indexPath, content); + console.log('✓ Patched index.js'); +} + +console.log('Patching build...'); +await patchHandler(); +await patchIndex(); +console.log('✓ Done');