Compare commits
No commits in common. "main" and "claude/2" have entirely different histories.
@ -1 +0,0 @@
|
|||||||
{"sessionId":"9cea077c-56da-4223-a765-be38c688106b","pid":1384,"procStart":"1332","acquiredAt":1780168110981}
|
|
||||||
10
.gitattributes
vendored
10
.gitattributes
vendored
@ -1,10 +0,0 @@
|
|||||||
# Paths excluded from release tarballs. `git archive` (used by
|
|
||||||
# scripts/release/make_release.sh) honours `export-ignore`, so these dev-only
|
|
||||||
# trees never ship in libreportal-<ver>.tar.gz.
|
|
||||||
scripts/unused export-ignore
|
|
||||||
scripts/release export-ignore
|
|
||||||
site export-ignore
|
|
||||||
docs export-ignore
|
|
||||||
.claude export-ignore
|
|
||||||
.gitignore export-ignore
|
|
||||||
.gitattributes export-ignore
|
|
||||||
11
.gitignore
vendored
11
.gitignore
vendored
@ -10,14 +10,3 @@
|
|||||||
# Node dependencies — installed via `npm ci` at image build, never vendored.
|
# Node dependencies — installed via `npm ci` at image build, never vendored.
|
||||||
node_modules/
|
node_modules/
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
|
|
||||||
# Release build output (scripts/release/make_release.sh).
|
|
||||||
/dist/
|
|
||||||
|
|
||||||
# Eleventy site build output + generator-produced data (scripts/gen-data.mjs).
|
|
||||||
# site/ is the legacy website location (active site now lives in
|
|
||||||
# containers/weblibreportal); these are build artifacts, not source.
|
|
||||||
site/dist/
|
|
||||||
site/src/_data/apps.json
|
|
||||||
site/src/_data/categories.json
|
|
||||||
|
|
||||||
|
|||||||
19
CLAUDE.md
19
CLAUDE.md
@ -1,19 +0,0 @@
|
|||||||
# LibrePortal — agent notes
|
|
||||||
|
|
||||||
## Verify WebUI changes visually before marking them done
|
|
||||||
After changing anything user-visible in the WebUI (`containers/libreportal/frontend/`),
|
|
||||||
confirm it actually renders correctly — syntax checks and type-correctness don't
|
|
||||||
catch layout or visual regressions.
|
|
||||||
|
|
||||||
The maintainer's dev environment provides a headless screenshot helper, `lp-shot`,
|
|
||||||
that captures a WebUI route (or a single element, via a trailing CSS selector) to a
|
|
||||||
PNG for review:
|
|
||||||
|
|
||||||
```
|
|
||||||
lp-shot /admin/system # full route -> /tmp/webui-shot.png
|
|
||||||
lp-shot /admin/system /tmp/x.png 12 ".sys-strip" # just one element, crisp
|
|
||||||
```
|
|
||||||
|
|
||||||
Use it (and read the PNG) to self-check UI work instead of assuming it looks right or
|
|
||||||
asking the user to look. Skip it for purely backend/non-visual edits. If `lp-shot`
|
|
||||||
isn't present, fall back to asking the user for a screenshot.
|
|
||||||
@ -7,7 +7,7 @@ to you — in plain language, so you can hold us to it.
|
|||||||
|
|
||||||
You can **run, study, modify, share, and fully use 100% of LibrePortal —
|
You can **run, study, modify, share, and fully use 100% of LibrePortal —
|
||||||
every feature — for free, forever.** The entire platform is licensed under
|
every feature — for free, forever.** The entire platform is licensed under
|
||||||
the GNU AGPLv3 (see [LICENSE](../../LICENSE)). There are **no feature paywalls in
|
the GNU AGPLv3 (see [LICENSE](LICENSE)). There are **no feature paywalls in
|
||||||
the software, no crippled "community edition," and no telemetry** phoning
|
the software, no crippled "community edition," and no telemetry** phoning
|
||||||
home.
|
home.
|
||||||
|
|
||||||
23
README.md
23
README.md
@ -21,7 +21,7 @@ toggle — it's the whole point.
|
|||||||
|
|
||||||
The entire platform is **free software under the [GNU AGPLv3](LICENSE)**.
|
The entire platform is **free software under the [GNU AGPLv3](LICENSE)**.
|
||||||
Self-host it and you get **everything** — every feature, no paywalls, no
|
Self-host it and you get **everything** — every feature, no paywalls, no
|
||||||
telemetry. See [our Promise](docs/guide/promise.md) for exactly what that means.
|
telemetry. See [our Promise](PROMISE.md) for exactly what that means.
|
||||||
|
|
||||||
## What you get
|
## What you get
|
||||||
|
|
||||||
@ -34,22 +34,11 @@ telemetry. See [our Promise](docs/guide/promise.md) for exactly what that means.
|
|||||||
## Quick start
|
## Quick start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -fsSL https://get.libreportal.org/install.sh | sudo bash
|
git clone https://gitea.scottwebstar.co.uk/Webstar/LibrePortal.git
|
||||||
|
cd LibrePortal
|
||||||
|
./init.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
This installs a versioned, checksum-verified release (Debian/Ubuntu, root). Put
|
|
||||||
data on separate disks with `--system-dir=` / `--containers-dir=` / `--backups-dir=`.
|
|
||||||
|
|
||||||
> The `get.libreportal.org` host is still being set up — until it's live, build a
|
|
||||||
> release and install from it locally (see the docs below).
|
|
||||||
|
|
||||||
## Documentation
|
|
||||||
|
|
||||||
- **[docs/guide/install-and-use.md](docs/guide/install-and-use.md)** — install, place data on separate disks/drives,
|
|
||||||
update, back up, uninstall.
|
|
||||||
- **[docs/contributing/development.md](docs/contributing/development.md)** — run a dev copy, cut stable/edge
|
|
||||||
releases, and test them before publishing.
|
|
||||||
|
|
||||||
## LibrePortal Connect (optional)
|
## LibrePortal Connect (optional)
|
||||||
|
|
||||||
Self-hosting is free and complete. If you'd rather not fiddle with the tricky
|
Self-hosting is free and complete. If you'd rather not fiddle with the tricky
|
||||||
@ -59,11 +48,11 @@ different: we work like a **courier carrying a sealed box.** We move your data
|
|||||||
between your devices and store backup copies, but it stays locked and *you*
|
between your devices and store backup copies, but it stays locked and *you*
|
||||||
hold the only key — we can't open it, and we never run your apps for you.
|
hold the only key — we can't open it, and we never run your apps for you.
|
||||||
**Everything we offer, you can also set up yourself for free.**
|
**Everything we offer, you can also set up yourself for free.**
|
||||||
[Our Promise](docs/guide/promise.md) spells out exactly where that line sits.
|
[Our Promise](PROMISE.md) spells out exactly where that line sits.
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
PRs welcome — see [CONTRIBUTING.md](docs/contributing/contributing.md). We use a lightweight
|
PRs welcome — see [CONTRIBUTING.md](CONTRIBUTING.md). We use a lightweight
|
||||||
DCO sign-off (`git commit -s`), no CLA.
|
DCO sign-off (`git commit -s`), no CLA.
|
||||||
|
|
||||||
## Acknowledgments
|
## Acknowledgments
|
||||||
|
|||||||
@ -2,4 +2,4 @@ TITLE=Backup
|
|||||||
DESCRIPTION=Backup schedules, retention, and engine settings
|
DESCRIPTION=Backup schedules, retention, and engine settings
|
||||||
ICON=backup
|
ICON=backup
|
||||||
ORDER=3
|
ORDER=3
|
||||||
SUBCATEGORY_ORDER=backup_general,backup_retention,backup_engine
|
SUBCATEGORY_ORDER=backup_general,backup_retention,backup_advanced
|
||||||
|
|||||||
@ -1,9 +1,8 @@
|
|||||||
# ================================================================================
|
# ================================================================================
|
||||||
# Backup Engine - **ADVANCED** Engine-level knobs most users won't need to touch
|
# Backup Advanced - **ADVANCED** Engine-level knobs most users won't need to touch
|
||||||
# ================================================================================
|
# ================================================================================
|
||||||
CFG_BACKUP_ENGINE=restic # Default Backup Engine - Fallback engine for new locations (each location can override) [restic:Restic|borg:BorgBackup|kopia:Kopia]
|
CFG_BACKUP_ENGINE=restic # Default Backup Engine - Fallback engine for new locations (each location can override) [restic:restic|borg:BorgBackup|kopia:Kopia]
|
||||||
CFG_BACKUP_DEFAULT_PATH= # Default Backup Location - Base directory for locations set to Automatic path mode; each location lives in its own numbered subfolder (<path>/<id>). Empty = the LibrePortal backups root (own mount-able).
|
CFG_BACKUP_STRATEGY=stop-snapshot-start # Backup Strategy - How containers are quiesced before snapshotting [stop-snapshot-start:Stop → snapshot → start (safe default)|pause-snapshot-unpause:Pause → snapshot → unpause (less downtime)|live:Live — snapshot while running (only with DB dump hooks)]
|
||||||
CFG_BACKUP_STRATEGY=auto # Backup Strategy - How containers are quiesced before snapshotting [auto:Automatic — live where safe, stop otherwise (recommended)|stop-snapshot-start:Stop → snapshot → start (always safe)|pause-snapshot-unpause:Pause → snapshot → unpause (less downtime)|live:Live — snapshot while running (force)]
|
|
||||||
CFG_BACKUP_VERIFY_AFTER=true # Verify After Backup - Run integrity check after each backup
|
CFG_BACKUP_VERIFY_AFTER=true # Verify After Backup - Run integrity check after each backup
|
||||||
CFG_BACKUP_VERIFY_DATA_PERCENT=5 # Verify Data Sample % - Percentage of repo data to checksum-verify weekly
|
CFG_BACKUP_VERIFY_DATA_PERCENT=5 # Verify Data Sample % - Percentage of repo data to checksum-verify weekly
|
||||||
CFG_BACKUP_PARALLEL_REPOS=true # Parallel Repos - Push to all enabled locations in parallel
|
CFG_BACKUP_PARALLEL_REPOS=true # Parallel Repos - Push to all enabled locations in parallel
|
||||||
@ -2,3 +2,4 @@
|
|||||||
# Backup General - Scheduling
|
# Backup General - Scheduling
|
||||||
# ================================================================================
|
# ================================================================================
|
||||||
CFG_BACKUP_CRONTAB_APP="0 5 * * *" # App Backup Schedule - Crontab schedule for application backups
|
CFG_BACKUP_CRONTAB_APP="0 5 * * *" # App Backup Schedule - Crontab schedule for application backups
|
||||||
|
CFG_BACKUP_CRONTAB_APP_INTERVAL=3 # App Backup Interval - Minutes between app backup checks
|
||||||
|
|||||||
@ -2,23 +2,23 @@
|
|||||||
# Edit via the Locations page on /backup, or directly here.
|
# Edit via the Locations page on /backup, or directly here.
|
||||||
CFG_BACKUP_LOC_1_NAME="Local disk" # Location Name - Friendly label shown in the UI
|
CFG_BACKUP_LOC_1_NAME="Local disk" # Location Name - Friendly label shown in the UI
|
||||||
CFG_BACKUP_LOC_1_ENABLED=true # Enabled - Snapshot to this location
|
CFG_BACKUP_LOC_1_ENABLED=true # Enabled - Snapshot to this location
|
||||||
CFG_BACKUP_LOC_1_ENGINE=restic # Engine - Backup engine used at this location [restic:Restic|borg:BorgBackup|kopia:Kopia] **ADVANCED**
|
CFG_BACKUP_LOC_1_ENGINE=restic # Engine - Backup engine used at this location [restic:restic|borg:BorgBackup|kopia:Kopia]
|
||||||
CFG_BACKUP_LOC_1_PASSWORD=RANDOMIZEDPASSWORD1 # Repository Password - Used to encrypt/decrypt snapshots — back up offline!
|
CFG_BACKUP_LOC_1_PASSWORD=RANDOMIZEDPASSWORD1 # Repository Password - Used to encrypt/decrypt snapshots — back up offline!
|
||||||
CFG_BACKUP_LOC_1_TYPE=local # Type - Backend [local:Local / mounted path|sftp:SFTP|rest:REST|s3:S3|b2:Backblaze B2|gs:Google Cloud Storage|azure:Azure|rclone:rclone]
|
CFG_BACKUP_LOC_1_TYPE=local # Type - Backend [local:Local / mounted path|sftp:SFTP|rest:REST|s3:S3|b2:Backblaze B2|gs:Google Cloud Storage|azure:Azure|rclone:rclone]
|
||||||
CFG_BACKUP_LOC_1_PATH_MODE=auto # Path Mode - Automatic uses the Default Backup Location from the Backup Engine config (one subfolder per location); Custom uses the path below [auto:Automatic|custom:Custom path]
|
CFG_BACKUP_LOC_1_PATH_MODE=auto # Path Mode - Where this location stores its data [auto:Automatic (/docker/backups/<id>)|custom:Custom path]
|
||||||
CFG_BACKUP_LOC_1_PATH= # Custom Path - Filesystem path on this server (used when Path Mode = Custom)
|
CFG_BACKUP_LOC_1_PATH= # Custom Path - Filesystem path on this server (used when Path Mode = Custom)
|
||||||
CFG_BACKUP_LOC_1_URI= # URI Override - Custom restic URI (leave blank to build from the fields below) **ADVANCED**
|
CFG_BACKUP_LOC_1_URI= # URI Override - Custom restic URI (leave blank to build from the fields below)
|
||||||
CFG_BACKUP_LOC_1_SSH_USER= # SSH User - For sftp type
|
CFG_BACKUP_LOC_1_SSH_USER= # SSH User - For sftp type
|
||||||
CFG_BACKUP_LOC_1_SSH_HOST= # SSH Host - For sftp type
|
CFG_BACKUP_LOC_1_SSH_HOST= # SSH Host - For sftp type
|
||||||
CFG_BACKUP_LOC_1_SSH_PORT=22 # SSH Port - For sftp type **ADVANCED**
|
CFG_BACKUP_LOC_1_SSH_PORT=22 # SSH Port - For sftp type
|
||||||
CFG_BACKUP_LOC_1_SSH_PATH= # SSH Remote Path - Path on the remote host where the repo lives
|
CFG_BACKUP_LOC_1_SSH_PATH= # SSH Remote Path - Path on the remote host where the repo lives
|
||||||
CFG_BACKUP_LOC_1_SSH_AUTH=key # SSH Authentication - [key:SSH key (managed by LibrePortal)|password:Password (via sshpass)]
|
CFG_BACKUP_LOC_1_SSH_AUTH=key # SSH Authentication - [key:SSH key (~/.ssh/id_rsa)|password:Password (via sshpass)]
|
||||||
CFG_BACKUP_LOC_1_SSH_PASS= # SSH Password - Used only when SSH Authentication is set to Password
|
CFG_BACKUP_LOC_1_SSH_PASS= # SSH Password - Used only when SSH Authentication is set to Password
|
||||||
CFG_BACKUP_LOC_1_S3_ACCESS_KEY= # S3 Access Key - For s3 type
|
CFG_BACKUP_LOC_1_S3_ACCESS_KEY= # S3 Access Key - For s3 type
|
||||||
CFG_BACKUP_LOC_1_S3_SECRET_KEY= # S3 Secret Key - For s3 type
|
CFG_BACKUP_LOC_1_S3_SECRET_KEY= # S3 Secret Key - For s3 type
|
||||||
CFG_BACKUP_LOC_1_B2_ACCOUNT_ID= # B2 Account ID - For b2 type
|
CFG_BACKUP_LOC_1_B2_ACCOUNT_ID= # B2 Account ID - For b2 type
|
||||||
CFG_BACKUP_LOC_1_B2_ACCOUNT_KEY= # B2 Account Key - For b2 type
|
CFG_BACKUP_LOC_1_B2_ACCOUNT_KEY= # B2 Account Key - For b2 type
|
||||||
CFG_BACKUP_LOC_1_APPEND_ONLY=false # Append-only - Refuse forget/prune for this location (ransomware-safe) **ADVANCED**
|
CFG_BACKUP_LOC_1_APPEND_ONLY=false # Append-only - Refuse forget/prune for this location (ransomware-safe)
|
||||||
CFG_BACKUP_LOC_1_CUSTOM_RETENTION=false # Custom Retention - Override the global retention for this location
|
CFG_BACKUP_LOC_1_CUSTOM_RETENTION=false # Custom Retention - Override the global retention for this location
|
||||||
CFG_BACKUP_LOC_1_KEEP_LAST= # Keep Last - Snapshots to always retain (blank = global)
|
CFG_BACKUP_LOC_1_KEEP_LAST= # Keep Last - Snapshots to always retain (blank = global)
|
||||||
CFG_BACKUP_LOC_1_KEEP_DAILY= # Keep Daily - Days (blank = global)
|
CFG_BACKUP_LOC_1_KEEP_DAILY= # Keep Daily - Days (blank = global)
|
||||||
|
|||||||
5
configs/features/.category
Executable file
5
configs/features/.category
Executable file
@ -0,0 +1,5 @@
|
|||||||
|
TITLE=Features
|
||||||
|
DESCRIPTION=Toggle system components and features
|
||||||
|
ICON=features
|
||||||
|
ORDER=5
|
||||||
|
SUBCATEGORY_ORDER=features_core,features_security,features_terminal
|
||||||
18
configs/features/features_core
Executable file
18
configs/features/features_core
Executable file
@ -0,0 +1,18 @@
|
|||||||
|
# ================================================================================
|
||||||
|
# Core Features - Essential LibrePortal functionality and core services
|
||||||
|
# ================================================================================
|
||||||
|
CFG_REQUIREMENT_CONFIG=true # Configuration Management - Enable configuration management system for LibrePortal settings
|
||||||
|
CFG_REQUIREMENT_COMMAND=true # Command Line Tool - Install the libreportal command line tool for system management
|
||||||
|
CFG_REQUIREMENT_WEBUI=true # Web Interface - Install and manage the LibrePortal web based management interface
|
||||||
|
CFG_REQUIREMENT_WEBUI_SERVICE=true # Web Task Service - Install the task management systemd service for the web interface
|
||||||
|
CFG_REQUIREMENT_DATABASE=true # Database Support - Install and configure database support for application data storage
|
||||||
|
CFG_REQUIREMENT_PASSWORDS=true # Password Management - Enable password generation and management features
|
||||||
|
CFG_REQUIREMENT_DOCKER_CE=true # Docker CE - Install Docker Community Edition instead of the default Docker version
|
||||||
|
CFG_REQUIREMENT_DOCKER_COMPOSE=true # Docker Compose - Install Docker Compose for multi container application management
|
||||||
|
CFG_REQUIREMENT_DOCKER_NETWORK=true # Docker Network - Create and manage Docker network for container communication
|
||||||
|
CFG_REQUIREMENT_UFW=true # Firewall Protection - Install and configure the Uncomplicated Firewall for system security
|
||||||
|
CFG_REQUIREMENT_UFWD=true # Docker Firewall - Install UFW Docker for container aware firewall management which is rooted Docker specific
|
||||||
|
CFG_REQUIREMENT_SSLCERTS=true # SSL Certificates - Generate and manage SSL certificates for secure HTTPS connections
|
||||||
|
CFG_REQUIREMENT_CRONTAB=true # Scheduled Tasks - Setup scheduled tasks and automated maintenance jobs
|
||||||
|
CFG_REQUIREMENT_WHITELIST_PORT_UPDATER=true # Auto Port Management - Automatically update port whitelist when applications are installed or removed
|
||||||
|
CFG_REQUIREMENT_BCRYPT_SAVE=true # Password Encryption - Encrypt saved passwords using bcrypt for enhanced security
|
||||||
7
configs/features/features_security
Executable file
7
configs/features/features_security
Executable file
@ -0,0 +1,7 @@
|
|||||||
|
# ================================================================================
|
||||||
|
# Security and Authentication - SSH access and security configuration
|
||||||
|
# ================================================================================
|
||||||
|
CFG_REQUIREMENT_SSHKEY_DOWNLOADER=false # SSH Key Downloader - Enable SSH key download functionality for remote access
|
||||||
|
CFG_REQUIREMENT_SSH_DISABLE_PASSWORDS=false # SSH Password Disable - Disable password authentication for SSH requiring key based access only
|
||||||
|
CFG_REQUIREMENT_GLUETUN_FOR_ALL=false # Gluetun For All Apps - Allow routing through Gluetun VPN for every app (default: only curated categories)
|
||||||
|
|
||||||
12
configs/features/features_terminal
Executable file
12
configs/features/features_terminal
Executable file
@ -0,0 +1,12 @@
|
|||||||
|
# ================================================================================
|
||||||
|
# Terminal Only - Advanced terminal based features and utilities **ADVANCED**
|
||||||
|
# ================================================================================
|
||||||
|
CFG_REQUIREMENT_SUGGEST_INSTALLS=false # Install Suggestions - Enable application suggestions and recommendations during installation
|
||||||
|
CFG_REQUIREMENT_SUGGEST_METRICS=true # Metrics Suggestions - Offer Prometheus and Grafana during first install (requires Install Suggestions enabled)
|
||||||
|
CFG_REQUIREMENT_CONTINUE_PROMPT=false # Continue Prompts - Show continue prompts during installation for user confirmation
|
||||||
|
CFG_REQUIREMENT_CONFIGS_CHECK=true # Config Validation - Validate configuration files on startup for errors and consistency
|
||||||
|
CFG_REQUIREMENT_CONFIGS_AUTO_UPDATE=true # Auto Config Updates - Add new config options from the template (non-interactive)
|
||||||
|
CFG_REQUIREMENT_CONFIGS_AUTO_DELETE=true # Auto Config Deletes - Remove config options no longer present in the template
|
||||||
|
CFG_REQUIREMENT_MISSING_IPS=false # IP Configuration Check - Check for and alert about missing IP configurations
|
||||||
|
CFG_REQUIREMENT_DOCKER_NETWORK_PRUNE=true # Docker Network Cleanup - Enable automatic cleanup of unused Docker networks
|
||||||
|
CFG_REQUIREMENT_DOCKER_SWITCHER=true # Docker Switcher - Install Docker version switching utility for managing multiple Docker versions
|
||||||
@ -3,4 +3,3 @@
|
|||||||
# ================================================================================
|
# ================================================================================
|
||||||
CFG_INSTALL_NAME=Change-Me # Installation Name - The name for your LibrePortal instance
|
CFG_INSTALL_NAME=Change-Me # Installation Name - The name for your LibrePortal instance
|
||||||
CFG_TIMEZONE=Etc/UTC # System Timezone - Timezone for scheduled tasks and logging timestamps
|
CFG_TIMEZONE=Etc/UTC # System Timezone - Timezone for scheduled tasks and logging timestamps
|
||||||
CFG_INSTALL_LEVEL=beginner # Experience Level - Beginner hides technical detail and skips advanced setup steps. Advanced reveals everything by default. Set during the first-run wizard; can be flipped any time via the Advanced toggle in the WebUI. [beginner:Beginner — simple|advanced:Advanced — show everything]
|
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
# ================================================================================
|
# ================================================================================
|
||||||
# Docker - Container runtime installation and configuration **ADVANCED**
|
# Docker - Container runtime installation and configuration **ADVANCED**
|
||||||
# ================================================================================
|
# ================================================================================
|
||||||
CFG_DOCKER_INSTALL_TYPE=rootless # Docker Installation Type - rootless (default, recommended): containers run unprivileged so a breakout isn't host root; rooted: legacy, containers run as root [rootless|rooted]
|
CFG_DOCKER_INSTALL_TYPE=rooted # Docker Installation Type - Security based setup rooted or rootless Docker installation [rooted|rootless]
|
||||||
CFG_DOCKER_INSTALL_USER=dockerinstall # Docker Install User - Username for Docker installation operations
|
CFG_DOCKER_INSTALL_USER=dockerinstall # Docker Install User - Username for Docker installation operations
|
||||||
CFG_DOCKER_INSTALL_PASS=RANDOMIZEDPASSWORD2 # Docker Install Password - Password for Docker install user
|
CFG_DOCKER_INSTALL_PASS=RANDOMIZEDPASSWORD2 # Docker Install Password - Password for Docker install user
|
||||||
|
|
||||||
|
|||||||
@ -1,20 +1,9 @@
|
|||||||
# ================================================================================
|
# ================================================================================
|
||||||
# Installation Setup - Local or Git Repository configuration and version control
|
# Installation Setup - Local or Git Repository configuration and version control
|
||||||
# ================================================================================
|
# ================================================================================
|
||||||
CFG_INSTALL_MODE=release # Installation Mode - How LibrePortal is fetched + updated. Hidden by default — only shown when Developer Mode is on (click the LibrePortal logo 10 times) or when the install is already on git/local (auto-enables Developer Mode). **DEV** [release:Release - Stable|git:Git clone|local:Local folder]
|
CFG_INSTALL_MODE=local # Installation Mode - Method used for installation of LibrePortal
|
||||||
CFG_RELEASE_BASE_URL=https://get.libreportal.org # Release Host - Base URL serving the release channels (override for self-hosting) **ADVANCED**
|
|
||||||
CFG_RELEASE_CHANNEL=stable # Release Channel - Pick the release channel for the tarball installer. Stable is the recommended default; Edge ships from main and may contain in-flight changes. **DEV** [stable:Release - Stable|edge:Release - Bleeding Edge]
|
|
||||||
CFG_DEV_MODE=false # Developer Mode - Reveal advanced developer / dev-install options across the WebUI. Auto-enables when the install is already on git/local. Easter egg: click the LibrePortal logo 10 times to toggle. **ADVANCED** [true:On|false:Off]
|
|
||||||
CFG_GIT_URL=changeme # Git Repository URL - Git repository URL for LibrePortal configuration
|
CFG_GIT_URL=changeme # Git Repository URL - Git repository URL for LibrePortal configuration
|
||||||
CFG_GIT_USER=changeme # Git Username - Git username for repository authentication
|
CFG_GIT_USER=changeme # Git Username - Git username for repository authentication
|
||||||
CFG_GIT_KEY=changeme # Git Access Key - SSH key or API key for Git repository access
|
CFG_GIT_KEY=changeme # Git Access Key - SSH key or API key for Git repository access
|
||||||
CFG_GIT_UPDATES=true # Auto Check Updates - Check for updates automatically
|
CFG_GIT_UPDATES=true # Auto Check Updates - Check for Git repository updates automatically
|
||||||
CFG_GIT_AUTO_UPDATES=true # Auto Apply Updates - Automatically apply updates when available
|
CFG_GIT_AUTO_UPDATES=true # Auto Apply Updates - Automatically apply Git updates when available
|
||||||
CFG_REQUIREMENT_CONFIG=true # Configuration Management - Install the configuration management system. Disabling this on an existing install will brick the system. **ADVANCED** **DEV**
|
|
||||||
CFG_REQUIREMENT_COMMAND=true # Command Line Tool - Install the libreportal command line tool. Disabling this on an existing install will brick the system. **ADVANCED** **DEV**
|
|
||||||
CFG_REQUIREMENT_WEBUI=true # Web Interface - Install the LibrePortal WebUI. Disabling this on an existing install will brick the system. **ADVANCED** **DEV**
|
|
||||||
CFG_REQUIREMENT_WEBUI_SERVICE=true # Web Task Service - Install the task-processor systemd service that backs the WebUI. Disabling this on an existing install will brick the system. **ADVANCED** **DEV**
|
|
||||||
CFG_REQUIREMENT_DATABASE=true # Database Support - Install and configure database support for application data storage. Install-time choice only — flipping post-install will not retrofit. **ADVANCED** **DEV**
|
|
||||||
CFG_REQUIREMENT_PASSWORDS=true # Password Management - Enable password generation and management features. Install-time choice only. **ADVANCED** **DEV**
|
|
||||||
CFG_REQUIREMENT_DOCKER_CE=true # Docker CE - Install Docker Community Edition instead of the distro default. Install-time choice only — flipping post-install does not swap Docker. **ADVANCED** **DEV**
|
|
||||||
CFG_REQUIREMENT_DOCKER_COMPOSE=true # Docker Compose - Install Docker Compose for multi-container application management. Install-time choice only. **ADVANCED** **DEV**
|
|
||||||
|
|||||||
@ -7,12 +7,3 @@ CFG_GENERATED_PASS_LENGTH=14 # Password Length - Len
|
|||||||
CFG_GENERATED_USER_LENGTH=8 # Username Length - Length for auto generated usernames
|
CFG_GENERATED_USER_LENGTH=8 # Username Length - Length for auto generated usernames
|
||||||
CFG_UFW_LOGGING=off # Firewall Logging - UFW firewall logging level [off|low|medium|high|full]
|
CFG_UFW_LOGGING=off # Firewall Logging - UFW firewall logging level [off|low|medium|high|full]
|
||||||
CFG_TEXT_EDITOR=nano # Text Editor - Default text editor for system operations [nano|vim]
|
CFG_TEXT_EDITOR=nano # Text Editor - Default text editor for system operations [nano|vim]
|
||||||
CFG_REQUIREMENT_CRONTAB=true # Scheduled Tasks - Install scheduled tasks and automated maintenance jobs
|
|
||||||
CFG_REQUIREMENT_CONFIGS_CHECK=true # Config Validation - Validate configuration files on startup for errors and consistency
|
|
||||||
CFG_REQUIREMENT_CONFIGS_AUTO_UPDATE=true # Auto Config Updates - Add new config options from the template (non-interactive)
|
|
||||||
CFG_REQUIREMENT_CONFIGS_AUTO_DELETE=true # Auto Config Deletes - Remove config options no longer present in the template
|
|
||||||
CFG_REQUIREMENT_MISSING_IPS=false # IP Configuration Check - Check for and alert about missing IP configurations
|
|
||||||
CFG_REQUIREMENT_CONTINUE_PROMPT=false # Continue Prompts - Show continue prompts during installation for user confirmation
|
|
||||||
CFG_REQUIREMENT_CONTINUE_ON_ERROR=true # Continue On Error - Log failures to error_report.log and continue instead of aborting (on by default to surface issues; turn off for strict abort once clean)
|
|
||||||
CFG_REQUIREMENT_SUGGEST_INSTALLS=false # Install Suggestions - Enable application suggestions and recommendations during installation
|
|
||||||
CFG_REQUIREMENT_SUGGEST_METRICS=true # Metrics Suggestions - Offer Prometheus and Grafana during first install (requires Install Suggestions enabled)
|
|
||||||
|
|||||||
@ -2,4 +2,4 @@ TITLE=Network
|
|||||||
DESCRIPTION=Network configuration and domain management
|
DESCRIPTION=Network configuration and domain management
|
||||||
ICON=network
|
ICON=network
|
||||||
ORDER=4
|
ORDER=4
|
||||||
SUBCATEGORY_ORDER=network_domains,network_whitelist,network_firewall,network_dns,network_docker,network_rootless,network_ports,network_headscale
|
SUBCATEGORY_ORDER=network_domains,network_whitelist,network_dns,network_docker,network_ports,network_headscale
|
||||||
|
|||||||
@ -5,6 +5,3 @@
|
|||||||
CFG_NETWORK_NAME=vpn # Network Name - Docker network name for container communication
|
CFG_NETWORK_NAME=vpn # Network Name - Docker network name for container communication
|
||||||
CFG_NETWORK_SUBNET=10.100.0.0/16 # Network Subnet - Subnet range for Docker network
|
CFG_NETWORK_SUBNET=10.100.0.0/16 # Network Subnet - Subnet range for Docker network
|
||||||
CFG_NETWORK_MTU=1500 # Network MTU - Maximum transmission unit for network packets
|
CFG_NETWORK_MTU=1500 # Network MTU - Maximum transmission unit for network packets
|
||||||
CFG_REQUIREMENT_DOCKER_NETWORK=true # Docker Network - Create and manage the Docker network for container communication
|
|
||||||
CFG_REQUIREMENT_DOCKER_NETWORK_PRUNE=true # Network Cleanup - Automatically prune unused Docker networks
|
|
||||||
CFG_REQUIREMENT_DOCKER_SWITCHER=true # Docker Switcher - Install the Docker version switching utility
|
|
||||||
|
|||||||
@ -10,4 +10,3 @@ CFG_DOMAIN_6= # Domain 6 - Domain slo
|
|||||||
CFG_DOMAIN_7= # Domain 7 - Domain slot 7 for a Traefik
|
CFG_DOMAIN_7= # Domain 7 - Domain slot 7 for a Traefik
|
||||||
CFG_DOMAIN_8= # Domain 8 - Domain slot 8 for a Traefik
|
CFG_DOMAIN_8= # Domain 8 - Domain slot 8 for a Traefik
|
||||||
CFG_DOMAIN_9= # Domain 9 - Domain slot 9 for a Traefik
|
CFG_DOMAIN_9= # Domain 9 - Domain slot 9 for a Traefik
|
||||||
CFG_REQUIREMENT_SSLCERTS=true # SSL Certificates - Generate and manage SSL certificates for secure HTTPS connections **ADVANCED**
|
|
||||||
|
|||||||
@ -1,6 +0,0 @@
|
|||||||
# ================================================================================
|
|
||||||
# Firewall - Host firewall and port-whitelist automation **ADVANCED**
|
|
||||||
# ================================================================================
|
|
||||||
CFG_REQUIREMENT_UFW=true # Firewall Protection - Install and configure the Uncomplicated Firewall (UFW) for system security
|
|
||||||
CFG_REQUIREMENT_UFWD=true # Docker Firewall - Install UFW-Docker for container-aware firewall management (rooted Docker only)
|
|
||||||
CFG_REQUIREMENT_WHITELIST_PORT_UPDATER=true # Auto Port Whitelisting - Update the port whitelist automatically when applications are installed or removed
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
# ================================================================================
|
|
||||||
# Rootless Networking - Network stack and behaviour for rootless Docker **ADVANCED**
|
|
||||||
# ================================================================================
|
|
||||||
|
|
||||||
CFG_ROOTLESS_NET=pasta # Rootless Network Driver - Network stack for rootless Docker. pasta (default): actively maintained, preserves the real client source IP on inbound connections, lower idle CPU; slirp4netns: legacy fallback, maintenance-only upstream. The matching rootlesskit port driver is selected automatically. On Debian, the installer also applies the local AppArmor override pasta needs (see scripts/docker/install/rootless/rootless_apparmor.sh) so this is a single-toggle switch. **ADVANCED** [pasta:Pasta (default, actively maintained)|slirp4netns:slirp4netns (legacy fallback)]
|
|
||||||
@ -2,4 +2,4 @@ TITLE=Security
|
|||||||
DESCRIPTION=Intrusion prevention, bouncers, and host firewall configuration
|
DESCRIPTION=Intrusion prevention, bouncers, and host firewall configuration
|
||||||
ICON=security
|
ICON=security
|
||||||
ORDER=5
|
ORDER=5
|
||||||
SUBCATEGORY_ORDER=security_logins,security_ssh
|
SUBCATEGORY_ORDER=security_logins
|
||||||
|
|||||||
@ -1,7 +0,0 @@
|
|||||||
# ================================================================================
|
|
||||||
# SSH & Access Hardening - Secure remote access and auth-storage toggles **ADVANCED**
|
|
||||||
# ================================================================================
|
|
||||||
CFG_REQUIREMENT_SSHKEY_DOWNLOADER=false # SSH Key Downloader - Enable SSH key download functionality for remote access
|
|
||||||
CFG_REQUIREMENT_SSH_DISABLE_PASSWORDS=false # Disable SSH Passwords - Disable password authentication for SSH (requires key-based access only)
|
|
||||||
CFG_REQUIREMENT_BCRYPT_SAVE=true # Password Encryption - Encrypt saved passwords using bcrypt for enhanced security
|
|
||||||
CFG_REQUIREMENT_GLUETUN_FOR_ALL=false # Gluetun For All Apps - Allow routing through Gluetun VPN for every app (default: only curated categories)
|
|
||||||
@ -2,4 +2,4 @@ TITLE=WebUI
|
|||||||
DESCRIPTION=Web interface settings and preferences
|
DESCRIPTION=Web interface settings and preferences
|
||||||
ICON=webui
|
ICON=webui
|
||||||
ORDER=2
|
ORDER=2
|
||||||
SUBCATEGORY_ORDER=webui_logins,webui_logs,webui_updater
|
SUBCATEGORY_ORDER=webui_logins,webui_logs
|
||||||
|
|||||||
@ -1,5 +0,0 @@
|
|||||||
# ================================================================================
|
|
||||||
# WebUI Updater - Automatic app update, CVE & improvement scanning **ADVANCED**
|
|
||||||
# ================================================================================
|
|
||||||
CFG_UPDATER_SCAN_INTERVAL=30 # App Scan Interval - Minutes between automatic app update/CVE/improvement scans. 0 disables.
|
|
||||||
CFG_HOTFIX_AUTO=security-breakage # Hotfix Auto-Apply - Which signed hotfix severities apply automatically on the update check [security-breakage|all|off]
|
|
||||||
@ -14,7 +14,6 @@
|
|||||||
#
|
#
|
||||||
CFG_ADGUARD_APP_NAME=adguard
|
CFG_ADGUARD_APP_NAME=adguard
|
||||||
CFG_ADGUARD_BACKUP=true
|
CFG_ADGUARD_BACKUP=true
|
||||||
CFG_ADGUARD_BACKUP_STRATEGY=auto
|
|
||||||
CFG_ADGUARD_COMPOSE_FILE=default
|
CFG_ADGUARD_COMPOSE_FILE=default
|
||||||
CFG_ADGUARD_HEALTHCHECK=true
|
CFG_ADGUARD_HEALTHCHECK=true
|
||||||
CFG_ADGUARD_AUTHELIA=false
|
CFG_ADGUARD_AUTHELIA=false
|
||||||
|
|||||||
276
containers/adguard/adguard.sh
Normal file
276
containers/adguard/adguard.sh
Normal file
@ -0,0 +1,276 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Category : Networking
|
||||||
|
# Description : AdGuard - DNS based Ad Blocking (c/u/s/r/i):
|
||||||
|
|
||||||
|
installAdguard()
|
||||||
|
{
|
||||||
|
local config_variables="$1"
|
||||||
|
|
||||||
|
if [[ "$adguard" == *[cCtTuUsSrRiI]* ]]; then
|
||||||
|
dockerConfigSetupToContainer silent adguard;
|
||||||
|
local app_name=$CFG_ADGUARD_APP_NAME
|
||||||
|
initializeAppVariables $app_name;
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$adguard" == *[cC]* ]]; then
|
||||||
|
editAppConfig $app_name;
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$adguard" == *[uU]* ]]; then
|
||||||
|
dockerUninstallApp $app_name;
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$adguard" == *[sS]* ]]; then
|
||||||
|
dockerComposeDown $app_name;
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$adguard" == *[rR]* ]]; then
|
||||||
|
dockerComposeRestart $app_name;
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$adguard" == *[iI]* ]]; then
|
||||||
|
isHeader "Install $app_name"
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Setting up install folder and config file for $app_name."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
dockerConfigSetupToContainer "loud" "$app_name" "install" "$config_variables";
|
||||||
|
isSuccessful "Install folders and Config files have been setup for $app_name."
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Setting up the $app_name docker-compose.yml file."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
dockerComposeSetupFile $app_name;
|
||||||
|
|
||||||
|
monitoringToggleAppConfig "$app_name" "docker-compose.yml";
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Updating file permissions before starting."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
fixPermissionsBeforeStart $app_name;
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Running the docker-compose.yml to install and start $app_name"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
dockerComposeUpdateAndStartApp $app_name install;
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Completing AdGuardHome initial setup automatically"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# The legacy `$usedport1` variable is no longer populated by the
|
||||||
|
# current install pipeline; the resolved host port is stored in the
|
||||||
|
# PORTS_TAG_1 docker-compose tag (format `external:internal`). Pull
|
||||||
|
# it from there so the curl + URL printout actually point somewhere.
|
||||||
|
local adguard_compose_file="$containers_dir$app_name/docker-compose.yml"
|
||||||
|
local adguard_port_pair
|
||||||
|
adguard_port_pair=$(tagsManagerGetTagContent "$adguard_compose_file" "PORTS_TAG_1")
|
||||||
|
local adguard_admin_port="${adguard_port_pair%%:*}"
|
||||||
|
|
||||||
|
if [[ -n "$public_ip_v4" && -n "$adguard_admin_port" ]]; then
|
||||||
|
echo " External : http://$public_ip_v4:$adguard_admin_port/"
|
||||||
|
fi
|
||||||
|
if [[ -n "$host_setup" ]]; then
|
||||||
|
echo " Hostname : http://$host_setup/"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# AdGuardHome ships a setup wizard that normally needs five clicks in a
|
||||||
|
# browser before the daemon writes its config file. Same wizard is
|
||||||
|
# exposed as an HTTP API (POST /control/install/configure), so we
|
||||||
|
# drive it from here and skip the manual interaction. We pre-poll the
|
||||||
|
# admin endpoint until the container is up, then send the form, then
|
||||||
|
# let the existing post-install sed edits run against the freshly
|
||||||
|
# written AdGuardHome.yaml.
|
||||||
|
local adguard_setup_url="http://127.0.0.1:${adguard_admin_port}"
|
||||||
|
local adguard_attempts=0
|
||||||
|
local adguard_max_attempts=60
|
||||||
|
while ((adguard_attempts < adguard_max_attempts)); do
|
||||||
|
if curl -fsS -o /dev/null --max-time 2 "${adguard_setup_url}/control/status" 2>/dev/null \
|
||||||
|
|| curl -fsS -o /dev/null --max-time 2 "${adguard_setup_url}/control/install/get_addresses" 2>/dev/null; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
((adguard_attempts++))
|
||||||
|
done
|
||||||
|
|
||||||
|
if ((adguard_attempts >= adguard_max_attempts)); then
|
||||||
|
isError "AdGuardHome admin endpoint did not respond on $adguard_setup_url within $((adguard_max_attempts * 2))s — open the URL and complete setup manually, then re-run the installer to apply the post-setup tweaks."
|
||||||
|
else
|
||||||
|
local adguard_user="${CFG_ADGUARD_USER:-admin}"
|
||||||
|
local adguard_pass="${CFG_ADGUARD_PASSWORD:-}"
|
||||||
|
if [[ -z "$adguard_pass" ]]; then
|
||||||
|
adguard_pass=$(generateRandomPassword)
|
||||||
|
updateConfigOption "CFG_ADGUARD_PASSWORD" "$adguard_pass" >/dev/null 2>&1 || true
|
||||||
|
isNotice "Generated a random AdGuardHome admin password and saved it to CFG_ADGUARD_PASSWORD."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Internal container ports are fixed (3000 admin, 53 DNS); host
|
||||||
|
# mapping is what `usedport1` etc. handle.
|
||||||
|
local adguard_payload
|
||||||
|
adguard_payload=$(cat <<JSON
|
||||||
|
{
|
||||||
|
"web": { "ip": "0.0.0.0", "port": 3000, "autofix": false },
|
||||||
|
"dns": { "ip": "0.0.0.0", "port": 53, "autofix": false },
|
||||||
|
"username": "${adguard_user}",
|
||||||
|
"password": "${adguard_pass}"
|
||||||
|
}
|
||||||
|
JSON
|
||||||
|
)
|
||||||
|
if curl -fsS -X POST \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
--data "$adguard_payload" \
|
||||||
|
--max-time 15 \
|
||||||
|
"${adguard_setup_url}/control/install/configure" >/dev/null 2>&1; then
|
||||||
|
isSuccessful "AdGuardHome admin setup completed automatically (user: $adguard_user)."
|
||||||
|
else
|
||||||
|
# 422/403 here typically means setup was already done on a
|
||||||
|
# previous install; the post-setup tweaks below are still
|
||||||
|
# safe to run against the existing yaml.
|
||||||
|
isNotice "AdGuardHome /control/install/configure rejected the request — assuming it's already configured. If this is a fresh install, complete setup manually at $adguard_setup_url."
|
||||||
|
fi
|
||||||
|
# Small breather so AdGuardHome finishes flushing AdGuardHome.yaml
|
||||||
|
# to disk before the sed edits below touch it.
|
||||||
|
#sleep 3
|
||||||
|
fi
|
||||||
|
|
||||||
|
#result=$(sudo sed -i "s/address: 0.0.0.0:80/address: 0.0.0.0:${usedport2}/g" "$containers_dir$app_name/conf/AdGuardHome.yaml")
|
||||||
|
#checkSuccess "Changing port 80 to $usedport2 for Admin Panel"
|
||||||
|
|
||||||
|
#result=$(sudo sed -i "s/port: 53/port: ${usedport3}/g" "$containers_dir$app_name/conf/AdGuardHome.yaml")
|
||||||
|
#checkSuccess "Changing port 53 to $usedport3 for DNS Port"
|
||||||
|
|
||||||
|
#result=$(sudo sed -i "s/port_https: 443/port_https: ${usedport4}/g" "$containers_dir$app_name/conf/AdGuardHome.yaml")
|
||||||
|
#checkSuccess "Changing port 443 to $usedport4 for DNS Port"
|
||||||
|
|
||||||
|
#result=$(sudo sed -i "s/port_dns_over_tls: 853/port_dns_over_tls: ${usedport5}/g" "$containers_dir$app_name/conf/AdGuardHome.yaml")
|
||||||
|
#checkSuccess "Changing port 853 to $usedport5 for port_dns_over_tls"
|
||||||
|
|
||||||
|
#result=$(sudo sed -i "s/port_dns_over_quic: 853/port_dns_over_quic: ${usedport5}/g" "$containers_dir$app_name/conf/AdGuardHome.yaml")
|
||||||
|
#checkSuccess "Changing port 853 to $usedport5 for port_dns_over_quic"
|
||||||
|
|
||||||
|
# NOTE: We deliberately do *not* force `tls.enabled: true` here.
|
||||||
|
# That section configures encrypted DNS (DoT/DoH/DoQ) and AdGuardHome
|
||||||
|
# crash-loops on startup with `[fatal] creating dns server: parsing
|
||||||
|
# tls key pair: tls: failed to find any PEM data in certificate input`
|
||||||
|
# if `enabled: true` is set without a real certificate pair pointed
|
||||||
|
# at by `certificate_path` / `private_key_path`. The admin user can
|
||||||
|
# opt into encrypted DNS from Settings → Encryption once they've
|
||||||
|
# provided a cert.
|
||||||
|
|
||||||
|
if [[ $public == "true" ]]; then
|
||||||
|
result=$(sudo sed -i "s|allow_unencrypted_doh: false|allow_unencrypted_doh: true|g" "$containers_dir$app_name/conf/AdGuardHome.yaml")
|
||||||
|
checkSuccess "Setting allow_unencrypted_doh to false for Traefik"
|
||||||
|
fi
|
||||||
|
|
||||||
|
result=$(sudo sed -i "s|anonymize_client_ip: false: false|anonymize_client_ip: true|g" "$containers_dir$app_name/conf/AdGuardHome.yaml")
|
||||||
|
checkSuccess "Setting anonymize_client_ip to true for privacy reasons"
|
||||||
|
|
||||||
|
# Force the admin web bind back to 0.0.0.0:3000 inside the container.
|
||||||
|
# The docker-compose mapping is `<host_port>:3000`, so the container
|
||||||
|
# MUST listen on 3000 internally for the host port to reach it. After
|
||||||
|
# the install API call AdGuardHome sometimes ends up bound to 0.0.0.0:80
|
||||||
|
# (its build-time default) instead of the port we sent — which is
|
||||||
|
# exactly what causes "unable to connect" on the host port.
|
||||||
|
local adguard_yaml="$containers_dir$app_name/conf/AdGuardHome.yaml"
|
||||||
|
if [[ -f "$adguard_yaml" ]]; then
|
||||||
|
# New schema (v0.107+): single `address: 0.0.0.0:NN` line under `http:`.
|
||||||
|
sudo sed -i 's|^\(\s*address:\s*\)0\.0\.0\.0:[0-9]\+|\10.0.0.0:3000|' "$adguard_yaml"
|
||||||
|
# Old schema fallback: separate `bind_host:` / `bind_port:` keys.
|
||||||
|
sudo sed -i 's|^\(\s*bind_host:\s*\).*|\10.0.0.0|' "$adguard_yaml"
|
||||||
|
sudo sed -i 's|^\(\s*bind_port:\s*\)[0-9]\+|\13000|' "$adguard_yaml"
|
||||||
|
checkSuccess "Pinned AdGuardHome admin bind to 0.0.0.0:3000 (matches the compose port mapping)."
|
||||||
|
fi
|
||||||
|
|
||||||
|
dockerComposeRestart "$app_name";
|
||||||
|
|
||||||
|
# Health-check after the restart so the user finds out *here* if
|
||||||
|
# AdGuardHome didn't come back up cleanly, rather than later when
|
||||||
|
# they try to open the URL and just see "unable to connect".
|
||||||
|
#
|
||||||
|
# Drop `-f` and accept any HTTP status code: now that the admin
|
||||||
|
# account is configured, `/control/status` returns 401 to an
|
||||||
|
# unauthenticated request — which is fine, it means the server is
|
||||||
|
# up and answering. We only care whether the connection succeeded
|
||||||
|
# at all, not what the response body says. `-w '%{http_code}'`
|
||||||
|
# gives us a 3-digit code on success and an empty string on a
|
||||||
|
# connection failure / timeout.
|
||||||
|
local adguard_health_attempts=0
|
||||||
|
while ((adguard_health_attempts < 20)); do
|
||||||
|
local adguard_health_code
|
||||||
|
adguard_health_code=$(curl -sS -o /dev/null --max-time 2 \
|
||||||
|
-w '%{http_code}' "${adguard_setup_url}/control/status" 2>/dev/null)
|
||||||
|
if [[ "$adguard_health_code" =~ ^[1-5][0-9][0-9]$ ]]; then
|
||||||
|
isSuccessful "AdGuardHome admin UI is reachable on $adguard_setup_url (HTTP $adguard_health_code)"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
((adguard_health_attempts++))
|
||||||
|
done
|
||||||
|
if ((adguard_health_attempts >= 20)); then
|
||||||
|
isError "AdGuardHome admin UI did not respond after restart on $adguard_setup_url. Check the container logs (\`docker logs adguard-service\`) and the conf/AdGuardHome.yaml bind address."
|
||||||
|
fi
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Running Application specific updates (if required)"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
appUpdateSpecifics $app_name;
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Running Headscale setup (if required)"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
setupHeadscale $app_name;
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Adding $app_name to the Apps Database table."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
databaseInstallApp $app_name;
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Updating the WebUI config file."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
webuiContainerSetup $app_name install;
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Refreshing monitoring integration."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
monitoringRefreshAll;
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. You can find $app_name files at $containers_dir$app_name"
|
||||||
|
echo ""
|
||||||
|
echo " You can now navigate to your $app_name service using any of the options below : "
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Same final-summary call shape as wireguard / vaultwarden. Pass the
|
||||||
|
# admin user/password we just configured so the user sees the
|
||||||
|
# credentials exactly once, at the end of the install.
|
||||||
|
menuShowFinalMessages "$app_name" "${CFG_ADGUARD_USER:-admin}" "$CFG_ADGUARD_PASSWORD";
|
||||||
|
|
||||||
|
menu_number=0
|
||||||
|
#sleep 3s
|
||||||
|
cd
|
||||||
|
fi
|
||||||
|
adguard=n
|
||||||
|
}
|
||||||
@ -1,30 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
authAdapter_adguard_setPassword() {
|
|
||||||
local user="$1" password="$2"
|
|
||||||
user="${user:-${CFG_ADGUARD_ADMIN_USER:-admin}}"
|
|
||||||
[[ -z "$password" ]] && password=$(generateRandomPassword)
|
|
||||||
|
|
||||||
local yaml="${containers_dir}adguard/conf/AdGuardHome.yaml"
|
|
||||||
[[ ! -f "$yaml" ]] && { isError "AdGuardHome.yaml not found at $yaml."; return 1; }
|
|
||||||
if ! command -v htpasswd >/dev/null 2>&1; then
|
|
||||||
isError "htpasswd is required to bcrypt the new password."
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
local bcrypt
|
|
||||||
bcrypt=$(htpasswd -bnBC 10 "" "$password" | tr -d ':\n')
|
|
||||||
[[ -z "$bcrypt" ]] && { isError "bcrypt failed."; return 1; }
|
|
||||||
|
|
||||||
# The yaml is owned by the in-container uid, so the rewrite runs in the
|
|
||||||
# root-owned appcfg helper (fixed path, validated user + bcrypt).
|
|
||||||
if ! runAppCfg adguard-auth "$user" "$bcrypt"; then
|
|
||||||
isError "Could not update AdGuardHome.yaml (no users password line, or invalid input)."
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
authPersistCfg adguard ADMIN_USER "$user"
|
|
||||||
authPersistCfg adguard ADMIN_PASSWORD "$password"
|
|
||||||
dockerComposeRestart adguard
|
|
||||||
isSuccessful "AdGuard admin set. User: $user — Password: $password"
|
|
||||||
}
|
|
||||||
@ -1,142 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# AdGuard Home install hooks — drive the first-boot setup wizard via its
|
|
||||||
# HTTP API so the admin doesn't have to click through five pages, then
|
|
||||||
# pin the admin bind back to 0.0.0.0:3000 (matches the compose mapping)
|
|
||||||
# and health-check the result.
|
|
||||||
|
|
||||||
adguard_install_post_start()
|
|
||||||
{
|
|
||||||
local app_name="$1"
|
|
||||||
|
|
||||||
((menu_number++))
|
|
||||||
echo ""
|
|
||||||
echo "---- $menu_number. Completing AdGuardHome initial setup automatically"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# The legacy `$usedport1` variable isn't populated by the current
|
|
||||||
# install pipeline; the resolved host port is stored in the PORTS_TAG_1
|
|
||||||
# docker-compose tag (format `external:internal`). Pull it from there
|
|
||||||
# so the curl + URL printout actually point somewhere real.
|
|
||||||
local adguard_compose_file="$containers_dir$app_name/docker-compose.yml"
|
|
||||||
local adguard_port_pair
|
|
||||||
adguard_port_pair=$(tagsManagerGetTagContent "$adguard_compose_file" "PORTS_TAG_1")
|
|
||||||
local adguard_admin_port="${adguard_port_pair%%:*}"
|
|
||||||
|
|
||||||
if [[ -n "$public_ip_v4" && -n "$adguard_admin_port" ]]; then
|
|
||||||
echo " External : http://$public_ip_v4:$adguard_admin_port/"
|
|
||||||
fi
|
|
||||||
if [[ -n "$host_setup" ]]; then
|
|
||||||
echo " Hostname : http://$host_setup/"
|
|
||||||
fi
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# AdGuardHome ships a setup wizard that normally needs five clicks in
|
|
||||||
# a browser before the daemon writes its config file. Same wizard is
|
|
||||||
# exposed as an HTTP API (POST /control/install/configure), so drive
|
|
||||||
# it from here and skip the manual interaction. Pre-poll the admin
|
|
||||||
# endpoint until the container is up, then send the form, then let
|
|
||||||
# the post-install sed edits run against the freshly written
|
|
||||||
# AdGuardHome.yaml.
|
|
||||||
local adguard_setup_url="http://127.0.0.1:${adguard_admin_port}"
|
|
||||||
local adguard_attempts=0
|
|
||||||
local adguard_max_attempts=60
|
|
||||||
while ((adguard_attempts < adguard_max_attempts)); do
|
|
||||||
if curl -fsS -o /dev/null --max-time 2 "${adguard_setup_url}/control/status" 2>/dev/null \
|
|
||||||
|| curl -fsS -o /dev/null --max-time 2 "${adguard_setup_url}/control/install/get_addresses" 2>/dev/null; then
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
sleep 2
|
|
||||||
((adguard_attempts++))
|
|
||||||
done
|
|
||||||
|
|
||||||
if ((adguard_attempts >= adguard_max_attempts)); then
|
|
||||||
isError "AdGuardHome admin endpoint did not respond on $adguard_setup_url within $((adguard_max_attempts * 2))s — open the URL and complete setup manually, then re-run the installer to apply the post-setup tweaks."
|
|
||||||
else
|
|
||||||
local adguard_user="${CFG_ADGUARD_USER:-admin}"
|
|
||||||
local adguard_pass="${CFG_ADGUARD_PASSWORD:-}"
|
|
||||||
if [[ -z "$adguard_pass" ]]; then
|
|
||||||
adguard_pass=$(generateRandomPassword)
|
|
||||||
updateConfigOption "CFG_ADGUARD_PASSWORD" "$adguard_pass" >/dev/null 2>&1 || true
|
|
||||||
isNotice "Generated a random AdGuardHome admin password and saved it to CFG_ADGUARD_PASSWORD."
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Internal container ports are fixed (3000 admin, 53 DNS); host
|
|
||||||
# mapping is what `usedport1` etc. handle.
|
|
||||||
local adguard_payload
|
|
||||||
adguard_payload=$(cat <<JSON
|
|
||||||
{
|
|
||||||
"web": { "ip": "0.0.0.0", "port": 3000, "autofix": false },
|
|
||||||
"dns": { "ip": "0.0.0.0", "port": 53, "autofix": false },
|
|
||||||
"username": "${adguard_user}",
|
|
||||||
"password": "${adguard_pass}"
|
|
||||||
}
|
|
||||||
JSON
|
|
||||||
)
|
|
||||||
if curl -fsS -X POST \
|
|
||||||
-H 'Content-Type: application/json' \
|
|
||||||
--data "$adguard_payload" \
|
|
||||||
--max-time 15 \
|
|
||||||
"${adguard_setup_url}/control/install/configure" >/dev/null 2>&1; then
|
|
||||||
isSuccessful "AdGuardHome admin setup completed automatically (user: $adguard_user)."
|
|
||||||
else
|
|
||||||
# 422/403 here typically means setup was already done on a
|
|
||||||
# previous install; the post-setup tweaks below are still
|
|
||||||
# safe to run against the existing yaml.
|
|
||||||
isNotice "AdGuardHome /control/install/configure rejected the request — assuming it's already configured. If this is a fresh install, complete setup manually at $adguard_setup_url."
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
local result
|
|
||||||
if [[ "$public" == "true" ]]; then
|
|
||||||
result=$(runFileOp sed -i "s|allow_unencrypted_doh: false|allow_unencrypted_doh: true|g" "$containers_dir$app_name/conf/AdGuardHome.yaml")
|
|
||||||
checkSuccess "Setting allow_unencrypted_doh to false for Traefik"
|
|
||||||
fi
|
|
||||||
|
|
||||||
result=$(runFileOp sed -i "s|anonymize_client_ip: false: false|anonymize_client_ip: true|g" "$containers_dir$app_name/conf/AdGuardHome.yaml")
|
|
||||||
checkSuccess "Setting anonymize_client_ip to true for privacy reasons"
|
|
||||||
|
|
||||||
# Force the admin web bind back to 0.0.0.0:3000 inside the container.
|
|
||||||
# The docker-compose mapping is `<host_port>:3000`, so the container
|
|
||||||
# MUST listen on 3000 internally for the host port to reach it. After
|
|
||||||
# the install API call AdGuardHome sometimes ends up bound to
|
|
||||||
# 0.0.0.0:80 (its build-time default) — exactly what causes "unable
|
|
||||||
# to connect" on the host port.
|
|
||||||
local adguard_yaml="$containers_dir$app_name/conf/AdGuardHome.yaml"
|
|
||||||
if [[ -f "$adguard_yaml" ]]; then
|
|
||||||
runFileOp sed -i 's|^\(\s*address:\s*\)0\.0\.0\.0:[0-9]\+|\10.0.0.0:3000|' "$adguard_yaml"
|
|
||||||
runFileOp sed -i 's|^\(\s*bind_host:\s*\).*|\10.0.0.0|' "$adguard_yaml"
|
|
||||||
runFileOp sed -i 's|^\(\s*bind_port:\s*\)[0-9]\+|\13000|' "$adguard_yaml"
|
|
||||||
checkSuccess "Pinned AdGuardHome admin bind to 0.0.0.0:3000 (matches the compose port mapping)."
|
|
||||||
fi
|
|
||||||
|
|
||||||
dockerComposeRestart "$app_name"
|
|
||||||
|
|
||||||
# Drop `-f` and accept any HTTP status code: now that the admin
|
|
||||||
# account is configured, /control/status returns 401 to an
|
|
||||||
# unauthenticated request — which is fine, it means the server is up
|
|
||||||
# and answering. We only care whether the connection succeeded at
|
|
||||||
# all, not what the response body says.
|
|
||||||
local adguard_health_attempts=0
|
|
||||||
local adguard_health_code
|
|
||||||
while ((adguard_health_attempts < 20)); do
|
|
||||||
adguard_health_code=$(curl -sS -o /dev/null --max-time 2 \
|
|
||||||
-w '%{http_code}' "${adguard_setup_url}/control/status" 2>/dev/null)
|
|
||||||
if [[ "$adguard_health_code" =~ ^[1-5][0-9][0-9]$ ]]; then
|
|
||||||
isSuccessful "AdGuardHome admin UI is reachable on $adguard_setup_url (HTTP $adguard_health_code)"
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
sleep 1
|
|
||||||
((adguard_health_attempts++))
|
|
||||||
done
|
|
||||||
if ((adguard_health_attempts >= 20)); then
|
|
||||||
isError "AdGuardHome admin UI did not respond after restart on $adguard_setup_url. Check the container logs (\`docker logs adguard-service\`) and the conf/AdGuardHome.yaml bind address."
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
adguard_install_message_data()
|
|
||||||
{
|
|
||||||
# Echo the admin user + password as space-separated tokens so they
|
|
||||||
# become $username $password positional args to menuShowFinalMessages.
|
|
||||||
echo "${CFG_ADGUARD_USER:-admin} $CFG_ADGUARD_PASSWORD"
|
|
||||||
}
|
|
||||||
@ -1,12 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# Post-install/update specifics for AdGuard Home — dispatched by appUpdateSpecifics
|
|
||||||
# (containers/<app>/scripts/<app>_update_specifics.sh defining appUpdateSpecifics_<app>).
|
|
||||||
appUpdateSpecifics_adguard() {
|
|
||||||
local app_name="$1"
|
|
||||||
if [[ $CFG_REQUIREMENT_DNS_UPDATER == "true" ]]; then
|
|
||||||
updateDNS "$app_name" install
|
|
||||||
fi
|
|
||||||
# Split-horizon local DNS: app subdomains resolve to the box on the LAN.
|
|
||||||
declare -F setupLocalDnsRewrites >/dev/null 2>&1 && setupLocalDnsRewrites
|
|
||||||
}
|
|
||||||
@ -1,20 +0,0 @@
|
|||||||
{
|
|
||||||
"tools": [
|
|
||||||
{
|
|
||||||
"id": "reset_password",
|
|
||||||
"label": "Reset Admin Password",
|
|
||||||
"description": "Set a new admin password. Leave blank to generate one.",
|
|
||||||
"icon": "🔑",
|
|
||||||
"fields": [
|
|
||||||
{ "name": "password", "label": "New password", "type": "password", "placeholder": "Leave blank to generate" }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "apply_dns_updater",
|
|
||||||
"label": "Apply DNS Updater",
|
|
||||||
"description": "Point this server's DNS at AdGuard now.",
|
|
||||||
"icon": "🌐",
|
|
||||||
"fields": []
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@ -17,7 +17,6 @@
|
|||||||
CFG_AUTHELIA_APP_NAME=authelia
|
CFG_AUTHELIA_APP_NAME=authelia
|
||||||
CFG_AUTHELIA_REQUIRES="domain,traefik"
|
CFG_AUTHELIA_REQUIRES="domain,traefik"
|
||||||
CFG_AUTHELIA_BACKUP=true
|
CFG_AUTHELIA_BACKUP=true
|
||||||
CFG_AUTHELIA_BACKUP_STRATEGY=auto
|
|
||||||
CFG_AUTHELIA_COMPOSE_FILE=default
|
CFG_AUTHELIA_COMPOSE_FILE=default
|
||||||
CFG_AUTHELIA_HEALTHCHECK=true
|
CFG_AUTHELIA_HEALTHCHECK=true
|
||||||
CFG_AUTHELIA_AUTHELIA=false
|
CFG_AUTHELIA_AUTHELIA=false
|
||||||
|
|||||||
208
containers/authelia/authelia.sh
Executable file
208
containers/authelia/authelia.sh
Executable file
@ -0,0 +1,208 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Category : Security
|
||||||
|
# Description : Authelia - Authentication & SSO (c/u/s/r/i):
|
||||||
|
|
||||||
|
installAuthelia()
|
||||||
|
{
|
||||||
|
local config_variables="$1"
|
||||||
|
|
||||||
|
if [[ "$authelia" == *[cCtTuUsSrRiI]* ]]; then
|
||||||
|
dockerConfigSetupToContainer silent authelia;
|
||||||
|
local app_name=$CFG_AUTHELIA_APP_NAME
|
||||||
|
initializeAppVariables $app_name;
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$authelia" == *[cC]* ]]; then
|
||||||
|
editAppConfig $app_name;
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$authelia" == *[uU]* ]]; then
|
||||||
|
dockerUninstallApp $app_name;
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$authelia" == *[sS]* ]]; then
|
||||||
|
dockerComposeDown $app_name;
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$authelia" == *[rR]* ]]; then
|
||||||
|
dockerComposeRestart $app_name;
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$authelia" == *[iI]* ]]; then
|
||||||
|
isHeader "Install $app_name"
|
||||||
|
|
||||||
|
# Pre-flight: bail out before touching any compose/config if the
|
||||||
|
# global prerequisites aren't met. CFG_AUTHELIA_REQUIRES lists
|
||||||
|
# what's needed (currently "domain,traefik"); the helper prints a
|
||||||
|
# clear list of what's missing so the user knows what to fix.
|
||||||
|
if ! appInstallCheckRequirements "$app_name" "$CFG_AUTHELIA_REQUIRES"; then
|
||||||
|
authelia=n
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Setting up install folder and config file for $app_name."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
dockerConfigSetupToContainer "loud" "$app_name" "install" "$config_variables";
|
||||||
|
isSuccessful "Install folders and Config files have been setup for $app_name."
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Setting up the $app_name docker-compose.yml file."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
dockerComposeSetupFile $app_name;
|
||||||
|
|
||||||
|
local result=$(copyResource "$app_name" "configuration.yml" "config" | sudo tee -a "$logs_dir/$docker_log_file" 2>&1)
|
||||||
|
checkSuccess "Copying configuration.yml to $containers_dir$app_name/config"
|
||||||
|
|
||||||
|
local result=$(copyResource "$app_name" "users_database.yml" "config" | sudo tee -a "$logs_dir/$docker_log_file" 2>&1)
|
||||||
|
checkSuccess "Copying users_database.yml to $containers_dir$app_name/config"
|
||||||
|
|
||||||
|
local authelia_config_file="$containers_dir$app_name/config/configuration.yml"
|
||||||
|
sudo sed -i "s|AUTHELIA_THEME_PLACEHOLDER|$CFG_AUTHELIA_THEME|g" "$authelia_config_file"
|
||||||
|
sudo sed -i "s|AUTHELIA_DOMAIN_PLACEHOLDER|$domain_full|g" "$authelia_config_file"
|
||||||
|
sudo sed -i "s|AUTHELIA_HOST_PLACEHOLDER|$host_setup|g" "$authelia_config_file"
|
||||||
|
checkSuccess "Substituting Authelia configuration values (theme=$CFG_AUTHELIA_THEME domain=$domain_full host=$host_setup)"
|
||||||
|
|
||||||
|
local authelia_secrets_dir="$containers_dir$app_name/secrets"
|
||||||
|
sudo mkdir -p "$authelia_secrets_dir"
|
||||||
|
for secret_name in JWT_SECRET SESSION_SECRET STORAGE_ENCRYPTION_KEY; do
|
||||||
|
local secret_file="$authelia_secrets_dir/$secret_name"
|
||||||
|
if [[ ! -s "$secret_file" ]]; then
|
||||||
|
openssl rand -hex 64 | sudo tee "$secret_file" >/dev/null
|
||||||
|
sudo chmod 600 "$secret_file"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
sudo chown -R "$docker_install_user":"$docker_install_user" "$authelia_secrets_dir"
|
||||||
|
checkSuccess "Generated Authelia secrets at $authelia_secrets_dir"
|
||||||
|
|
||||||
|
# Enable Authelia's telemetry/metrics endpoint only when
|
||||||
|
# CFG_AUTHELIA_MONITORING=true (toggles the libreportal-monitoring
|
||||||
|
# marker block in configuration.yml).
|
||||||
|
monitoringToggleAppConfig "$app_name" "config/configuration.yml";
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Updating file permissions before starting."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
fixPermissionsBeforeStart $app_name;
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Running the docker-compose.yml to install and start $app_name"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
dockerComposeUpdateAndStartApp $app_name install;
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Configuring Authelia admin account"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
local authelia_admin_user="${CFG_AUTHELIA_ADMIN_USERNAME:-admin}"
|
||||||
|
local authelia_admin_pass="${CFG_AUTHELIA_ADMIN_PASSWORD:-authelia}"
|
||||||
|
local authelia_users_file="$containers_dir$app_name/config/users_database.yml"
|
||||||
|
local authelia_attempts=0
|
||||||
|
while ((authelia_attempts < 30)); do
|
||||||
|
if sudo docker exec authelia-service authelia --version >/dev/null 2>&1; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
((authelia_attempts++))
|
||||||
|
done
|
||||||
|
|
||||||
|
if ((authelia_attempts >= 30)); then
|
||||||
|
isNotice "Authelia container did not become responsive in time — admin left at default (admin / authelia)."
|
||||||
|
else
|
||||||
|
local authelia_hash
|
||||||
|
authelia_hash=$(sudo docker exec authelia-service authelia crypto hash generate argon2 --password "$authelia_admin_pass" 2>/dev/null \
|
||||||
|
| grep -oE '\$argon2[^[:space:]]+')
|
||||||
|
if [[ -z "$authelia_hash" ]]; then
|
||||||
|
isNotice "Could not generate Authelia password hash — admin left at default (admin / authelia)."
|
||||||
|
else
|
||||||
|
sudo tee "$authelia_users_file" >/dev/null <<EOF
|
||||||
|
---
|
||||||
|
users:
|
||||||
|
${authelia_admin_user}:
|
||||||
|
disabled: false
|
||||||
|
displayname: "Admin"
|
||||||
|
password: "${authelia_hash}"
|
||||||
|
email: ${authelia_admin_user}@${domain_full:-example.com}
|
||||||
|
groups:
|
||||||
|
- admins
|
||||||
|
EOF
|
||||||
|
sudo chown "$docker_install_user":"$docker_install_user" "$authelia_users_file"
|
||||||
|
isSuccessful "Configured Authelia admin (user: $authelia_admin_user)."
|
||||||
|
dockerComposeRestart "$app_name";
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Running Application specific updates (if required)"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
appUpdateSpecifics $app_name;
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Running Headscale setup (if required)"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
setupHeadscale $app_name;
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Adding $app_name to the Apps Database table."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
databaseInstallApp $app_name;
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Updating WebUI config file."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
webuiContainerSetup $app_name install;
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Refreshing monitoring integration."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Self-correcting: adds Authelia's scrape target + dashboard to
|
||||||
|
# Prometheus/Grafana when CFG_AUTHELIA_MONITORING=true, removes them
|
||||||
|
# when it's off. No-ops with a notice if either app isn't installed.
|
||||||
|
monitoringRefreshAll;
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. You can find $app_name files at $containers_dir$app_name"
|
||||||
|
echo ""
|
||||||
|
echo " You can now navigate to your new service using one of the options below : "
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
menuShowFinalMessages $app_name;
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
isNotice "Authelia admin login:"
|
||||||
|
echo ""
|
||||||
|
echo " Username : ${authelia_admin_user}"
|
||||||
|
echo " Password : ${authelia_admin_pass}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
menu_number=0
|
||||||
|
#sleep 3s
|
||||||
|
cd
|
||||||
|
fi
|
||||||
|
authelia=n
|
||||||
|
}
|
||||||
@ -23,7 +23,6 @@ services:
|
|||||||
labels:
|
labels:
|
||||||
libreportal.category: "CATEGORY_DATA" #LIBREPORTAL|CATEGORY_TAG|CATEGORY_DATA
|
libreportal.category: "CATEGORY_DATA" #LIBREPORTAL|CATEGORY_TAG|CATEGORY_DATA
|
||||||
libreportal.title: "TITLE_DATA" #LIBREPORTAL|TITLE_TAG|TITLE_DATA
|
libreportal.title: "TITLE_DATA" #LIBREPORTAL|TITLE_TAG|TITLE_DATA
|
||||||
libreportal.backup.db: "sqlite:::config/db.sqlite3"
|
|
||||||
traefik.enable: TRAEFIK_ENABLE_DATA #LIBREPORTAL|TRAEFIK_ENABLE_TAG|TRAEFIK_ENABLE_DATA
|
traefik.enable: TRAEFIK_ENABLE_DATA #LIBREPORTAL|TRAEFIK_ENABLE_TAG|TRAEFIK_ENABLE_DATA
|
||||||
# TRAEFIK_PORT_1_BEGIN
|
# TRAEFIK_PORT_1_BEGIN
|
||||||
traefik.http.routers.authelia-service.entrypoints: web,websecure
|
traefik.http.routers.authelia-service.entrypoints: web,websecure
|
||||||
|
|||||||
@ -1,111 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# Authelia install hooks — requirements check, config + secrets bootstrap,
|
|
||||||
# admin account provisioning, and an end-of-install credentials notice.
|
|
||||||
|
|
||||||
authelia_install_pre()
|
|
||||||
{
|
|
||||||
local app_name="$1"
|
|
||||||
if ! appInstallCheckRequirements "$app_name" "$CFG_AUTHELIA_REQUIRES"; then
|
|
||||||
authelia=n
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
authelia_install_post_compose()
|
|
||||||
{
|
|
||||||
local app_name="$1"
|
|
||||||
|
|
||||||
local result
|
|
||||||
result=$(copyResource "$app_name" "configuration.yml" "config" | runInstallWrite -a "$logs_dir/$docker_log_file" 2>&1)
|
|
||||||
checkSuccess "Copying configuration.yml to $containers_dir$app_name/config"
|
|
||||||
|
|
||||||
result=$(copyResource "$app_name" "users_database.yml" "config" | runInstallWrite -a "$logs_dir/$docker_log_file" 2>&1)
|
|
||||||
checkSuccess "Copying users_database.yml to $containers_dir$app_name/config"
|
|
||||||
|
|
||||||
local authelia_config_file="$containers_dir$app_name/config/configuration.yml"
|
|
||||||
runFileOp sed -i "s|AUTHELIA_THEME_PLACEHOLDER|$CFG_AUTHELIA_THEME|g" "$authelia_config_file"
|
|
||||||
runFileOp sed -i "s|AUTHELIA_DOMAIN_PLACEHOLDER|$domain_full|g" "$authelia_config_file"
|
|
||||||
runFileOp sed -i "s|AUTHELIA_HOST_PLACEHOLDER|$host_setup|g" "$authelia_config_file"
|
|
||||||
checkSuccess "Substituting Authelia configuration values (theme=$CFG_AUTHELIA_THEME domain=$domain_full host=$host_setup)"
|
|
||||||
|
|
||||||
local authelia_secrets_dir="$containers_dir$app_name/secrets"
|
|
||||||
runFileOp mkdir -p "$authelia_secrets_dir"
|
|
||||||
local secret_name secret_file
|
|
||||||
for secret_name in JWT_SECRET SESSION_SECRET STORAGE_ENCRYPTION_KEY; do
|
|
||||||
secret_file="$authelia_secrets_dir/$secret_name"
|
|
||||||
if [[ ! -s "$secret_file" ]]; then
|
|
||||||
openssl rand -hex 64 | runFileWrite "$secret_file"
|
|
||||||
runFileOp chmod 600 "$secret_file"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
runFileOp chown -R "$docker_install_user":"$docker_install_user" "$authelia_secrets_dir"
|
|
||||||
checkSuccess "Generated Authelia secrets at $authelia_secrets_dir"
|
|
||||||
|
|
||||||
# Authelia's metrics block lives in configuration.yml (not the compose),
|
|
||||||
# so toggle it here. The driver already toggled docker-compose.yml.
|
|
||||||
monitoringToggleAppConfig "$app_name" "config/configuration.yml"
|
|
||||||
}
|
|
||||||
|
|
||||||
authelia_install_post_start()
|
|
||||||
{
|
|
||||||
local app_name="$1"
|
|
||||||
|
|
||||||
((menu_number++))
|
|
||||||
echo ""
|
|
||||||
echo "---- $menu_number. Configuring Authelia admin account"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
local authelia_admin_user="${CFG_AUTHELIA_ADMIN_USERNAME:-admin}"
|
|
||||||
local authelia_admin_pass="${CFG_AUTHELIA_ADMIN_PASSWORD:-authelia}"
|
|
||||||
local authelia_users_file="$containers_dir$app_name/config/users_database.yml"
|
|
||||||
local authelia_attempts=0
|
|
||||||
while ((authelia_attempts < 30)); do
|
|
||||||
if runFileOp docker exec authelia-service authelia --version >/dev/null 2>&1; then
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
sleep 2
|
|
||||||
((authelia_attempts++))
|
|
||||||
done
|
|
||||||
|
|
||||||
if ((authelia_attempts >= 30)); then
|
|
||||||
isNotice "Authelia container did not become responsive in time — admin left at default (admin / authelia)."
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
local authelia_hash
|
|
||||||
authelia_hash=$(runFileOp docker exec authelia-service authelia crypto hash generate argon2 --password "$authelia_admin_pass" 2>/dev/null \
|
|
||||||
| grep -oE '\$argon2[^[:space:]]+')
|
|
||||||
if [[ -z "$authelia_hash" ]]; then
|
|
||||||
isNotice "Could not generate Authelia password hash — admin left at default (admin / authelia)."
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
runFileWrite "$authelia_users_file" <<EOF
|
|
||||||
---
|
|
||||||
users:
|
|
||||||
${authelia_admin_user}:
|
|
||||||
disabled: false
|
|
||||||
displayname: "Admin"
|
|
||||||
password: "${authelia_hash}"
|
|
||||||
email: ${authelia_admin_user}@${domain_full:-example.com}
|
|
||||||
groups:
|
|
||||||
- admins
|
|
||||||
EOF
|
|
||||||
runFileOp chown "$docker_install_user":"$docker_install_user" "$authelia_users_file"
|
|
||||||
isSuccessful "Configured Authelia admin (user: $authelia_admin_user)."
|
|
||||||
dockerComposeRestart "$app_name"
|
|
||||||
}
|
|
||||||
|
|
||||||
authelia_install_post()
|
|
||||||
{
|
|
||||||
local app_name="$1"
|
|
||||||
local authelia_admin_user="${CFG_AUTHELIA_ADMIN_USERNAME:-admin}"
|
|
||||||
local authelia_admin_pass="${CFG_AUTHELIA_ADMIN_PASSWORD:-authelia}"
|
|
||||||
echo ""
|
|
||||||
isNotice "Authelia admin login:"
|
|
||||||
echo ""
|
|
||||||
echo " Username : ${authelia_admin_user}"
|
|
||||||
echo " Password : ${authelia_admin_pass}"
|
|
||||||
echo ""
|
|
||||||
}
|
|
||||||
@ -12,13 +12,7 @@
|
|||||||
# ADMIN_PASSWORD = password used for the Bookstack admin account
|
# ADMIN_PASSWORD = password used for the Bookstack admin account
|
||||||
#
|
#
|
||||||
CFG_BOOKSTACK_APP_NAME=bookstack
|
CFG_BOOKSTACK_APP_NAME=bookstack
|
||||||
# MULTI_INSTANCE = if true, this app can run as multiple isolated instances
|
|
||||||
# (own data/DB/subdomain/backups) via `libreportal instance create`. Only set on
|
|
||||||
# apps whose compose identity (container_name, Traefik routers, backup labels)
|
|
||||||
# is instance-safe — see scripts/instance/instance_create.sh.
|
|
||||||
CFG_BOOKSTACK_MULTI_INSTANCE=true
|
|
||||||
CFG_BOOKSTACK_BACKUP=true
|
CFG_BOOKSTACK_BACKUP=true
|
||||||
CFG_BOOKSTACK_BACKUP_STRATEGY=auto
|
|
||||||
CFG_BOOKSTACK_COMPOSE_FILE=default
|
CFG_BOOKSTACK_COMPOSE_FILE=default
|
||||||
CFG_BOOKSTACK_HEALTHCHECK=true
|
CFG_BOOKSTACK_HEALTHCHECK=true
|
||||||
CFG_BOOKSTACK_AUTHELIA=false
|
CFG_BOOKSTACK_AUTHELIA=false
|
||||||
|
|||||||
180
containers/bookstack/bookstack.sh
Executable file
180
containers/bookstack/bookstack.sh
Executable file
@ -0,0 +1,180 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Category : Knowledge Management
|
||||||
|
# Description : Bookstack - Wiki/Knowledge Base (c/u/s/r/i):
|
||||||
|
|
||||||
|
installBookstack()
|
||||||
|
{
|
||||||
|
local config_variables="$1"
|
||||||
|
|
||||||
|
if [[ "$bookstack" == *[cCtTuUsSrRiI]* ]]; then
|
||||||
|
dockerConfigSetupToContainer silent bookstack;
|
||||||
|
local app_name=$CFG_BOOKSTACK_APP_NAME
|
||||||
|
initializeAppVariables $app_name;
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$bookstack" == *[cC]* ]]; then
|
||||||
|
editAppConfig $app_name;
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$bookstack" == *[uU]* ]]; then
|
||||||
|
dockerUninstallApp $app_name;
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$bookstack" == *[sS]* ]]; then
|
||||||
|
dockerComposeDown $app_name;
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$bookstack" == *[rR]* ]]; then
|
||||||
|
dockerComposeRestart $app_name;
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$bookstack" == *[iI]* ]]; then
|
||||||
|
isHeader "Install $app_name"
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Setting up install folder and config file for $app_name."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
dockerConfigSetupToContainer "loud" "$app_name" "install" "$config_variables";
|
||||||
|
isSuccessful "Install folders and Config files have been setup for $app_name."
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Setting up the $app_name docker-compose.yml file."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
dockerComposeSetupFile $app_name;
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Updating file permissions before starting."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
fixPermissionsBeforeStart $app_name;
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Running the docker-compose.yml to install and start $app_name"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
dockerComposeUpdateAndStartApp $app_name install;
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Running Application specific updates (if required)"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
appUpdateSpecifics $app_name;
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Running Headscale setup (if required)"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
setupHeadscale $app_name;
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Adding $app_name to the Apps Database table."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
databaseInstallApp $app_name;
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Updating WebUI config file."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
webuiContainerSetup $app_name install;
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. You can find $app_name files at $containers_dir$app_name"
|
||||||
|
echo ""
|
||||||
|
echo " You can now navigate to your $app_name service using any of the options below : "
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
menuShowFinalMessages $app_name;
|
||||||
|
|
||||||
|
bookstack_target_email="${CFG_BOOKSTACK_ADMIN_EMAIL:-admin@admin.com}"
|
||||||
|
bookstack_target_pass="${CFG_BOOKSTACK_ADMIN_PASSWORD:-password}"
|
||||||
|
|
||||||
|
bookstack_compose_file="$containers_dir$app_name/docker-compose.yml"
|
||||||
|
bookstack_port_pair=$(tagsManagerGetTagContent "$bookstack_compose_file" "PORTS_TAG_1")
|
||||||
|
bookstack_host_port="${bookstack_port_pair%%:*}"
|
||||||
|
bookstack_probe_url="http://127.0.0.1:${bookstack_host_port}/login"
|
||||||
|
|
||||||
|
isNotice "Waiting for Bookstack to come online at ${bookstack_probe_url} ..."
|
||||||
|
isNotice "This may take up to 20 seconds, please wait..."
|
||||||
|
|
||||||
|
bookstack_attempts=0
|
||||||
|
bookstack_ready=0
|
||||||
|
while ((bookstack_attempts < 60)); do
|
||||||
|
bookstack_http_code=$(curl -sS -o /dev/null --max-time 3 -w '%{http_code}' "$bookstack_probe_url" 2>/dev/null)
|
||||||
|
if [[ "$bookstack_http_code" =~ ^(200|302)$ ]]; then
|
||||||
|
bookstack_ready=1
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
((bookstack_attempts++))
|
||||||
|
done
|
||||||
|
|
||||||
|
if ((bookstack_ready == 0)); then
|
||||||
|
isNotice "Bookstack did not respond on ${bookstack_probe_url} within $((60 * 2))s — admin account left at upstream defaults."
|
||||||
|
echo ""
|
||||||
|
isNotice "Bookstack admin login (default):"
|
||||||
|
echo ""
|
||||||
|
echo " Email : admin@admin.com"
|
||||||
|
echo " Password : password"
|
||||||
|
echo ""
|
||||||
|
else
|
||||||
|
isSuccessful "Bookstack is online (HTTP ${bookstack_http_code})."
|
||||||
|
|
||||||
|
bookstack_create_output=$(sudo docker exec \
|
||||||
|
-e EZ_BS_NEW_EMAIL="$bookstack_target_email" \
|
||||||
|
-e EZ_BS_NEW_PASS="$bookstack_target_pass" \
|
||||||
|
bookstack sh -c 'cd /app/www && s6-setuidgid abc php artisan bookstack:create-admin --no-ansi --email="$EZ_BS_NEW_EMAIL" --name=Admin --password="$EZ_BS_NEW_PASS" 2>&1')
|
||||||
|
bookstack_create_rc=$?
|
||||||
|
if [[ $bookstack_create_rc -eq 0 ]]; then
|
||||||
|
isSuccessful "Bookstack admin account created (email: $bookstack_target_email)."
|
||||||
|
|
||||||
|
if [[ "$bookstack_target_email" != "admin@admin.com" ]]; then
|
||||||
|
sudo docker exec -i bookstack php /app/www/artisan tinker --no-ansi >/dev/null 2>&1 <<'PHP'
|
||||||
|
$c = class_exists('\BookStack\Users\Models\User') ? '\BookStack\Users\Models\User' : '\BookStack\Auth\User';
|
||||||
|
optional($c::where('email', 'admin@admin.com')->first())->delete();
|
||||||
|
PHP
|
||||||
|
isSuccessful "Removed seeded admin@admin.com account."
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
isNotice "Bookstack admin login:"
|
||||||
|
echo ""
|
||||||
|
echo " Email : ${bookstack_target_email}"
|
||||||
|
echo " Password : ${bookstack_target_pass}"
|
||||||
|
echo ""
|
||||||
|
else
|
||||||
|
isNotice "Bookstack admin auto-create failed (exit $bookstack_create_rc). Output:"
|
||||||
|
echo "$bookstack_create_output" | sed 's/^/ /'
|
||||||
|
echo ""
|
||||||
|
isNotice "Falling back to upstream defaults — update from inside Bookstack."
|
||||||
|
echo ""
|
||||||
|
isNotice "Bookstack admin login (default):"
|
||||||
|
echo ""
|
||||||
|
echo " Email : admin@admin.com"
|
||||||
|
echo " Password : password"
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
menu_number=0
|
||||||
|
#sleep 3s
|
||||||
|
cd
|
||||||
|
fi
|
||||||
|
bookstack=n
|
||||||
|
}
|
||||||
@ -32,8 +32,6 @@ services:
|
|||||||
labels:
|
labels:
|
||||||
libreportal.category: "CATEGORY_DATA" #LIBREPORTAL|CATEGORY_TAG|CATEGORY_DATA
|
libreportal.category: "CATEGORY_DATA" #LIBREPORTAL|CATEGORY_TAG|CATEGORY_DATA
|
||||||
libreportal.title: "TITLE_DATA" #LIBREPORTAL|TITLE_TAG|TITLE_DATA
|
libreportal.title: "TITLE_DATA" #LIBREPORTAL|TITLE_TAG|TITLE_DATA
|
||||||
libreportal.backup.db: "mariadb:bookstack_db:db:"
|
|
||||||
libreportal.backup.files: "bookstack:/config:data"
|
|
||||||
traefik.enable: TRAEFIK_ENABLE_DATA #LIBREPORTAL|TRAEFIK_ENABLE_TAG|TRAEFIK_ENABLE_DATA
|
traefik.enable: TRAEFIK_ENABLE_DATA #LIBREPORTAL|TRAEFIK_ENABLE_TAG|TRAEFIK_ENABLE_DATA
|
||||||
# TRAEFIK_PORT_1_BEGIN
|
# TRAEFIK_PORT_1_BEGIN
|
||||||
traefik.http.routers.bookstack-service.entrypoints: web,websecure
|
traefik.http.routers.bookstack-service.entrypoints: web,websecure
|
||||||
|
|||||||
@ -1,84 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# Bookstack install hooks — drive the post-start admin account bootstrap.
|
|
||||||
# Generic installApp driver handles compose / start / db / monitoring; this
|
|
||||||
# adds the readiness probe + first-admin provisioning the original
|
|
||||||
# installBookstack() did inline.
|
|
||||||
|
|
||||||
bookstack_install_post_start()
|
|
||||||
{
|
|
||||||
local app_name="$1"
|
|
||||||
|
|
||||||
local bookstack_target_email="${CFG_BOOKSTACK_ADMIN_EMAIL:-admin@admin.com}"
|
|
||||||
local bookstack_target_pass="${CFG_BOOKSTACK_ADMIN_PASSWORD:-password}"
|
|
||||||
|
|
||||||
local bookstack_compose_file="$containers_dir$app_name/docker-compose.yml"
|
|
||||||
local bookstack_port_pair
|
|
||||||
bookstack_port_pair=$(tagsManagerGetTagContent "$bookstack_compose_file" "PORTS_TAG_1")
|
|
||||||
local bookstack_host_port="${bookstack_port_pair%%:*}"
|
|
||||||
local bookstack_probe_url="http://127.0.0.1:${bookstack_host_port}/login"
|
|
||||||
|
|
||||||
isNotice "Waiting for Bookstack to come online at ${bookstack_probe_url} ..."
|
|
||||||
isNotice "This may take up to 20 seconds, please wait..."
|
|
||||||
|
|
||||||
local bookstack_attempts=0
|
|
||||||
local bookstack_ready=0
|
|
||||||
local bookstack_http_code
|
|
||||||
while ((bookstack_attempts < 60)); do
|
|
||||||
bookstack_http_code=$(curl -sS -o /dev/null --max-time 3 -w '%{http_code}' "$bookstack_probe_url" 2>/dev/null)
|
|
||||||
if [[ "$bookstack_http_code" =~ ^(200|302)$ ]]; then
|
|
||||||
bookstack_ready=1
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
sleep 2
|
|
||||||
((bookstack_attempts++))
|
|
||||||
done
|
|
||||||
|
|
||||||
if ((bookstack_ready == 0)); then
|
|
||||||
isNotice "Bookstack did not respond on ${bookstack_probe_url} within $((60 * 2))s — admin account left at upstream defaults."
|
|
||||||
echo ""
|
|
||||||
isNotice "Bookstack admin login (default):"
|
|
||||||
echo ""
|
|
||||||
echo " Email : admin@admin.com"
|
|
||||||
echo " Password : password"
|
|
||||||
echo ""
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
isSuccessful "Bookstack is online (HTTP ${bookstack_http_code})."
|
|
||||||
|
|
||||||
local bookstack_create_output
|
|
||||||
bookstack_create_output=$(runFileOp docker exec \
|
|
||||||
-e EZ_BS_NEW_EMAIL="$bookstack_target_email" \
|
|
||||||
-e EZ_BS_NEW_PASS="$bookstack_target_pass" \
|
|
||||||
bookstack sh -c 'cd /app/www && s6-setuidgid abc php artisan bookstack:create-admin --no-ansi --email="$EZ_BS_NEW_EMAIL" --name=Admin --password="$EZ_BS_NEW_PASS" 2>&1')
|
|
||||||
local bookstack_create_rc=$?
|
|
||||||
if [[ $bookstack_create_rc -eq 0 ]]; then
|
|
||||||
isSuccessful "Bookstack admin account created (email: $bookstack_target_email)."
|
|
||||||
|
|
||||||
if [[ "$bookstack_target_email" != "admin@admin.com" ]]; then
|
|
||||||
runFileOp docker exec -i bookstack php /app/www/artisan tinker --no-ansi >/dev/null 2>&1 <<'PHP'
|
|
||||||
$c = class_exists('\BookStack\Users\Models\User') ? '\BookStack\Users\Models\User' : '\BookStack\Auth\User';
|
|
||||||
optional($c::where('email', 'admin@admin.com')->first())->delete();
|
|
||||||
PHP
|
|
||||||
isSuccessful "Removed seeded admin@admin.com account."
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
isNotice "Bookstack admin login:"
|
|
||||||
echo ""
|
|
||||||
echo " Email : ${bookstack_target_email}"
|
|
||||||
echo " Password : ${bookstack_target_pass}"
|
|
||||||
echo ""
|
|
||||||
else
|
|
||||||
isNotice "Bookstack admin auto-create failed (exit $bookstack_create_rc). Output:"
|
|
||||||
echo "$bookstack_create_output" | sed 's/^/ /'
|
|
||||||
echo ""
|
|
||||||
isNotice "Falling back to upstream defaults — update from inside Bookstack."
|
|
||||||
echo ""
|
|
||||||
isNotice "Bookstack admin login (default):"
|
|
||||||
echo ""
|
|
||||||
echo " Email : admin@admin.com"
|
|
||||||
echo " Password : password"
|
|
||||||
echo ""
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
@ -1,106 +0,0 @@
|
|||||||
{
|
|
||||||
"tools": [
|
|
||||||
{
|
|
||||||
"id": "reset_password",
|
|
||||||
"category": "users",
|
|
||||||
"label": "Reset User Password",
|
|
||||||
"description": "Set a new password for an existing user. Leave blank to generate one.",
|
|
||||||
"icon": "🔑",
|
|
||||||
"fields": [
|
|
||||||
{
|
|
||||||
"name": "email",
|
|
||||||
"label": "User email",
|
|
||||||
"type": "text",
|
|
||||||
"placeholder": "user@example.com",
|
|
||||||
"required": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "password",
|
|
||||||
"label": "New password",
|
|
||||||
"type": "password",
|
|
||||||
"placeholder": "Leave blank to generate"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "create_account",
|
|
||||||
"category": "users",
|
|
||||||
"label": "Create User Account",
|
|
||||||
"description": "Add a new user. Tick \"Make admin\" for full rights.",
|
|
||||||
"icon": "👤",
|
|
||||||
"fields": [
|
|
||||||
{
|
|
||||||
"name": "email",
|
|
||||||
"label": "Email",
|
|
||||||
"type": "text",
|
|
||||||
"placeholder": "user@example.com",
|
|
||||||
"required": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "name",
|
|
||||||
"label": "Display name",
|
|
||||||
"type": "text",
|
|
||||||
"required": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "password",
|
|
||||||
"label": "Password",
|
|
||||||
"type": "password",
|
|
||||||
"placeholder": "Leave blank to generate"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "admin",
|
|
||||||
"label": "Make admin",
|
|
||||||
"type": "checkbox",
|
|
||||||
"default": false
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "list_users",
|
|
||||||
"category": "users",
|
|
||||||
"label": "List Users",
|
|
||||||
"description": "List every user and their role.",
|
|
||||||
"icon": "📋",
|
|
||||||
"fields": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "delete_user",
|
|
||||||
"category": "users",
|
|
||||||
"label": "Delete User Account",
|
|
||||||
"description": "Permanently remove a user.",
|
|
||||||
"icon": "🗑",
|
|
||||||
"destructive": true,
|
|
||||||
"confirm": "This cannot be undone.",
|
|
||||||
"fields": [
|
|
||||||
{
|
|
||||||
"name": "email",
|
|
||||||
"label": "User email",
|
|
||||||
"type": "text",
|
|
||||||
"required": true
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "set_admin",
|
|
||||||
"category": "users",
|
|
||||||
"label": "Set Admin Status",
|
|
||||||
"description": "Promote or demote a user.",
|
|
||||||
"icon": "👑",
|
|
||||||
"fields": [
|
|
||||||
{
|
|
||||||
"name": "email",
|
|
||||||
"label": "User email",
|
|
||||||
"type": "text",
|
|
||||||
"required": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "admin",
|
|
||||||
"label": "Make admin",
|
|
||||||
"type": "checkbox",
|
|
||||||
"default": false
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@ -19,7 +19,6 @@ CFG_CROWDSEC_HOST_SERVICE=crowdsec
|
|||||||
CFG_CROWDSEC_HOST_SERVICES=crowdsec.service,crowdsec-firewall-bouncer.service
|
CFG_CROWDSEC_HOST_SERVICES=crowdsec.service,crowdsec-firewall-bouncer.service
|
||||||
CFG_CROWDSEC_HOST_LOG_FILES="crowdsec.service|/var/log/crowdsec.log,crowdsec-firewall-bouncer.service|/var/log/crowdsec-firewall-bouncer.log"
|
CFG_CROWDSEC_HOST_LOG_FILES="crowdsec.service|/var/log/crowdsec.log,crowdsec-firewall-bouncer.service|/var/log/crowdsec-firewall-bouncer.log"
|
||||||
CFG_CROWDSEC_BACKUP=true
|
CFG_CROWDSEC_BACKUP=true
|
||||||
CFG_CROWDSEC_BACKUP_STRATEGY=auto
|
|
||||||
CFG_CROWDSEC_MONITORING=false
|
CFG_CROWDSEC_MONITORING=false
|
||||||
CFG_CROWDSEC_PROMETHEUS_LISTEN=0.0.0.0:6060
|
CFG_CROWDSEC_PROMETHEUS_LISTEN=0.0.0.0:6060
|
||||||
#
|
#
|
||||||
|
|||||||
@ -4,8 +4,8 @@
|
|||||||
# Description : CrowdSec - Intrusion Prevention (c/u/s/r/i):
|
# Description : CrowdSec - Intrusion Prevention (c/u/s/r/i):
|
||||||
#
|
#
|
||||||
# Host-installed agent (apt + systemd) — no Docker container. Host install
|
# Host-installed agent (apt + systemd) — no Docker container. Host install
|
||||||
# logic lives in scripts/crowdsec_install_host.sh (installCrowdsecHost) beside
|
# logic lives in scripts/install/install_crowdsec.sh (installCrowdsecHost);
|
||||||
# this file; install registration uses the shared hostAppInstall helper
|
# install registration uses the shared hostAppInstall helper
|
||||||
# (scripts/install/host_app.sh). uninstall/stop/restartCrowdsec (below) are the
|
# (scripts/install/host_app.sh). uninstall/stop/restartCrowdsec (below) are the
|
||||||
# host-side hooks dockerUninstallApp / dockerStopApp / dockerRestartApp invoke.
|
# host-side hooks dockerUninstallApp / dockerStopApp / dockerRestartApp invoke.
|
||||||
|
|
||||||
@ -58,18 +58,18 @@ uninstallCrowdsec()
|
|||||||
echo ""
|
echo ""
|
||||||
echo "---- $menu_number. Stopping CrowdSec host services."
|
echo "---- $menu_number. Stopping CrowdSec host services."
|
||||||
echo ""
|
echo ""
|
||||||
local result; result=$(runSystem systemctl disable --now crowdsec-firewall-bouncer 2>&1)
|
local result=$(sudo systemctl disable --now crowdsec-firewall-bouncer 2>&1)
|
||||||
checkSuccess "Disabling firewall bouncer"
|
checkSuccess "Disabling firewall bouncer"
|
||||||
local result; result=$(runSystem systemctl disable --now crowdsec 2>&1)
|
local result=$(sudo systemctl disable --now crowdsec 2>&1)
|
||||||
checkSuccess "Disabling agent"
|
checkSuccess "Disabling agent"
|
||||||
|
|
||||||
((menu_number++))
|
((menu_number++))
|
||||||
echo ""
|
echo ""
|
||||||
echo "---- $menu_number. Removing CrowdSec packages."
|
echo "---- $menu_number. Removing CrowdSec packages."
|
||||||
echo ""
|
echo ""
|
||||||
local result; result=$(runSystem DEBIAN_FRONTEND=noninteractive apt-get purge -y -q crowdsec crowdsec-firewall-bouncer-nftables </dev/null 2>&1)
|
local result=$(sudo DEBIAN_FRONTEND=noninteractive apt-get purge -y -q crowdsec crowdsec-firewall-bouncer-nftables </dev/null 2>&1)
|
||||||
checkSuccess "Purged packages"
|
checkSuccess "Purged packages"
|
||||||
local result; result=$(runSystem DEBIAN_FRONTEND=noninteractive apt-get autoremove -y -q </dev/null 2>&1)
|
local result=$(sudo DEBIAN_FRONTEND=noninteractive apt-get autoremove -y -q </dev/null 2>&1)
|
||||||
checkSuccess "Removed orphaned dependencies"
|
checkSuccess "Removed orphaned dependencies"
|
||||||
|
|
||||||
crowdsecToggleLibrePortalLogMounts off
|
crowdsecToggleLibrePortalLogMounts off
|
||||||
@ -81,9 +81,9 @@ uninstallCrowdsec()
|
|||||||
stopCrowdsec()
|
stopCrowdsec()
|
||||||
{
|
{
|
||||||
isNotice "Stopping CrowdSec host services..."
|
isNotice "Stopping CrowdSec host services..."
|
||||||
local result; result=$(runSystem systemctl stop crowdsec-firewall-bouncer 2>&1)
|
local result=$(sudo systemctl stop crowdsec-firewall-bouncer 2>&1)
|
||||||
checkSuccess "Stopped firewall bouncer"
|
checkSuccess "Stopped firewall bouncer"
|
||||||
local result; result=$(runSystem systemctl stop crowdsec 2>&1)
|
local result=$(sudo systemctl stop crowdsec 2>&1)
|
||||||
checkSuccess "Stopped agent"
|
checkSuccess "Stopped agent"
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -93,8 +93,8 @@ stopCrowdsec()
|
|||||||
restartCrowdsec()
|
restartCrowdsec()
|
||||||
{
|
{
|
||||||
isNotice "Restarting CrowdSec host services..."
|
isNotice "Restarting CrowdSec host services..."
|
||||||
local result; result=$(runSystem systemctl restart crowdsec 2>&1)
|
local result=$(sudo systemctl restart crowdsec 2>&1)
|
||||||
checkSuccess "Restarted agent"
|
checkSuccess "Restarted agent"
|
||||||
local result; result=$(runSystem systemctl restart crowdsec-firewall-bouncer 2>&1)
|
local result=$(sudo systemctl restart crowdsec-firewall-bouncer 2>&1)
|
||||||
checkSuccess "Restarted firewall bouncer"
|
checkSuccess "Restarted firewall bouncer"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,19 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
appCrowdSecFixPriority() {
|
|
||||||
local cfg="/etc/crowdsec/bouncers/crowdsec-firewall-bouncer.yaml"
|
|
||||||
if [[ ! -f "$cfg" ]]; then
|
|
||||||
isNotice "Bouncer config not found at $cfg — is CrowdSec installed?"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# The bouncer yaml is root-owned under /etc/crowdsec; the backup + nftables
|
|
||||||
# ipv4/ipv6 priority rewrite (to -100) runs in the root-owned crowdsec helper.
|
|
||||||
runCrowdsec bouncer-priority
|
|
||||||
checkSuccess "Patched nftables priority to -100 in $cfg"
|
|
||||||
|
|
||||||
runSystem systemctl restart crowdsec-firewall-bouncer
|
|
||||||
checkSuccess "Restarted crowdsec-firewall-bouncer"
|
|
||||||
|
|
||||||
isSuccessful "Priority updated. Run 'crowdsec_verify_firewall' to confirm CrowdSec now runs before UFW."
|
|
||||||
}
|
|
||||||
@ -11,7 +11,6 @@
|
|||||||
#
|
#
|
||||||
CFG_DASHY_APP_NAME=dashy
|
CFG_DASHY_APP_NAME=dashy
|
||||||
CFG_DASHY_BACKUP=true
|
CFG_DASHY_BACKUP=true
|
||||||
CFG_DASHY_BACKUP_STRATEGY=auto
|
|
||||||
CFG_DASHY_COMPOSE_FILE=default
|
CFG_DASHY_COMPOSE_FILE=default
|
||||||
CFG_DASHY_HEALTHCHECK=true
|
CFG_DASHY_HEALTHCHECK=true
|
||||||
CFG_DASHY_AUTHELIA=false
|
CFG_DASHY_AUTHELIA=false
|
||||||
|
|||||||
114
containers/dashy/dashy.sh
Executable file
114
containers/dashy/dashy.sh
Executable file
@ -0,0 +1,114 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Category : Miscellaneous
|
||||||
|
# Description : Dashy - Dashboard Tool (c/t/u/s/r/i):
|
||||||
|
|
||||||
|
installDashy()
|
||||||
|
{
|
||||||
|
local config_variables="$1"
|
||||||
|
|
||||||
|
if [[ "$dashy" == *[cCtTuUsSrRiI]* ]]; then
|
||||||
|
dockerConfigSetupToContainer silent dashy;
|
||||||
|
local app_name=$CFG_DASHY_APP_NAME
|
||||||
|
initializeAppVariables $app_name;
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$dashy" == *[cC]* ]]; then
|
||||||
|
editAppConfig $app_name;
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$dashy" == *[tT]* ]]; then
|
||||||
|
dashyToolsMenu;
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$dashy" == *[uU]* ]]; then
|
||||||
|
dockerUninstallApp $app_name;
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$dashy" == *[sS]* ]]; then
|
||||||
|
dockerComposeDown $app_name;
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$dashy" == *[rR]* ]]; then
|
||||||
|
dockerComposeRestart $app_name;
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$dashy" == *[iI]* ]]; then
|
||||||
|
isHeader "Install $app_name"
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Setting up install folder and config file for $app_name."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
dockerConfigSetupToContainer "loud" "$app_name" "install" "$config_variables";
|
||||||
|
isSuccessful "Install folders and Config files have been setup for $app_name."
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Setting up the $app_name docker-compose.yml file."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
dockerComposeSetupFile $app_name;
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Updating file permissions before starting."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
fixPermissionsBeforeStart $app_name;
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Running the docker-compose.yml to install and start $app_name"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
dockerComposeUpdateAndStartApp $app_name install;
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Running Application specific updates (if required)"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
appUpdateSpecifics $app_name;
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Running Headscale setup (if required)"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
setupHeadscale $app_name;
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Adding $app_name to the Apps Database table."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
databaseInstallApp $app_name;
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Updating WebUI config file."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
webuiContainerSetup $app_name install;
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. You can find $app_name files at $containers_dir$app_name"
|
||||||
|
echo ""
|
||||||
|
echo " You can now navigate to your new service using one of the options below : "
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
menuShowFinalMessages $app_name;
|
||||||
|
|
||||||
|
menu_number=0
|
||||||
|
#sleep 3s
|
||||||
|
cd
|
||||||
|
fi
|
||||||
|
dashy=n
|
||||||
|
}
|
||||||
@ -1,10 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# Post-install/update specifics for Dashy — dispatched by appUpdateSpecifics.
|
|
||||||
appUpdateSpecifics_dashy() {
|
|
||||||
# Refresh apps-services.json (the source of truth appDashyUpdateConf reads)
|
|
||||||
# before generating dashy's conf.yml. On a first dashy install the file may
|
|
||||||
# not yet reflect dashy itself; on a re-install the previous selection survives.
|
|
||||||
webuiLibrePortalUpdate
|
|
||||||
appDashyUpdateConf
|
|
||||||
}
|
|
||||||
@ -1,21 +0,0 @@
|
|||||||
{
|
|
||||||
"tools": [
|
|
||||||
{
|
|
||||||
"id": "manage_shortcuts",
|
|
||||||
"label": "Manage Shortcuts",
|
|
||||||
"description": "Pick which apps appear on the Dashy dashboard.",
|
|
||||||
"icon": "🧩",
|
|
||||||
"fields": [
|
|
||||||
{
|
|
||||||
"name": "selected",
|
|
||||||
"label": "Apps to show on the dashboard",
|
|
||||||
"type": "app_urls_multi",
|
|
||||||
"prefillFromCfgKey": "CFG_DASHY_SHORTCUTS",
|
|
||||||
"excludeApps": [
|
|
||||||
"dashy"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@ -11,7 +11,7 @@ services:
|
|||||||
- "PORTS_DATA_1" #LIBREPORTAL|PORTS_TAG_1|PORTS_DATA_1
|
- "PORTS_DATA_1" #LIBREPORTAL|PORTS_TAG_1|PORTS_DATA_1
|
||||||
# GLUETUN_OFF_END
|
# GLUETUN_OFF_END
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/opt/focalboard/data
|
- ./data:/data
|
||||||
environment:
|
environment:
|
||||||
- VIRTUAL_HOST:DOMAINSUBNAME_DATA #LIBREPORTAL|DOMAINSUBNAME_TAG|DOMAINSUBNAME_DATA
|
- VIRTUAL_HOST:DOMAINSUBNAME_DATA #LIBREPORTAL|DOMAINSUBNAME_TAG|DOMAINSUBNAME_DATA
|
||||||
- VIRTUAL_PORT:8000
|
- VIRTUAL_PORT:8000
|
||||||
@ -22,8 +22,6 @@ services:
|
|||||||
labels:
|
labels:
|
||||||
libreportal.category: "CATEGORY_DATA" #LIBREPORTAL|CATEGORY_TAG|CATEGORY_DATA
|
libreportal.category: "CATEGORY_DATA" #LIBREPORTAL|CATEGORY_TAG|CATEGORY_DATA
|
||||||
libreportal.title: "TITLE_DATA" #LIBREPORTAL|TITLE_TAG|TITLE_DATA
|
libreportal.title: "TITLE_DATA" #LIBREPORTAL|TITLE_TAG|TITLE_DATA
|
||||||
libreportal.backup.db: "sqlite:::data/focalboard.db"
|
|
||||||
libreportal.backup.files: "focalboard-service:/opt/focalboard/data:data"
|
|
||||||
traefik.enable: TRAEFIK_ENABLE_DATA #LIBREPORTAL|TRAEFIK_ENABLE_TAG|TRAEFIK_ENABLE_DATA
|
traefik.enable: TRAEFIK_ENABLE_DATA #LIBREPORTAL|TRAEFIK_ENABLE_TAG|TRAEFIK_ENABLE_DATA
|
||||||
# TRAEFIK_PORT_1_BEGIN
|
# TRAEFIK_PORT_1_BEGIN
|
||||||
traefik.http.routers.focalboard-service.entrypoints: web,websecure
|
traefik.http.routers.focalboard-service.entrypoints: web,websecure
|
||||||
|
|||||||
@ -11,7 +11,6 @@
|
|||||||
#
|
#
|
||||||
CFG_FOCALBOARD_APP_NAME=focalboard
|
CFG_FOCALBOARD_APP_NAME=focalboard
|
||||||
CFG_FOCALBOARD_BACKUP=true
|
CFG_FOCALBOARD_BACKUP=true
|
||||||
CFG_FOCALBOARD_BACKUP_STRATEGY=auto
|
|
||||||
CFG_FOCALBOARD_COMPOSE_FILE=default
|
CFG_FOCALBOARD_COMPOSE_FILE=default
|
||||||
CFG_FOCALBOARD_HEALTHCHECK=true
|
CFG_FOCALBOARD_HEALTHCHECK=true
|
||||||
CFG_FOCALBOARD_AUTHELIA=false
|
CFG_FOCALBOARD_AUTHELIA=false
|
||||||
|
|||||||
110
containers/focalboard/focalboard.sh
Executable file
110
containers/focalboard/focalboard.sh
Executable file
@ -0,0 +1,110 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Category : Productivity
|
||||||
|
# Description : Focalboard - Project Management (c/u/s/r/i):
|
||||||
|
|
||||||
|
installFocalboard()
|
||||||
|
{
|
||||||
|
local config_variables="$1"
|
||||||
|
|
||||||
|
if [[ "$focalboard" == *[cCtTuUsSrRiI]* ]]; then
|
||||||
|
dockerConfigSetupToContainer silent focalboard;
|
||||||
|
local app_name=$CFG_FOCALBOARD_APP_NAME
|
||||||
|
initializeAppVariables $app_name;
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$focalboard" == *[cC]* ]]; then
|
||||||
|
editAppConfig $app_name;
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$focalboard" == *[uU]* ]]; then
|
||||||
|
dockerUninstallApp $app_name;
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$focalboard" == *[sS]* ]]; then
|
||||||
|
dockerComposeDown $app_name;
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$focalboard" == *[rR]* ]]; then
|
||||||
|
dockerComposeRestart $app_name;
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$focalboard" == *[iI]* ]]; then
|
||||||
|
isHeader "Install $app_name"
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Setting up install folder and config file for $app_name."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
dockerConfigSetupToContainer "loud" "$app_name" "install" "$config_variables";
|
||||||
|
isSuccessful "Install folders and Config files have been setup for $app_name."
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Setting up the $app_name docker-compose.yml file."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
dockerComposeSetupFile $app_name;
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Updating file permissions before starting."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
fixPermissionsBeforeStart $app_name;
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Running the docker-compose.yml to install and start $app_name"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
dockerComposeUpdateAndStartApp $app_name install;
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Running Application specific updates (if required)"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
appUpdateSpecifics $app_name;
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Running Headscale setup (if required)"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
setupHeadscale $app_name;
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Adding $app_name to the Apps Database table."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
databaseInstallApp $app_name;
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Updating WebUI config file."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
webuiContainerSetup $app_name install;
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. You can find $app_name files at $containers_dir$app_name"
|
||||||
|
echo ""
|
||||||
|
echo " You can now navigate to your $app_name service using one of the options below : "
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
menuShowFinalMessages $app_name;
|
||||||
|
|
||||||
|
menu_number=0
|
||||||
|
#sleep 3s
|
||||||
|
cd
|
||||||
|
fi
|
||||||
|
focalboard=n
|
||||||
|
}
|
||||||
@ -1,12 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# Post-install/update specifics for Focalboard — dispatched by appUpdateSpecifics.
|
|
||||||
appUpdateSpecifics_focalboard() {
|
|
||||||
local app_name="$1"
|
|
||||||
# Focalboard runs as nobody (65534) and writes its sqlite db + uploads under
|
|
||||||
# its mounted data dir; fixPermissionsBeforeStart hands the dir to the install
|
|
||||||
# user, so give it to 65534 here or the server can't open the database.
|
|
||||||
# Setting shouldrestart (not local) requests the restart in appUpdateSpecifics.
|
|
||||||
runOwnership app-data-nobody "$app_name"
|
|
||||||
shouldrestart="true"
|
|
||||||
}
|
|
||||||
@ -1,83 +0,0 @@
|
|||||||
{
|
|
||||||
"tools": [
|
|
||||||
{
|
|
||||||
"id": "reset_password",
|
|
||||||
"category": "users",
|
|
||||||
"label": "Reset User Password",
|
|
||||||
"description": "Set a new password for an existing user. Leave blank to generate one.",
|
|
||||||
"icon": "🔑",
|
|
||||||
"fields": [
|
|
||||||
{
|
|
||||||
"name": "username",
|
|
||||||
"label": "Username",
|
|
||||||
"type": "text",
|
|
||||||
"required": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "password",
|
|
||||||
"label": "New password",
|
|
||||||
"type": "password",
|
|
||||||
"placeholder": "Leave blank to generate"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "create_account",
|
|
||||||
"category": "users",
|
|
||||||
"label": "Create User Account",
|
|
||||||
"description": "Add a new user. Tick \"Make admin\" for full rights.",
|
|
||||||
"icon": "👤",
|
|
||||||
"fields": [
|
|
||||||
{
|
|
||||||
"name": "username",
|
|
||||||
"label": "Username",
|
|
||||||
"type": "text",
|
|
||||||
"required": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "email",
|
|
||||||
"label": "Email",
|
|
||||||
"type": "text",
|
|
||||||
"required": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "password",
|
|
||||||
"label": "Password",
|
|
||||||
"type": "password",
|
|
||||||
"placeholder": "Leave blank to generate"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "admin",
|
|
||||||
"label": "Make admin",
|
|
||||||
"type": "checkbox",
|
|
||||||
"default": false
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "list_users",
|
|
||||||
"category": "users",
|
|
||||||
"label": "List Users",
|
|
||||||
"description": "List every user.",
|
|
||||||
"icon": "📋",
|
|
||||||
"fields": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "delete_user",
|
|
||||||
"category": "users",
|
|
||||||
"label": "Delete User Account",
|
|
||||||
"description": "Permanently remove a user.",
|
|
||||||
"icon": "🗑",
|
|
||||||
"destructive": true,
|
|
||||||
"confirm": "This cannot be undone.",
|
|
||||||
"fields": [
|
|
||||||
{
|
|
||||||
"name": "username",
|
|
||||||
"label": "Username",
|
|
||||||
"type": "text",
|
|
||||||
"required": true
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@ -64,8 +64,6 @@ services:
|
|||||||
labels:
|
labels:
|
||||||
libreportal.category: "CATEGORY_DATA" #LIBREPORTAL|CATEGORY_TAG|CATEGORY_DATA
|
libreportal.category: "CATEGORY_DATA" #LIBREPORTAL|CATEGORY_TAG|CATEGORY_DATA
|
||||||
libreportal.title: "TITLE_DATA" #LIBREPORTAL|TITLE_TAG|TITLE_DATA
|
libreportal.title: "TITLE_DATA" #LIBREPORTAL|TITLE_TAG|TITLE_DATA
|
||||||
libreportal.backup.db: "sqlite:::data/gitea/gitea/gitea.db"
|
|
||||||
libreportal.backup.files: "gitea-service:/data:data/gitea"
|
|
||||||
traefik.enable: TRAEFIK_ENABLE_DATA #LIBREPORTAL|TRAEFIK_ENABLE_TAG|TRAEFIK_ENABLE_DATA
|
traefik.enable: TRAEFIK_ENABLE_DATA #LIBREPORTAL|TRAEFIK_ENABLE_TAG|TRAEFIK_ENABLE_DATA
|
||||||
# TRAEFIK_PORT_1_BEGIN
|
# TRAEFIK_PORT_1_BEGIN
|
||||||
traefik.http.routers.gitea-service.entrypoints: web,websecure
|
traefik.http.routers.gitea-service.entrypoints: web,websecure
|
||||||
|
|||||||
@ -13,7 +13,6 @@
|
|||||||
#
|
#
|
||||||
CFG_GITEA_APP_NAME=gitea
|
CFG_GITEA_APP_NAME=gitea
|
||||||
CFG_GITEA_BACKUP=true
|
CFG_GITEA_BACKUP=true
|
||||||
CFG_GITEA_BACKUP_STRATEGY=auto
|
|
||||||
CFG_GITEA_COMPOSE_FILE=default
|
CFG_GITEA_COMPOSE_FILE=default
|
||||||
CFG_GITEA_HEALTHCHECK=true
|
CFG_GITEA_HEALTHCHECK=true
|
||||||
CFG_GITEA_AUTHELIA=false
|
CFG_GITEA_AUTHELIA=false
|
||||||
|
|||||||
139
containers/gitea/gitea.sh
Executable file
139
containers/gitea/gitea.sh
Executable file
@ -0,0 +1,139 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Category : Development & Version Control
|
||||||
|
# Description : Gitea - Git Repository Management (c/u/s/r/i):
|
||||||
|
|
||||||
|
installGitea()
|
||||||
|
{
|
||||||
|
local config_variables="$1"
|
||||||
|
|
||||||
|
if [[ "$gitea" == *[cCtTuUsSrRiI]* ]]; then
|
||||||
|
dockerConfigSetupToContainer silent gitea;
|
||||||
|
local app_name=$CFG_GITEA_APP_NAME
|
||||||
|
initializeAppVariables $app_name;
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$gitea" == *[cC]* ]]; then
|
||||||
|
editAppConfig $app_name;
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$gitea" == *[uU]* ]]; then
|
||||||
|
dockerUninstallApp $app_name;
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$gitea" == *[sS]* ]]; then
|
||||||
|
dockerComposeDown $app_name;
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$gitea" == *[rR]* ]]; then
|
||||||
|
dockerComposeRestart $app_name;
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$gitea" == *[iI]* ]]; then
|
||||||
|
isHeader "Install $app_name"
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Setting up install folder and config file for $app_name."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
dockerConfigSetupToContainer "loud" "$app_name" "install" "$config_variables";
|
||||||
|
isSuccessful "Install folders and Config files have been setup for $app_name."
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Setting up the $app_name docker-compose.yml file."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
dockerComposeSetupFile $app_name;
|
||||||
|
|
||||||
|
# Enable Gitea's /metrics endpoint only when CFG_GITEA_MONITORING=true
|
||||||
|
# (toggles the libreportal-monitoring marker block in the compose).
|
||||||
|
monitoringToggleAppConfig "$app_name" "docker-compose.yml";
|
||||||
|
|
||||||
|
# /metrics rides Gitea's public web port, so it's locked behind a
|
||||||
|
# bearer token. CFG_GITEA_METRICS_TOKEN lives in the .config (filled
|
||||||
|
# once by the RANDOMIZEDPASSWORD scanner, preserved across reinstalls)
|
||||||
|
# and reaches the compose via the GITEA_METRICS_TOKEN_TAG tag — mirror
|
||||||
|
# that same value into the Prometheus scrape fragment so the two agree.
|
||||||
|
if monitoringAppEnabled "$app_name"; then
|
||||||
|
if [[ -n "$CFG_GITEA_METRICS_TOKEN" ]]; then
|
||||||
|
sudo sed -i "s|GITEA_METRICS_TOKEN_PLACEHOLDER|${CFG_GITEA_METRICS_TOKEN}|g" \
|
||||||
|
"$containers_dir$app_name/resources/monitoring/prometheus-scrape.yml"
|
||||||
|
checkSuccess "Synced Gitea /metrics token to the Prometheus scrape config"
|
||||||
|
else
|
||||||
|
isNotice "CFG_GITEA_METRICS_TOKEN is empty — Gitea /metrics scrape may 401."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Updating file permissions before starting."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
fixPermissionsBeforeStart $app_name;
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Running the docker-compose.yml to install and start $app_name"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
dockerComposeUpdateAndStartApp $app_name install;
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Running Application specific updates (if required)"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
appUpdateSpecifics $app_name;
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Running Headscale setup (if required)"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
setupHeadscale $app_name;
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Adding $app_name to the Apps Database table."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
databaseInstallApp $app_name;
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Updating WebUI config file."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
webuiContainerSetup $app_name install;
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Refreshing monitoring integration."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Self-correcting: adds Gitea's scrape target + dashboard to
|
||||||
|
# Prometheus/Grafana when CFG_GITEA_MONITORING=true, removes them when
|
||||||
|
# it's off. No-ops with a notice if either app isn't installed.
|
||||||
|
monitoringRefreshAll;
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. You can find $app_name files at $containers_dir$app_name"
|
||||||
|
echo ""
|
||||||
|
echo " You can now navigate to your $app_name service using one of the options below : "
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
menuShowFinalMessages $app_name;
|
||||||
|
|
||||||
|
menu_number=0
|
||||||
|
#sleep 3s
|
||||||
|
cd
|
||||||
|
fi
|
||||||
|
gitea=n
|
||||||
|
}
|
||||||
@ -1,25 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# Gitea install hooks — mirror CFG_GITEA_METRICS_TOKEN into the Prometheus
|
|
||||||
# scrape fragment so the bearer token in the compose env matches what the
|
|
||||||
# Prometheus side sends.
|
|
||||||
|
|
||||||
gitea_install_post_compose()
|
|
||||||
{
|
|
||||||
local app_name="$1"
|
|
||||||
|
|
||||||
# The driver already ran monitoringToggleAppConfig "$app_name" docker-compose.yml,
|
|
||||||
# so the metrics block reflects CFG_GITEA_MONITORING. /metrics rides
|
|
||||||
# Gitea's public web port and is token-protected; sync the token into
|
|
||||||
# the scrape config so the two sides agree.
|
|
||||||
if monitoringAppEnabled "$app_name"; then
|
|
||||||
if [[ -n "$CFG_GITEA_METRICS_TOKEN" ]]; then
|
|
||||||
local result
|
|
||||||
result=$(runFileOp sed -i "s|GITEA_METRICS_TOKEN_PLACEHOLDER|${CFG_GITEA_METRICS_TOKEN}|g" \
|
|
||||||
"$containers_dir$app_name/resources/monitoring/prometheus-scrape.yml")
|
|
||||||
checkSuccess "Synced Gitea /metrics token to the Prometheus scrape config"
|
|
||||||
else
|
|
||||||
isNotice "CFG_GITEA_METRICS_TOKEN is empty — Gitea /metrics scrape may 401."
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
@ -1,105 +0,0 @@
|
|||||||
{
|
|
||||||
"tools": [
|
|
||||||
{
|
|
||||||
"id": "reset_password",
|
|
||||||
"category": "users",
|
|
||||||
"label": "Reset User Password",
|
|
||||||
"description": "Set a new password for an existing user. Leave blank to generate one.",
|
|
||||||
"icon": "🔑",
|
|
||||||
"fields": [
|
|
||||||
{
|
|
||||||
"name": "username",
|
|
||||||
"label": "Username",
|
|
||||||
"type": "text",
|
|
||||||
"required": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "password",
|
|
||||||
"label": "New password",
|
|
||||||
"type": "password",
|
|
||||||
"placeholder": "Leave blank to generate"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "create_account",
|
|
||||||
"category": "users",
|
|
||||||
"label": "Create User Account",
|
|
||||||
"description": "Add a new user. Tick \"Make admin\" for full rights.",
|
|
||||||
"icon": "👤",
|
|
||||||
"fields": [
|
|
||||||
{
|
|
||||||
"name": "username",
|
|
||||||
"label": "Username",
|
|
||||||
"type": "text",
|
|
||||||
"required": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "email",
|
|
||||||
"label": "Email",
|
|
||||||
"type": "text",
|
|
||||||
"placeholder": "user@example.com",
|
|
||||||
"required": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "password",
|
|
||||||
"label": "Password",
|
|
||||||
"type": "password",
|
|
||||||
"placeholder": "Leave blank to generate"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "admin",
|
|
||||||
"label": "Make admin",
|
|
||||||
"type": "checkbox",
|
|
||||||
"default": false
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "list_users",
|
|
||||||
"category": "users",
|
|
||||||
"label": "List Users",
|
|
||||||
"description": "List every user.",
|
|
||||||
"icon": "📋",
|
|
||||||
"fields": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "delete_user",
|
|
||||||
"category": "users",
|
|
||||||
"label": "Delete User Account",
|
|
||||||
"description": "Permanently remove a user.",
|
|
||||||
"icon": "🗑",
|
|
||||||
"destructive": true,
|
|
||||||
"confirm": "This cannot be undone.",
|
|
||||||
"fields": [
|
|
||||||
{
|
|
||||||
"name": "username",
|
|
||||||
"label": "Username",
|
|
||||||
"type": "text",
|
|
||||||
"required": true
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "set_admin",
|
|
||||||
"category": "users",
|
|
||||||
"label": "Set Admin Status",
|
|
||||||
"description": "Promote or demote a user.",
|
|
||||||
"icon": "👑",
|
|
||||||
"fields": [
|
|
||||||
{
|
|
||||||
"name": "username",
|
|
||||||
"label": "Username",
|
|
||||||
"type": "text",
|
|
||||||
"required": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "admin",
|
|
||||||
"label": "Make admin",
|
|
||||||
"type": "checkbox",
|
|
||||||
"default": false
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@ -12,7 +12,6 @@
|
|||||||
#
|
#
|
||||||
CFG_GLUETUN_APP_NAME=gluetun
|
CFG_GLUETUN_APP_NAME=gluetun
|
||||||
CFG_GLUETUN_BACKUP=true
|
CFG_GLUETUN_BACKUP=true
|
||||||
CFG_GLUETUN_BACKUP_STRATEGY=auto
|
|
||||||
CFG_GLUETUN_COMPOSE_FILE=default
|
CFG_GLUETUN_COMPOSE_FILE=default
|
||||||
CFG_GLUETUN_HEALTHCHECK=true
|
CFG_GLUETUN_HEALTHCHECK=true
|
||||||
CFG_GLUETUN_AUTHELIA=false
|
CFG_GLUETUN_AUTHELIA=false
|
||||||
|
|||||||
136
containers/gluetun/gluetun.sh
Normal file
136
containers/gluetun/gluetun.sh
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Category : Networking
|
||||||
|
# Description : Gluetun - VPN client for routing other containers (c/u/s/r/i):
|
||||||
|
|
||||||
|
installGluetun()
|
||||||
|
{
|
||||||
|
local config_variables="$1"
|
||||||
|
|
||||||
|
if [[ "$gluetun" == *[cCtTuUsSrRiI]* ]]; then
|
||||||
|
dockerConfigSetupToContainer silent gluetun;
|
||||||
|
local app_name=$CFG_GLUETUN_APP_NAME
|
||||||
|
initializeAppVariables $app_name;
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$gluetun" == *[cC]* ]]; then
|
||||||
|
editAppConfig $app_name;
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$gluetun" == *[uU]* ]]; then
|
||||||
|
dockerUninstallApp $app_name;
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$gluetun" == *[sS]* ]]; then
|
||||||
|
dockerComposeDown $app_name;
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$gluetun" == *[rR]* ]]; then
|
||||||
|
dockerComposeRestart $app_name;
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$gluetun" == *[iI]* ]]; then
|
||||||
|
isHeader "Install $app_name"
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Setting up install folder and config file for $app_name."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
dockerConfigSetupToContainer "loud" "$app_name" "install" "$config_variables";
|
||||||
|
isSuccessful "Install folders and Config files have been setup for $app_name."
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Setting up the $app_name docker-compose.yml file."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
dockerComposeSetupFile $app_name;
|
||||||
|
|
||||||
|
monitoringToggleAppConfig "$app_name" "docker-compose.yml";
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Updating file permissions before starting."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
fixPermissionsBeforeStart $app_name;
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Running the docker-compose.yml to install and start $app_name"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
dockerComposeUpdateAndStartApp $app_name install;
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Running Application specific updates (if required)"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
appUpdateSpecifics $app_name;
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Running Headscale setup (if required)"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
setupHeadscale $app_name;
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Adding $app_name to the Apps Database table."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
databaseInstallApp $app_name;
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Updating the WebUI config file."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
webuiContainerSetup $app_name install;
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Refreshing Gluetun provider snapshot."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
webuiGenerateGluetunProviders;
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Re-attaching gluetun-routed apps (post-recreate)."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Gluetun was just (re)created — every existing routed app holds a
|
||||||
|
# stale container ID in its network_mode. Reattach them now so the
|
||||||
|
# user doesn't have to chase silent netns drift later.
|
||||||
|
appGluetunRoutedRecreate
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Routing existing apps through Gluetun (optional)."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
gluetunRouteExistingAppsPrompt;
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Refreshing monitoring integration."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
monitoringRefreshAll;
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. You can find $app_name files at $containers_dir$app_name"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
menuShowFinalMessages "$app_name";
|
||||||
|
|
||||||
|
menu_number=0
|
||||||
|
cd
|
||||||
|
fi
|
||||||
|
gluetun=n
|
||||||
|
}
|
||||||
@ -1,16 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# App-specific compose tags for Gluetun (VPN gateway) + its forwarded-port wiring.
|
|
||||||
appSetupComposeTags_gluetun() {
|
|
||||||
local full_file_path="$1"
|
|
||||||
tagsManagerUpdateUniversalTag "$full_file_path" "GLUETUN_VPN_SERVICE_PROVIDER_TAG" "$CFG_GLUETUN_VPN_SERVICE_PROVIDER"
|
|
||||||
tagsManagerUpdateUniversalTag "$full_file_path" "GLUETUN_VPN_TYPE_TAG" "$CFG_GLUETUN_VPN_TYPE"
|
|
||||||
tagsManagerUpdateUniversalTag "$full_file_path" "GLUETUN_VPN_COUNTRIES_TAG" "$CFG_GLUETUN_VPN_COUNTRIES"
|
|
||||||
tagsManagerUpdateUniversalTag "$full_file_path" "GLUETUN_OPENVPN_USER_TAG" "$CFG_GLUETUN_OPENVPN_USER"
|
|
||||||
tagsManagerUpdateUniversalTag "$full_file_path" "GLUETUN_OPENVPN_PASSWORD_TAG" "$CFG_GLUETUN_OPENVPN_PASSWORD"
|
|
||||||
tagsManagerUpdateUniversalTag "$full_file_path" "GLUETUN_WIREGUARD_PRIVATE_KEY_TAG" "$CFG_GLUETUN_WIREGUARD_PRIVATE_KEY"
|
|
||||||
tagsManagerUpdateUniversalTag "$full_file_path" "GLUETUN_WIREGUARD_ADDRESSES_TAG" "$CFG_GLUETUN_WIREGUARD_ADDRESSES"
|
|
||||||
tagsManagerUpdateUniversalTag "$full_file_path" "GLUETUN_HEALTH_TARGETS_TAG" "${CFG_GLUETUN_HEALTH_TARGETS:-mullvad.net:443,eff.org:443}"
|
|
||||||
tagsManagerUpdateUniversalTag "$full_file_path" "GLUETUN_HEALTH_ICMP_IPS_TAG" "${CFG_GLUETUN_HEALTH_ICMP_IPS:-9.9.9.9}"
|
|
||||||
appNetworkRegisterPorts_gluetun
|
|
||||||
}
|
|
||||||
@ -1,35 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# Gluetun install hooks — post-start provider snapshot refresh + reattach
|
|
||||||
# any apps routed through gluetun (their network_mode holds a stale
|
|
||||||
# container ID after gluetun was just recreated) + offer to onboard
|
|
||||||
# existing apps.
|
|
||||||
|
|
||||||
gluetun_install_post_start()
|
|
||||||
{
|
|
||||||
local app_name="$1"
|
|
||||||
|
|
||||||
((menu_number++))
|
|
||||||
echo ""
|
|
||||||
echo "---- $menu_number. Refreshing Gluetun provider snapshot."
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
appWebuiRefresh_gluetun
|
|
||||||
|
|
||||||
((menu_number++))
|
|
||||||
echo ""
|
|
||||||
echo "---- $menu_number. Re-attaching gluetun-routed apps (post-recreate)."
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Gluetun was just (re)created — every existing routed app holds a
|
|
||||||
# stale container ID in its network_mode. Reattach them now so the
|
|
||||||
# user doesn't have to chase silent netns drift later.
|
|
||||||
appGluetunRoutedRecreate
|
|
||||||
|
|
||||||
((menu_number++))
|
|
||||||
echo ""
|
|
||||||
echo "---- $menu_number. Routing existing apps through Gluetun (optional)."
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
gluetunRouteExistingAppsPrompt
|
|
||||||
}
|
|
||||||
@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"tools": [
|
|
||||||
{
|
|
||||||
"id": "refresh_providers",
|
|
||||||
"label": "Refresh VPN Providers",
|
|
||||||
"description": "Refresh the VPN provider and country lists.",
|
|
||||||
"icon": "🔄",
|
|
||||||
"fields": []
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@ -14,7 +14,6 @@
|
|||||||
CFG_GRAFANA_APP_NAME=grafana
|
CFG_GRAFANA_APP_NAME=grafana
|
||||||
CFG_GRAFANA_REQUIRES="prometheus"
|
CFG_GRAFANA_REQUIRES="prometheus"
|
||||||
CFG_GRAFANA_BACKUP=true
|
CFG_GRAFANA_BACKUP=true
|
||||||
CFG_GRAFANA_BACKUP_STRATEGY=auto
|
|
||||||
CFG_GRAFANA_COMPOSE_FILE=default
|
CFG_GRAFANA_COMPOSE_FILE=default
|
||||||
CFG_GRAFANA_HEALTHCHECK=true
|
CFG_GRAFANA_HEALTHCHECK=true
|
||||||
CFG_GRAFANA_AUTHELIA=false
|
CFG_GRAFANA_AUTHELIA=false
|
||||||
|
|||||||
133
containers/grafana/grafana.sh
Executable file
133
containers/grafana/grafana.sh
Executable file
@ -0,0 +1,133 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Category : Development & Version Control
|
||||||
|
# Description : Grafana - Metrics Visualizer (c/u/s/r/i):
|
||||||
|
|
||||||
|
installGrafana()
|
||||||
|
{
|
||||||
|
local config_variables="$1"
|
||||||
|
|
||||||
|
if [[ "$grafana" == *[cCtTuUsSrRiI]* ]]; then
|
||||||
|
dockerConfigSetupToContainer silent grafana;
|
||||||
|
local app_name=$CFG_GRAFANA_APP_NAME
|
||||||
|
initializeAppVariables $app_name;
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$grafana" == *[cC]* ]]; then
|
||||||
|
editAppConfig $app_name;
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$grafana" == *[uU]* ]]; then
|
||||||
|
dockerUninstallApp $app_name;
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$grafana" == *[sS]* ]]; then
|
||||||
|
dockerComposeDown $app_name;
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$grafana" == *[rR]* ]]; then
|
||||||
|
dockerComposeRestart $app_name;
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$grafana" == *[iI]* ]]; then
|
||||||
|
isHeader "Install $app_name"
|
||||||
|
|
||||||
|
if ! appInstallCheckRequirements "$app_name" "$CFG_GRAFANA_REQUIRES"; then
|
||||||
|
grafana=n
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Setting up install folder and config file for $app_name."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
dockerConfigSetupToContainer "loud" "$app_name" "install" "$config_variables";
|
||||||
|
isSuccessful "Install folders and Config files have been setup for $app_name."
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Setting up the $app_name docker-compose.yml file."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
dockerComposeSetupFile $app_name;
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Updating file permissions before starting."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
fixPermissionsBeforeStart $app_name;
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Running the docker-compose.yml to install and start $app_name"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
dockerComposeUpdateAndStartApp $app_name install;
|
||||||
|
|
||||||
|
# Grafana
|
||||||
|
if [ -d "${containers_dir}grafana/grafana_storage" ]; then
|
||||||
|
local result=$(sudo chmod -R 777 "${containers_dir}grafana/grafana_storage")
|
||||||
|
checkSuccess "Set permissions to grafana_storage folder."
|
||||||
|
fi
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Running Application specific updates (if required)"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
appUpdateSpecifics $app_name;
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Running Headscale setup (if required)"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
setupHeadscale $app_name;
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Adding $app_name to the Apps Database table."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
databaseInstallApp $app_name;
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Updating WebUI config file."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
webuiContainerSetup $app_name install;
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Provisioning monitoring dashboards from installed apps."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Re-gather the Prometheus datasource + every monitoring-enabled app's
|
||||||
|
# dashboards into provisioning/ — so a fresh (or re-)install of Grafana
|
||||||
|
# picks up the apps that already had CFG_<APP>_MONITORING=true.
|
||||||
|
# monitoringRefreshAll also covers Grafana's own scrape target when
|
||||||
|
# CFG_GRAFANA_MONITORING=true.
|
||||||
|
monitoringRefreshAll;
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. You can find $app_name files at $containers_dir$app_name"
|
||||||
|
echo ""
|
||||||
|
echo " You can now navigate to your $app_name service using any of the options below : "
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
menuShowFinalMessages $app_name;
|
||||||
|
|
||||||
|
menu_number=0
|
||||||
|
#sleep 3s
|
||||||
|
cd
|
||||||
|
fi
|
||||||
|
grafana=n
|
||||||
|
}
|
||||||
@ -1,23 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# Grafana install hooks — pre-flight prereq check + post-start 0777 on the
|
|
||||||
# storage folder so Grafana can write its sqlite db regardless of host UID.
|
|
||||||
|
|
||||||
grafana_install_pre()
|
|
||||||
{
|
|
||||||
local app_name="$1"
|
|
||||||
if ! appInstallCheckRequirements "$app_name" "$CFG_GRAFANA_REQUIRES"; then
|
|
||||||
grafana=n
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
grafana_install_post_start()
|
|
||||||
{
|
|
||||||
local app_name="$1"
|
|
||||||
if [ -d "${containers_dir}grafana/grafana_storage" ]; then
|
|
||||||
local result
|
|
||||||
result=$(runFileOp chmod -R 777 "${containers_dir}grafana/grafana_storage")
|
|
||||||
checkSuccess "Set permissions to grafana_storage folder."
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
@ -20,8 +20,6 @@ services:
|
|||||||
labels:
|
labels:
|
||||||
libreportal.category: "CATEGORY_DATA" #LIBREPORTAL|CATEGORY_TAG|CATEGORY_DATA
|
libreportal.category: "CATEGORY_DATA" #LIBREPORTAL|CATEGORY_TAG|CATEGORY_DATA
|
||||||
libreportal.title: "TITLE_DATA" #LIBREPORTAL|TITLE_TAG|TITLE_DATA
|
libreportal.title: "TITLE_DATA" #LIBREPORTAL|TITLE_TAG|TITLE_DATA
|
||||||
libreportal.backup.db: "sqlite:::data/db.sqlite"
|
|
||||||
libreportal.backup.files: "headscale-service:/var/lib/headscale:data"
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
disable: HEALTHCHECK_DATA #LIBREPORTAL|HEALTHCHECK_TAG|HEALTHCHECK_DATA
|
disable: HEALTHCHECK_DATA #LIBREPORTAL|HEALTHCHECK_TAG|HEALTHCHECK_DATA
|
||||||
# GLUETUN_OFF_BEGIN
|
# GLUETUN_OFF_BEGIN
|
||||||
|
|||||||
@ -11,7 +11,6 @@
|
|||||||
#
|
#
|
||||||
CFG_HEADSCALE_APP_NAME=headscale
|
CFG_HEADSCALE_APP_NAME=headscale
|
||||||
CFG_HEADSCALE_BACKUP=true
|
CFG_HEADSCALE_BACKUP=true
|
||||||
CFG_HEADSCALE_BACKUP_STRATEGY=auto
|
|
||||||
CFG_HEADSCALE_COMPOSE_FILE=default
|
CFG_HEADSCALE_COMPOSE_FILE=default
|
||||||
CFG_HEADSCALE_HEALTHCHECK=true
|
CFG_HEADSCALE_HEALTHCHECK=true
|
||||||
CFG_HEADSCALE_BASIC_AUTH_PASS=RANDOMIZEDPASSWORD1
|
CFG_HEADSCALE_BASIC_AUTH_PASS=RANDOMIZEDPASSWORD1
|
||||||
|
|||||||
127
containers/headscale/headscale.sh
Executable file
127
containers/headscale/headscale.sh
Executable file
@ -0,0 +1,127 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Category : Networking
|
||||||
|
# Description : Self-hosted WireGuard orchestrator (c/u/s/r/i):
|
||||||
|
|
||||||
|
installHeadscale()
|
||||||
|
{
|
||||||
|
local config_variables="$1"
|
||||||
|
|
||||||
|
if [[ "$headscale" == *[cCtTuUsSrRiI]* ]]; then
|
||||||
|
dockerConfigSetupToContainer silent headscale;
|
||||||
|
local app_name=$CFG_HEADSCALE_APP_NAME
|
||||||
|
initializeAppVariables $app_name;
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$headscale" == *[cC]* ]]; then
|
||||||
|
editAppConfig $app_name;
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$headscale" == *[uU]* ]]; then
|
||||||
|
dockerUninstallApp $app_name;
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$headscale" == *[sS]* ]]; then
|
||||||
|
dockerComposeDown $app_name;
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$headscale" == *[rR]* ]]; then
|
||||||
|
dockerComposeRestart $app_name;
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$headscale" == *[iI]* ]]; then
|
||||||
|
isHeader "Install $app_name"
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Setting up install folder and config file for $app_name."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
dockerConfigSetupToContainer "loud" "$app_name" "install" "$config_variables";
|
||||||
|
isSuccessful "Install folders and Config files have been setup for $app_name."
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Setting up the $app_name docker-compose.yml file."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
dockerComposeSetupFile $app_name;
|
||||||
|
|
||||||
|
local result=$(createFolders "loud" $docker_install_user $containers_dir$app_name/config)
|
||||||
|
checkSuccess "Create config folder"
|
||||||
|
|
||||||
|
local result=$(copyResource "$app_name" "config.yaml" "config" | sudo tee -a "$logs_dir/$docker_log_file" 2>&1)
|
||||||
|
checkSuccess "Copying config.yaml to config folder."
|
||||||
|
|
||||||
|
configSetupFileWithData $app_name "config.yaml" "config";
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Updating file permissions before starting."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
fixPermissionsBeforeStart $app_name;
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Running the docker-compose.yml to install and start $app_name"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
dockerComposeUpdateAndStartApp $app_name install;
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Running Application specific updates (if required)"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
appUpdateSpecifics $app_name;
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Running Headscale setup (if required)"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
setupHeadscale $app_name;
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Setting up database records"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
databaseInstallApp $app_name;
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Updating WebUI config file."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
webuiContainerSetup $app_name install;
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Refreshing monitoring integration."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
monitoringRefreshAll;
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. You can find $app_name files at $containers_dir$app_name"
|
||||||
|
echo ""
|
||||||
|
echo " You can now navigate to your $app_name service using any of the options below : "
|
||||||
|
echo ""
|
||||||
|
echo " NOTE - The password to login in defined in the yml install file that was installed"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
menuShowFinalMessages $app_name;
|
||||||
|
|
||||||
|
menu_number=0
|
||||||
|
#sleep 3s
|
||||||
|
cd
|
||||||
|
fi
|
||||||
|
headscale=n
|
||||||
|
}
|
||||||
@ -1,19 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# Headscale install hooks — drop the config.yaml template into the
|
|
||||||
# container's config folder before start so the daemon has its config on
|
|
||||||
# first boot.
|
|
||||||
|
|
||||||
headscale_install_post_compose()
|
|
||||||
{
|
|
||||||
local app_name="$1"
|
|
||||||
|
|
||||||
local result
|
|
||||||
result=$(createFolders "loud" $docker_install_user $containers_dir$app_name/config)
|
|
||||||
checkSuccess "Create config folder"
|
|
||||||
|
|
||||||
result=$(copyResource "$app_name" "config.yaml" "config" | runInstallWrite -a "$logs_dir/$docker_log_file" 2>&1)
|
|
||||||
checkSuccess "Copying config.yaml to config folder."
|
|
||||||
|
|
||||||
configSetupFileWithData $app_name "config.yaml" "config"
|
|
||||||
}
|
|
||||||
@ -1,19 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
tailscaleInstallToContainer()
|
|
||||||
{
|
|
||||||
local app_name="$1"
|
|
||||||
local type="$2"
|
|
||||||
|
|
||||||
local result; result=$(createFolders "loud" $docker_install_user $containers_dir$app_name/tailscale)
|
|
||||||
checkSuccess "Creating Tailscale folder"
|
|
||||||
|
|
||||||
copyFile "loud" "${install_containers_dir}headscale/resources/tailscale.sh" "$containers_dir$app_name/tailscale/tailscale.sh" $docker_install_user | runInstallWrite -a "$logs_dir/$docker_log_file" 2>&1
|
|
||||||
|
|
||||||
if [[ "$type" != "install" ]]; then
|
|
||||||
dockerComposeRestart $app_name;
|
|
||||||
fi
|
|
||||||
|
|
||||||
dockerCommandRun "docker exec -it $app_name /usr/local/bin/tailscale.sh"
|
|
||||||
checkSuccess "Executing Tailscale installer script in the $app_name container"
|
|
||||||
}
|
|
||||||
@ -42,7 +42,6 @@ services:
|
|||||||
labels:
|
labels:
|
||||||
libreportal.category: "CATEGORY_DATA" #LIBREPORTAL|CATEGORY_TAG|CATEGORY_DATA
|
libreportal.category: "CATEGORY_DATA" #LIBREPORTAL|CATEGORY_TAG|CATEGORY_DATA
|
||||||
libreportal.title: "TITLE_DATA" #LIBREPORTAL|TITLE_TAG|TITLE_DATA
|
libreportal.title: "TITLE_DATA" #LIBREPORTAL|TITLE_TAG|TITLE_DATA
|
||||||
libreportal.backup.db: "postgres:invidious-db:postgresdata:"
|
|
||||||
traefik.enable: TRAEFIK_ENABLE_DATA #LIBREPORTAL|TRAEFIK_ENABLE_TAG|TRAEFIK_ENABLE_DATA
|
traefik.enable: TRAEFIK_ENABLE_DATA #LIBREPORTAL|TRAEFIK_ENABLE_TAG|TRAEFIK_ENABLE_DATA
|
||||||
# TRAEFIK_PORT_1_BEGIN
|
# TRAEFIK_PORT_1_BEGIN
|
||||||
traefik.http.routers.invidious-service.entrypoints: web,websecure
|
traefik.http.routers.invidious-service.entrypoints: web,websecure
|
||||||
|
|||||||
@ -11,7 +11,6 @@
|
|||||||
#
|
#
|
||||||
CFG_INVIDIOUS_APP_NAME=invidious
|
CFG_INVIDIOUS_APP_NAME=invidious
|
||||||
CFG_INVIDIOUS_BACKUP=false
|
CFG_INVIDIOUS_BACKUP=false
|
||||||
CFG_INVIDIOUS_BACKUP_STRATEGY=auto
|
|
||||||
CFG_INVIDIOUS_COMPOSE_FILE=default
|
CFG_INVIDIOUS_COMPOSE_FILE=default
|
||||||
CFG_INVIDIOUS_HEALTHCHECK=false
|
CFG_INVIDIOUS_HEALTHCHECK=false
|
||||||
CFG_INVIDIOUS_AUTHELIA=false
|
CFG_INVIDIOUS_AUTHELIA=false
|
||||||
|
|||||||
114
containers/invidious/invidious.sh
Executable file
114
containers/invidious/invidious.sh
Executable file
@ -0,0 +1,114 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Category : Media & Streaming
|
||||||
|
# Description : Invidious - Privacy-focused YouTube Frontend (c/u/s/r/i/t):
|
||||||
|
|
||||||
|
installInvidious()
|
||||||
|
{
|
||||||
|
local config_variables="$1"
|
||||||
|
|
||||||
|
if [[ "$invidious" == *[cCtTuUsSrRiI]* ]]; then
|
||||||
|
dockerConfigSetupToContainer silent invidious;
|
||||||
|
local app_name=$CFG_INVIDIOUS_APP_NAME
|
||||||
|
initializeAppVariables $app_name;
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$invidious" == *[cC]* ]]; then
|
||||||
|
editAppConfig $app_name;
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$invidious" == *[tT]* ]]; then
|
||||||
|
invidiousToolsMenu;
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$invidious" == *[uU]* ]]; then
|
||||||
|
dockerUninstallApp $app_name;
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$invidious" == *[sS]* ]]; then
|
||||||
|
dockerComposeDown $app_name;
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$invidious" == *[rR]* ]]; then
|
||||||
|
dockerComposeRestart $app_name;
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$invidious" == *[iI]* ]]; then
|
||||||
|
isHeader "Install $app_name"
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Setting up install folder and config file for $app_name."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
dockerConfigSetupToContainer "loud" "$app_name" "install" "$config_variables";
|
||||||
|
isSuccessful "Install folders and Config files have been setup for $app_name."
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Setting up the $app_name docker-compose.yml file."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
dockerComposeSetupFile $app_name;
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Updating file permissions before starting."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
fixPermissionsBeforeStart $app_name;
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Running the docker-compose.yml to install and start $app_name"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
dockerComposeUpdateAndStartApp $app_name install;
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Running Application specific updates (if required)"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
appUpdateSpecifics $app_name;
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Running Headscale setup (if required)"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
setupHeadscale $app_name;
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Adding $app_name to the Apps Database table."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
databaseInstallApp $app_name;
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Updating WebUI config file."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
webuiContainerSetup $app_name install;
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. You can find $app_name files at $containers_dir$app_name"
|
||||||
|
echo ""
|
||||||
|
echo " You can now navigate to your $app_name service using any of the options below : "
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
menuShowFinalMessages $app_name;
|
||||||
|
|
||||||
|
menu_number=0
|
||||||
|
#sleep 3s
|
||||||
|
cd
|
||||||
|
fi
|
||||||
|
invidious=n
|
||||||
|
}
|
||||||
@ -1,77 +0,0 @@
|
|||||||
{
|
|
||||||
"tools": [
|
|
||||||
{
|
|
||||||
"id": "reset_password",
|
|
||||||
"category": "users",
|
|
||||||
"label": "Reset User Password",
|
|
||||||
"description": "Set a new password for an existing user. Leave blank to generate one.",
|
|
||||||
"icon": "🔑",
|
|
||||||
"fields": [
|
|
||||||
{
|
|
||||||
"name": "email",
|
|
||||||
"label": "Email",
|
|
||||||
"type": "text",
|
|
||||||
"required": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "password",
|
|
||||||
"label": "New password",
|
|
||||||
"type": "password",
|
|
||||||
"placeholder": "Leave blank to generate"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "create_account",
|
|
||||||
"category": "users",
|
|
||||||
"label": "Create User Account",
|
|
||||||
"description": "Add a new user. Sign-in uses the email address.",
|
|
||||||
"icon": "👤",
|
|
||||||
"fields": [
|
|
||||||
{
|
|
||||||
"name": "email",
|
|
||||||
"label": "Email",
|
|
||||||
"type": "text",
|
|
||||||
"required": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "password",
|
|
||||||
"label": "Password",
|
|
||||||
"type": "password",
|
|
||||||
"placeholder": "Leave blank to generate"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "admin",
|
|
||||||
"label": "Make admin",
|
|
||||||
"type": "checkbox",
|
|
||||||
"default": false
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "list_users",
|
|
||||||
"category": "users",
|
|
||||||
"label": "List Users",
|
|
||||||
"description": "List every user.",
|
|
||||||
"icon": "📋",
|
|
||||||
"fields": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "delete_user",
|
|
||||||
"category": "users",
|
|
||||||
"label": "Delete User Account",
|
|
||||||
"description": "Permanently remove a user.",
|
|
||||||
"icon": "🗑",
|
|
||||||
"destructive": true,
|
|
||||||
"confirm": "This cannot be undone.",
|
|
||||||
"fields": [
|
|
||||||
{
|
|
||||||
"name": "email",
|
|
||||||
"label": "Email",
|
|
||||||
"type": "text",
|
|
||||||
"required": true
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@ -11,7 +11,6 @@
|
|||||||
#
|
#
|
||||||
CFG_IPINFO_APP_NAME=ipinfo
|
CFG_IPINFO_APP_NAME=ipinfo
|
||||||
CFG_IPINFO_BACKUP=false
|
CFG_IPINFO_BACKUP=false
|
||||||
CFG_IPINFO_BACKUP_STRATEGY=auto
|
|
||||||
CFG_IPINFO_COMPOSE_FILE=default
|
CFG_IPINFO_COMPOSE_FILE=default
|
||||||
CFG_IPINFO_HEALTHCHECK=true
|
CFG_IPINFO_HEALTHCHECK=true
|
||||||
CFG_IPINFO_AUTHELIA=false
|
CFG_IPINFO_AUTHELIA=false
|
||||||
|
|||||||
110
containers/ipinfo/ipinfo.sh
Executable file
110
containers/ipinfo/ipinfo.sh
Executable file
@ -0,0 +1,110 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Category : Networking
|
||||||
|
# Description : IPinfo - IP Geolocation and Information (c/u/s/r/i):
|
||||||
|
|
||||||
|
installIpinfo()
|
||||||
|
{
|
||||||
|
local config_variables="$1"
|
||||||
|
|
||||||
|
if [[ "$ipinfo" == *[cCtTuUsSrRiI]* ]]; then
|
||||||
|
dockerConfigSetupToContainer silent ipinfo;
|
||||||
|
local app_name=$CFG_IPINFO_APP_NAME
|
||||||
|
initializeAppVariables $app_name;
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$ipinfo" == *[cC]* ]]; then
|
||||||
|
editAppConfig $app_name;
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$ipinfo" == *[uU]* ]]; then
|
||||||
|
dockerUninstallApp $app_name;
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$ipinfo" == *[sS]* ]]; then
|
||||||
|
dockerComposeDown $app_name;
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$ipinfo" == *[rR]* ]]; then
|
||||||
|
dockerComposeRestart $app_name;
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$ipinfo" == *[iI]* ]]; then
|
||||||
|
isHeader "Install $app_name"
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Setting up install folder and config file for $app_name."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
dockerConfigSetupToContainer "loud" "$app_name" "install" "$config_variables";
|
||||||
|
isSuccessful "Install folders and Config files have been setup for $app_name."
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Setting up the $app_name docker-compose.yml file."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
dockerComposeSetupFile $app_name;
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Updating file permissions before starting."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
fixPermissionsBeforeStart $app_name;
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Running the docker-compose.yml to install and start $app_name"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
dockerComposeUpdateAndStartApp $app_name install;
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Running Application specific updates (if required)"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
appUpdateSpecifics $app_name;
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Running Headscale setup (if required)"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
setupHeadscale $app_name;
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Adding $app_name to the Apps Database table."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
databaseInstallApp $app_name;
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Updating WebUI config file."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
webuiContainerSetup $app_name install;
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. You can find $app_name files at $containers_dir$app_name"
|
||||||
|
echo ""
|
||||||
|
echo " You can now navigate to your $app_name service using any of the options below : "
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
menuShowFinalMessages $app_name;
|
||||||
|
|
||||||
|
menu_number=0
|
||||||
|
#sleep 3s
|
||||||
|
cd
|
||||||
|
fi
|
||||||
|
ipinfo=n
|
||||||
|
}
|
||||||
@ -11,7 +11,6 @@
|
|||||||
#
|
#
|
||||||
CFG_JELLYFIN_APP_NAME=jellyfin
|
CFG_JELLYFIN_APP_NAME=jellyfin
|
||||||
CFG_JELLYFIN_BACKUP=true
|
CFG_JELLYFIN_BACKUP=true
|
||||||
CFG_JELLYFIN_BACKUP_STRATEGY=auto
|
|
||||||
CFG_JELLYFIN_COMPOSE_FILE=default
|
CFG_JELLYFIN_COMPOSE_FILE=default
|
||||||
CFG_JELLYFIN_HEALTHCHECK=true
|
CFG_JELLYFIN_HEALTHCHECK=true
|
||||||
CFG_JELLYFIN_AUTHELIA=false
|
CFG_JELLYFIN_AUTHELIA=false
|
||||||
|
|||||||
103
containers/jellyfin/jellyfin.sh
Executable file
103
containers/jellyfin/jellyfin.sh
Executable file
@ -0,0 +1,103 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Category : Media & Streaming
|
||||||
|
# Description : Jellyfin - Media Server (c/u/s/r/i):
|
||||||
|
|
||||||
|
installJellyfin()
|
||||||
|
{
|
||||||
|
local config_variables="$1"
|
||||||
|
|
||||||
|
if [[ "$jellyfin" == *[cCtTuUsSrRiI]* ]]; then
|
||||||
|
dockerConfigSetupToContainer silent jellyfin;
|
||||||
|
local app_name=$CFG_JELLYFIN_APP_NAME
|
||||||
|
initializeAppVariables $app_name;
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$jellyfin" == *[cC]* ]]; then
|
||||||
|
editAppConfig $app_name;
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$jellyfin" == *[uU]* ]]; then
|
||||||
|
dockerUninstallApp $app_name;
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$jellyfin" == *[sS]* ]]; then
|
||||||
|
dockerComposeDown $app_name;
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$jellyfin" == *[rR]* ]]; then
|
||||||
|
dockerComposeRestart $app_name;
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$jellyfin" == *[iI]* ]]; then
|
||||||
|
isHeader "Install $app_name"
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Setting up install folder and config file for $app_name."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
dockerConfigSetupToContainer "loud" "$app_name" "install" "$config_variables";
|
||||||
|
isSuccessful "Install folders and Config files have been setup for $app_name."
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Pulling a default Jellyfin docker-compose.yml file."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
dockerComposeSetupFile $app_name;
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Updating file permissions before starting."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
fixPermissionsBeforeStart $app_name;
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Running the docker-compose.yml to install and start Jellyfin"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
dockerComposeUpdateAndStartApp $app_name install;
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Running Application specific updates (if required)"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
appUpdateSpecifics $app_name;
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Running Headscale setup (if required)"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
setupHeadscale $app_name;
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Adding $app_name to the Apps Database table."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
databaseInstallApp $app_name;
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. You can find $app_name files at $containers_dir$app_name"
|
||||||
|
echo ""
|
||||||
|
echo " You can now navigate to your new service using one of the options below : "
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
menuShowFinalMessages $app_name;
|
||||||
|
|
||||||
|
menu_number=0
|
||||||
|
#sleep 3s
|
||||||
|
cd
|
||||||
|
fi
|
||||||
|
jellyfin=n
|
||||||
|
}
|
||||||
@ -11,7 +11,6 @@
|
|||||||
#
|
#
|
||||||
CFG_JITSIMEET_APP_NAME=jitsimeet
|
CFG_JITSIMEET_APP_NAME=jitsimeet
|
||||||
CFG_JITSIMEET_BACKUP=true
|
CFG_JITSIMEET_BACKUP=true
|
||||||
CFG_JITSIMEET_BACKUP_STRATEGY=auto
|
|
||||||
CFG_JITSIMEET_COMPOSE_FILE=default
|
CFG_JITSIMEET_COMPOSE_FILE=default
|
||||||
CFG_JITSIMEET_HEALTHCHECK=true
|
CFG_JITSIMEET_HEALTHCHECK=true
|
||||||
CFG_JITSIMEET_AUTHELIA=false
|
CFG_JITSIMEET_AUTHELIA=false
|
||||||
|
|||||||
202
containers/jitsimeet/jitsimeet.sh
Executable file
202
containers/jitsimeet/jitsimeet.sh
Executable file
@ -0,0 +1,202 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Category : Communication & Collaboration Tools
|
||||||
|
# Description : Jitsi Meet - Video Conferencing *UNFINISHED* (c/u/s/r/i):
|
||||||
|
|
||||||
|
installJitsimeet()
|
||||||
|
{
|
||||||
|
local config_variables="$1"
|
||||||
|
|
||||||
|
if [[ "$jitsimeet" == *[cCtTuUsSrRiI]* ]]; then
|
||||||
|
dockerConfigSetupToContainer silent jitsimeet;
|
||||||
|
local app_name=$CFG_JITSIMEET_APP_NAME
|
||||||
|
git_url=$CFG_JITSIMEET_GIT
|
||||||
|
initializeAppVariables $app_name;
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$jitsimeet" == *[cC]* ]]; then
|
||||||
|
editAppConfig $app_name;
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$jitsimeet" == *[uU]* ]]; then
|
||||||
|
dockerUninstallApp $app_name;
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$jitsimeet" == *[sS]* ]]; then
|
||||||
|
dockerComposeDown $app_name;
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$jitsimeet" == *[rR]* ]]; then
|
||||||
|
dockerComposeRestart $app_name;
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$jitsimeet" == *[iI]* ]]; then
|
||||||
|
isHeader "Install $app_name"
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Setting up install folder and config file for $app_name."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
dockerConfigSetupToContainer "loud" "$app_name" "install" "$config_variables";
|
||||||
|
isSuccessful "Install folders and Config files have been setup for $app_name."
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Downloading latest GitHub release"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
latest_tag=$(git ls-remote --refs --sort="version:refname" --tags $git_url | cut -d/ -f3- | tail -n1)
|
||||||
|
echo "The latest tag is: $latest_tag"
|
||||||
|
|
||||||
|
local result=$(createFolders "loud" $docker_install_user $containers_dir$app_name)
|
||||||
|
checkSuccess "Creating $app_name container installation folder"
|
||||||
|
local result=$(cd $containers_dir$app_name && sudo rm -rf $containers_dir$app_name/$latest_tag.zip)
|
||||||
|
checkSuccess "Deleting zip file to prevent conflicts"
|
||||||
|
local result=$(createTouch $containers_dir$app_name/$latest_tag.txt $docker_install_user && echo 'Installed "$latest_tag" on "$backupDate"!' > $latest_tag.txt)
|
||||||
|
checkSuccess "Create logging txt file"
|
||||||
|
|
||||||
|
|
||||||
|
# Download files and unzip
|
||||||
|
local result=$(sudo wget -O $containers_dir$app_name/$latest_tag.zip $git_url/archive/refs/tags/$latest_tag.zip)
|
||||||
|
checkSuccess "Downloading tagged zip file from GitHub"
|
||||||
|
local result=$(sudo unzip -o $containers_dir$app_name/$latest_tag.zip -d $containers_dir$app_name)
|
||||||
|
checkSuccess "Unzip downloaded file"
|
||||||
|
local result=$(sudo mv $containers_dir$app_name/docker-jitsi-meet-$latest_tag/* $containers_dir$app_name)
|
||||||
|
checkSuccess "Moving all files from zip file to install directory"
|
||||||
|
local result=$(sudo rm -rf $containers_dir$app_name/$latest_tag.zip && sudo rm -rf $containers_dir$app_name/$latest_tag/)
|
||||||
|
checkSuccess "Removing downloaded zip file as no longer needed"
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Setting up the $app_name docker-compose.yml file."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
dockerComposeSetupFile $app_name;
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Updating file permissions before starting."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
fixPermissionsBeforeStart $app_name;
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Setting up .env file for setup"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
dockerSetupEnvFile;
|
||||||
|
|
||||||
|
# Updating custom .env values
|
||||||
|
local result=$(sudo sed -i "s|CONFIG=~/.jitsi-meet-cfg|CONFIG=$containers_dir$app_name/.jitsi-meet-cfg|g" $containers_dir$app_name/.env)
|
||||||
|
checkSuccess "Updating .env file with new install path"
|
||||||
|
|
||||||
|
local result=$(sudo sed -i "s|#PUBLIC_URL=https://meet.example.com|PUBLIC_URL=https://$host_setup|g" $containers_dir$app_name/.env)
|
||||||
|
checkSuccess "Updating .env file with Public URL to $host_setup"
|
||||||
|
|
||||||
|
local result=$(sudo sed -i "s|HTTP_PORT=8000|HTTP_PORT=$usedport1|g" $containers_dir$app_name/.env)
|
||||||
|
checkSuccess "Updating .env file with HTTP_PORT to $usedport1"
|
||||||
|
|
||||||
|
local result=$(sudo sed -i "s|HTTPS_PORT=8443|HTTPS_PORT=$usedport2|g" $containers_dir$app_name/.env)
|
||||||
|
checkSuccess "Updating .env file with HTTP_PORT to $usedport2"
|
||||||
|
|
||||||
|
#local result=$(echo "ENABLE_HTTP_REDIRECT=1" | sudo tee -a "$containers_dir$app_name/.env")
|
||||||
|
#checkSuccess "Updating .env file with option : ENABLE_HTTP_REDIRECT"
|
||||||
|
|
||||||
|
# Values are missing from the .env by default for some reason
|
||||||
|
# https://github.com/jitsi/docker-jitsi-meet/commit/12051700562d9826f9e024ad649c4dd9b88f94de#diff-b335630551682c19a781afebcf4d07bf978fb1f8ac04c6bf87428ed5106870f5
|
||||||
|
local result=$(echo "XMPP_DOMAIN=meet.jitsi" | sudo tee -a "$containers_dir$app_name/.env")
|
||||||
|
checkSuccess "Updating .env file with missing option : XMPP_DOMAIN"
|
||||||
|
|
||||||
|
local result=$(echo "XMPP_SERVER=xmpp.meet.jitsi" | sudo tee -a "$containers_dir$app_name/.env")
|
||||||
|
checkSuccess "Updating .env file with missing option : XMPP_SERVER"
|
||||||
|
|
||||||
|
local result=$(echo "JVB_PORT=$usedport4" | sudo tee -a "$containers_dir$app_name/.env")
|
||||||
|
checkSuccess "Updating .env file with missing option : JVB_PORT"
|
||||||
|
|
||||||
|
local result=$(echo "JVB_TCP_MAPPED_PORT=$usedport5" | sudo tee -a "$containers_dir$app_name/.env")
|
||||||
|
checkSuccess "Updating .env file with missing option : JVB_TCP_MAPPED_PORT"
|
||||||
|
|
||||||
|
local result=$(echo "JVB_TCP_PORT=$usedport5" | sudo tee -a "$containers_dir$app_name/.env")
|
||||||
|
checkSuccess "Updating .env file with missing option : JVB_TCP_PORT"
|
||||||
|
|
||||||
|
local result=$(cd "$containers_dir$app_name" && sudo ./gen-passwords.sh)
|
||||||
|
checkSuccess "Running Jitsi Meet gen-passwords.sh script"
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Running the docker-compose.yml to install and start $app_name"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
dockerComposeUpdateAndStartApp $app_name install;
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Adjusting $app_name docker system files for port changes."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
#dockerCommandRun "docker exec -it $app_name /bin/bash && cd /"
|
||||||
|
|
||||||
|
#local result=$(sudo sed -i "s|80|$usedport1|g" $containers_dir$app_nameweb/default)
|
||||||
|
#checkSuccess "Updating Docker NGINX default site port 80 to $usedport1"
|
||||||
|
|
||||||
|
#local result=$(sudo sed -i "s|443|$usedport2|g" $containers_dir$app_nameweb/default)
|
||||||
|
#checkSuccess "Updating Docker NGINX default site port 443 to $usedport2"
|
||||||
|
|
||||||
|
local result=$(sudo sed -i "s|80|$usedport1|g" $containers_dir$app_name/web/rootfs/defaults/default)
|
||||||
|
checkSuccess "Updating NGINX default site port 80 to $usedport1"
|
||||||
|
|
||||||
|
local result=$(sudo sed -i "s|443|$usedport2|g" $containers_dir$app_name/web/rootfs/defaults/default)
|
||||||
|
checkSuccess "Updating NGINX default site port 443 to $usedport2"
|
||||||
|
|
||||||
|
#dockerCommandRun "docker cp '$containers_dir$app_name' '$app_name:/etc/nginx/sites-available/default'"
|
||||||
|
dockerComposeRestart $app_name;
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Running Application specific updates (if required)"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
appUpdateSpecifics $app_name;
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Running Headscale setup (if required)"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
setupHeadscale $app_name;
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Adding $app_name to the Apps Database table."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
databaseInstallApp $app_name;
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. Updating WebUI config file."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
webuiContainerSetup $app_name install;
|
||||||
|
|
||||||
|
((menu_number++))
|
||||||
|
echo ""
|
||||||
|
echo "---- $menu_number. You can find $app_name files at $containers_dir$app_name"
|
||||||
|
echo ""
|
||||||
|
echo " You can now navigate to your new service using one of the options below : "
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
menuShowFinalMessages $app_name;
|
||||||
|
|
||||||
|
menu_number=0
|
||||||
|
#sleep 3s
|
||||||
|
cd
|
||||||
|
fi
|
||||||
|
jitsimeet=n
|
||||||
|
}
|
||||||
@ -1,101 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# Jitsi Meet install hooks — Jitsi ships its docker layout as a tagged
|
|
||||||
# release zip on GitHub, so we download + unpack it before compose setup.
|
|
||||||
# Then mass-edit the .env, generate passwords, and rewire the nginx ports.
|
|
||||||
|
|
||||||
jitsimeet_install_post_setup()
|
|
||||||
{
|
|
||||||
local app_name="$1"
|
|
||||||
local git_url="$CFG_JITSIMEET_GIT"
|
|
||||||
|
|
||||||
((menu_number++))
|
|
||||||
echo ""
|
|
||||||
echo "---- $menu_number. Downloading latest GitHub release"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
local latest_tag
|
|
||||||
latest_tag=$(git ls-remote --refs --sort="version:refname" --tags "$git_url" | cut -d/ -f3- | tail -n1)
|
|
||||||
echo "The latest tag is: $latest_tag"
|
|
||||||
|
|
||||||
local result
|
|
||||||
result=$(createFolders "loud" $docker_install_user $containers_dir$app_name)
|
|
||||||
checkSuccess "Creating $app_name container installation folder"
|
|
||||||
result=$(cd $containers_dir$app_name && runFileOp rm -rf $containers_dir$app_name/$latest_tag.zip)
|
|
||||||
checkSuccess "Deleting zip file to prevent conflicts"
|
|
||||||
result=$(createTouch $containers_dir$app_name/$latest_tag.txt $docker_install_user && echo "Installed \"$latest_tag\" on \"$backupDate\"!" > $latest_tag.txt)
|
|
||||||
checkSuccess "Create logging txt file"
|
|
||||||
|
|
||||||
result=$(runFileOp wget -O $containers_dir$app_name/$latest_tag.zip $git_url/archive/refs/tags/$latest_tag.zip)
|
|
||||||
checkSuccess "Downloading tagged zip file from GitHub"
|
|
||||||
result=$(runFileOp unzip -o $containers_dir$app_name/$latest_tag.zip -d $containers_dir$app_name)
|
|
||||||
checkSuccess "Unzip downloaded file"
|
|
||||||
result=$(runFileOp mv $containers_dir$app_name/docker-jitsi-meet-$latest_tag/* $containers_dir$app_name)
|
|
||||||
checkSuccess "Moving all files from zip file to install directory"
|
|
||||||
result=$(runFileOp rm -rf $containers_dir$app_name/$latest_tag.zip && runFileOp rm -rf $containers_dir$app_name/$latest_tag/)
|
|
||||||
checkSuccess "Removing downloaded zip file as no longer needed"
|
|
||||||
}
|
|
||||||
|
|
||||||
jitsimeet_install_post_compose()
|
|
||||||
{
|
|
||||||
local app_name="$1"
|
|
||||||
|
|
||||||
((menu_number++))
|
|
||||||
echo ""
|
|
||||||
echo "---- $menu_number. Setting up .env file for setup"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
dockerSetupEnvFile
|
|
||||||
|
|
||||||
local result
|
|
||||||
result=$(runFileOp sed -i "s|CONFIG=~/.jitsi-meet-cfg|CONFIG=$containers_dir$app_name/.jitsi-meet-cfg|g" $containers_dir$app_name/.env)
|
|
||||||
checkSuccess "Updating .env file with new install path"
|
|
||||||
|
|
||||||
result=$(runFileOp sed -i "s|#PUBLIC_URL=https://meet.example.com|PUBLIC_URL=https://$host_setup|g" $containers_dir$app_name/.env)
|
|
||||||
checkSuccess "Updating .env file with Public URL to $host_setup"
|
|
||||||
|
|
||||||
result=$(runFileOp sed -i "s|HTTP_PORT=8000|HTTP_PORT=$usedport1|g" $containers_dir$app_name/.env)
|
|
||||||
checkSuccess "Updating .env file with HTTP_PORT to $usedport1"
|
|
||||||
|
|
||||||
result=$(runFileOp sed -i "s|HTTPS_PORT=8443|HTTPS_PORT=$usedport2|g" $containers_dir$app_name/.env)
|
|
||||||
checkSuccess "Updating .env file with HTTP_PORT to $usedport2"
|
|
||||||
|
|
||||||
# Defaults missing from the shipped .env (see jitsi/docker-jitsi-meet
|
|
||||||
# commit 12051700562d…). Append them here so the install boots.
|
|
||||||
result=$(echo "XMPP_DOMAIN=meet.jitsi" | runFileWrite -a "$containers_dir$app_name/.env")
|
|
||||||
checkSuccess "Updating .env file with missing option : XMPP_DOMAIN"
|
|
||||||
|
|
||||||
result=$(echo "XMPP_SERVER=xmpp.meet.jitsi" | runFileWrite -a "$containers_dir$app_name/.env")
|
|
||||||
checkSuccess "Updating .env file with missing option : XMPP_SERVER"
|
|
||||||
|
|
||||||
result=$(echo "JVB_PORT=$usedport4" | runFileWrite -a "$containers_dir$app_name/.env")
|
|
||||||
checkSuccess "Updating .env file with missing option : JVB_PORT"
|
|
||||||
|
|
||||||
result=$(echo "JVB_TCP_MAPPED_PORT=$usedport5" | runFileWrite -a "$containers_dir$app_name/.env")
|
|
||||||
checkSuccess "Updating .env file with missing option : JVB_TCP_MAPPED_PORT"
|
|
||||||
|
|
||||||
result=$(echo "JVB_TCP_PORT=$usedport5" | runFileWrite -a "$containers_dir$app_name/.env")
|
|
||||||
checkSuccess "Updating .env file with missing option : JVB_TCP_PORT"
|
|
||||||
|
|
||||||
result=$(cd "$containers_dir$app_name" && runFileOp ./gen-passwords.sh)
|
|
||||||
checkSuccess "Running Jitsi Meet gen-passwords.sh script"
|
|
||||||
}
|
|
||||||
|
|
||||||
jitsimeet_install_post_start()
|
|
||||||
{
|
|
||||||
local app_name="$1"
|
|
||||||
|
|
||||||
((menu_number++))
|
|
||||||
echo ""
|
|
||||||
echo "---- $menu_number. Adjusting $app_name docker system files for port changes."
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
local result
|
|
||||||
result=$(runFileOp sed -i "s|80|$usedport1|g" $containers_dir$app_name/web/rootfs/defaults/default)
|
|
||||||
checkSuccess "Updating NGINX default site port 80 to $usedport1"
|
|
||||||
|
|
||||||
result=$(runFileOp sed -i "s|443|$usedport2|g" $containers_dir$app_name/web/rootfs/defaults/default)
|
|
||||||
checkSuccess "Updating NGINX default site port 443 to $usedport2"
|
|
||||||
|
|
||||||
dockerComposeRestart $app_name
|
|
||||||
}
|
|
||||||
45
containers/libreportal/backend/package-lock.json
generated
45
containers/libreportal/backend/package-lock.json
generated
@ -9,7 +9,6 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"compression": "^1.8.1",
|
|
||||||
"cookie-parser": "^1.4.6",
|
"cookie-parser": "^1.4.6",
|
||||||
"express": "^4.17.1",
|
"express": "^4.17.1",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
@ -131,42 +130,6 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/compressible": {
|
|
||||||
"version": "2.0.18",
|
|
||||||
"resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz",
|
|
||||||
"integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==",
|
|
||||||
"dependencies": {
|
|
||||||
"mime-db": ">= 1.43.0 < 2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/compression": {
|
|
||||||
"version": "1.8.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz",
|
|
||||||
"integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==",
|
|
||||||
"dependencies": {
|
|
||||||
"bytes": "3.1.2",
|
|
||||||
"compressible": "~2.0.18",
|
|
||||||
"debug": "2.6.9",
|
|
||||||
"negotiator": "~0.6.4",
|
|
||||||
"on-headers": "~1.1.0",
|
|
||||||
"safe-buffer": "5.2.1",
|
|
||||||
"vary": "~1.1.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.8.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/compression/node_modules/negotiator": {
|
|
||||||
"version": "0.6.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz",
|
|
||||||
"integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/content-disposition": {
|
"node_modules/content-disposition": {
|
||||||
"version": "0.5.4",
|
"version": "0.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
||||||
@ -684,14 +647,6 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/on-headers": {
|
|
||||||
"version": "1.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz",
|
|
||||||
"integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/parseurl": {
|
"node_modules/parseurl": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||||
|
|||||||
@ -4,7 +4,6 @@
|
|||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"compression": "^1.8.1",
|
|
||||||
"cookie-parser": "^1.4.6",
|
"cookie-parser": "^1.4.6",
|
||||||
"express": "^4.17.1",
|
"express": "^4.17.1",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
|||||||
@ -1,400 +0,0 @@
|
|||||||
// Read-only Docker inspection routes for the Admin → System deep-dive pages.
|
|
||||||
//
|
|
||||||
// GET /api/system/containers
|
|
||||||
// List every container with a per-app summary (compose-project grouping
|
|
||||||
// mirrors what metrics_apps.json does, but with extra per-container
|
|
||||||
// fields the deep-dive pages need: state, status, image, ports, mounts,
|
|
||||||
// restart count). Cached for STAT_TTL_MS.
|
|
||||||
//
|
|
||||||
// GET /api/system/containers/:id
|
|
||||||
// Full container detail straight from `docker inspect`. Includes
|
|
||||||
// resource limits, mounts, networks, env, health-check state, restart
|
|
||||||
// policy. Cached briefly so the page can poll without thrashing the
|
|
||||||
// daemon.
|
|
||||||
//
|
|
||||||
// GET /api/system/containers/:id/stats
|
|
||||||
// One-shot live stats sample (one frame of /containers/<id>/stats).
|
|
||||||
// Returns the same shape Docker emits, plus a derived `cpu_percent`
|
|
||||||
// and `mem_percent` so the frontend doesn't have to recompute.
|
|
||||||
//
|
|
||||||
// GET /api/system/containers/:id/logs?tail=N
|
|
||||||
// Last N lines of combined stdout/stderr, multiplex-decoded.
|
|
||||||
//
|
|
||||||
// GET /api/system/storage
|
|
||||||
// `docker system df` — total + reclaimable for the engine overhead worth
|
|
||||||
// acting on (images, build cache). Cached for STORAGE_TTL_MS because this is
|
|
||||||
// one of the more expensive calls on a busy daemon. Named volumes and
|
|
||||||
// container writable layers are omitted: LibrePortal apps keep data in bind
|
|
||||||
// mounts, so both read ~empty here — per-app on-disk usage is generated
|
|
||||||
// separately (see webuiSystemAppStorage / /data/system/app_storage.json).
|
|
||||||
//
|
|
||||||
// Mounted at /api/system in routes.js (so paths are /api/system/containers
|
|
||||||
// etc.). Uses the shared docker util (utils/docker.js) which talks to the
|
|
||||||
// bind-mounted unix socket — no `docker` CLI inside the container, no
|
|
||||||
// extra deps.
|
|
||||||
|
|
||||||
const express = require('express');
|
|
||||||
const { dockerRequest, dockerStream, decodeMultiplexedLog, DOCKER_SOCKET } = require('../utils/docker.js');
|
|
||||||
|
|
||||||
const router = express.Router();
|
|
||||||
|
|
||||||
const STAT_TTL_MS = 1500;
|
|
||||||
const STORAGE_TTL_MS = 5000;
|
|
||||||
const LIST_TTL_MS = 1500;
|
|
||||||
|
|
||||||
// Trivial per-key TTL cache so concurrent tabs don't pile up daemon calls.
|
|
||||||
function makeTtlCache(ttl) {
|
|
||||||
const m = new Map();
|
|
||||||
return {
|
|
||||||
async get(key, loader) {
|
|
||||||
const now = Date.now();
|
|
||||||
const hit = m.get(key);
|
|
||||||
if (hit && (now - hit.at) < ttl) return hit.value;
|
|
||||||
const value = await loader();
|
|
||||||
m.set(key, { value, at: now });
|
|
||||||
return value;
|
|
||||||
},
|
|
||||||
invalidate(key) { m.delete(key); },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const listCache = makeTtlCache(LIST_TTL_MS);
|
|
||||||
const statCache = makeTtlCache(STAT_TTL_MS);
|
|
||||||
const inspectCache = makeTtlCache(STAT_TTL_MS);
|
|
||||||
const storageCache = makeTtlCache(STORAGE_TTL_MS);
|
|
||||||
|
|
||||||
// Container id slug used on the wire. Real Docker ids are 64-char hex; the
|
|
||||||
// short form is the first 12 chars. We accept either, plus container
|
|
||||||
// *names* (which can include /., _, -). Anything else is rejected.
|
|
||||||
const SAFE_CONTAINER_REF = /^[a-zA-Z0-9_.\-]{1,128}$/;
|
|
||||||
function safeRef(s) { return typeof s === 'string' && SAFE_CONTAINER_REF.test(s); }
|
|
||||||
|
|
||||||
function reduceContainer(c) {
|
|
||||||
// Compose project label tells us which "app" (in LibrePortal terms) a
|
|
||||||
// container belongs to. Containers without that label fall back to
|
|
||||||
// their own name as a single-container app.
|
|
||||||
const labels = c.Labels || {};
|
|
||||||
const project = labels['com.docker.compose.project'] || (c.Names && c.Names[0] ? c.Names[0].replace(/^\//, '') : null);
|
|
||||||
const service = labels['com.docker.compose.service'] || null;
|
|
||||||
const name = (c.Names && c.Names[0]) ? c.Names[0].replace(/^\//, '') : c.Id?.slice(0, 12) || '';
|
|
||||||
const networks = c.NetworkSettings && c.NetworkSettings.Networks
|
|
||||||
? Object.entries(c.NetworkSettings.Networks).map(([n, v]) => ({ name: n, ip: v?.IPAddress || null }))
|
|
||||||
: [];
|
|
||||||
const ports = Array.isArray(c.Ports)
|
|
||||||
? c.Ports.map(p => ({ ip: p.IP || null, host: p.PublicPort || null, container: p.PrivatePort, proto: p.Type }))
|
|
||||||
: [];
|
|
||||||
return {
|
|
||||||
id: c.Id,
|
|
||||||
short: (c.Id || '').slice(0, 12),
|
|
||||||
name,
|
|
||||||
image: c.Image,
|
|
||||||
image_id: c.ImageID,
|
|
||||||
project,
|
|
||||||
service,
|
|
||||||
state: c.State,
|
|
||||||
status: c.Status,
|
|
||||||
created: c.Created, // epoch seconds
|
|
||||||
labels,
|
|
||||||
ports,
|
|
||||||
networks,
|
|
||||||
mounts: Array.isArray(c.Mounts) ? c.Mounts.map(m => ({
|
|
||||||
type: m.Type,
|
|
||||||
source: m.Source,
|
|
||||||
target: m.Destination,
|
|
||||||
mode: m.Mode,
|
|
||||||
rw: m.RW,
|
|
||||||
})) : [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// CPU% from a Docker stats frame. The daemon emits cumulative CPU usage in
|
|
||||||
// nanoseconds plus the system-wide CPU time; the percentage is the delta
|
|
||||||
// over the previous frame normalised by online CPUs. The first call has no
|
|
||||||
// prev frame, so Docker conveniently sends both `cpu_stats` (current) and
|
|
||||||
// `precpu_stats` (previous) in every frame.
|
|
||||||
function cpuPercent(s) {
|
|
||||||
const cpu = s.cpu_stats, pre = s.precpu_stats;
|
|
||||||
if (!cpu || !pre) return 0;
|
|
||||||
const cpuDelta = (cpu.cpu_usage?.total_usage || 0) - (pre.cpu_usage?.total_usage || 0);
|
|
||||||
const sysDelta = (cpu.system_cpu_usage || 0) - (pre.system_cpu_usage || 0);
|
|
||||||
const onlineCpus = cpu.online_cpus
|
|
||||||
|| (cpu.cpu_usage?.percpu_usage?.length)
|
|
||||||
|| 1;
|
|
||||||
if (sysDelta <= 0 || cpuDelta < 0) return 0;
|
|
||||||
return +((cpuDelta / sysDelta) * onlineCpus * 100).toFixed(2);
|
|
||||||
}
|
|
||||||
|
|
||||||
function memUsage(s) {
|
|
||||||
const m = s.memory_stats || {};
|
|
||||||
const usage = m.usage || 0;
|
|
||||||
// Docker counts page-cache in `usage`; the "real" working set excludes
|
|
||||||
// cached memory. Matches what `docker stats` shows.
|
|
||||||
const cache = (m.stats && (m.stats.cache || m.stats.total_inactive_file)) || 0;
|
|
||||||
const used = Math.max(0, usage - cache);
|
|
||||||
const limit = m.limit || 0;
|
|
||||||
return {
|
|
||||||
used, cache, limit,
|
|
||||||
percent: limit ? +((used / limit) * 100).toFixed(2) : 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function netUsage(s) {
|
|
||||||
const nets = s.networks || {};
|
|
||||||
let rx = 0, tx = 0;
|
|
||||||
for (const v of Object.values(nets)) {
|
|
||||||
rx += v.rx_bytes || 0;
|
|
||||||
tx += v.tx_bytes || 0;
|
|
||||||
}
|
|
||||||
return { rx_total: rx, tx_total: tx };
|
|
||||||
}
|
|
||||||
|
|
||||||
function blkioUsage(s) {
|
|
||||||
const b = (s.blkio_stats && s.blkio_stats.io_service_bytes_recursive) || [];
|
|
||||||
let read = 0, write = 0;
|
|
||||||
for (const e of b) {
|
|
||||||
if (e.op === 'Read' || e.op === 'read') read += e.value || 0;
|
|
||||||
else if (e.op === 'Write' || e.op === 'write') write += e.value || 0;
|
|
||||||
}
|
|
||||||
return { read, write };
|
|
||||||
}
|
|
||||||
|
|
||||||
function pidsUsage(s) {
|
|
||||||
const p = s.pids_stats || {};
|
|
||||||
return { current: p.current || 0, limit: p.limit || 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
router.get('/containers', async (req, res) => {
|
|
||||||
if (!DOCKER_SOCKET) return res.status(503).json({ error: 'docker_socket_unavailable' });
|
|
||||||
try {
|
|
||||||
const list = await listCache.get('all', () =>
|
|
||||||
dockerRequest('GET', '/containers/json', { all: 'true' })
|
|
||||||
);
|
|
||||||
const containers = (Array.isArray(list) ? list : []).map(reduceContainer);
|
|
||||||
// Group by project for convenience — frontend uses both shapes.
|
|
||||||
const byApp = new Map();
|
|
||||||
for (const c of containers) {
|
|
||||||
const key = c.project || c.name;
|
|
||||||
if (!byApp.has(key)) byApp.set(key, []);
|
|
||||||
byApp.get(key).push(c);
|
|
||||||
}
|
|
||||||
const apps = [...byApp.entries()].map(([app, members]) => {
|
|
||||||
const running = members.filter(c => c.state === 'running').length;
|
|
||||||
return { app, containers: members.length, running, members };
|
|
||||||
}).sort((a, b) => b.running - a.running || a.app.localeCompare(b.app));
|
|
||||||
res.set('Cache-Control', 'no-store');
|
|
||||||
res.json({ containers, apps, updated: new Date().toISOString() });
|
|
||||||
} catch (err) {
|
|
||||||
res.status(500).json({ error: err.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.get('/containers/:id', async (req, res) => {
|
|
||||||
if (!DOCKER_SOCKET) return res.status(503).json({ error: 'docker_socket_unavailable' });
|
|
||||||
const { id } = req.params;
|
|
||||||
if (!safeRef(id)) return res.status(400).json({ error: 'invalid_id' });
|
|
||||||
try {
|
|
||||||
const detail = await inspectCache.get(`inspect:${id}`, () =>
|
|
||||||
dockerRequest('GET', `/containers/${encodeURIComponent(id)}/json`)
|
|
||||||
);
|
|
||||||
if (!detail) return res.status(404).json({ error: 'not_found' });
|
|
||||||
// Project the verbose inspect payload down to what the deep-dive
|
|
||||||
// page actually wants. Keeps the wire small and the frontend
|
|
||||||
// contract stable.
|
|
||||||
const host = detail.HostConfig || {};
|
|
||||||
const state = detail.State || {};
|
|
||||||
const cfg = detail.Config || {};
|
|
||||||
const netSettings = detail.NetworkSettings || {};
|
|
||||||
const out = {
|
|
||||||
id: detail.Id,
|
|
||||||
short: (detail.Id || '').slice(0, 12),
|
|
||||||
name: (detail.Name || '').replace(/^\//, ''),
|
|
||||||
image: cfg.Image || detail.Image,
|
|
||||||
image_id: detail.Image,
|
|
||||||
created: detail.Created,
|
|
||||||
project: (cfg.Labels && cfg.Labels['com.docker.compose.project']) || null,
|
|
||||||
service: (cfg.Labels && cfg.Labels['com.docker.compose.service']) || null,
|
|
||||||
labels: cfg.Labels || {},
|
|
||||||
state: {
|
|
||||||
status: state.Status,
|
|
||||||
running: !!state.Running,
|
|
||||||
paused: !!state.Paused,
|
|
||||||
restarting: !!state.Restarting,
|
|
||||||
oom_killed: !!state.OOMKilled,
|
|
||||||
dead: !!state.Dead,
|
|
||||||
pid: state.Pid || 0,
|
|
||||||
exit_code: state.ExitCode ?? null,
|
|
||||||
error: state.Error || '',
|
|
||||||
started_at: state.StartedAt || null,
|
|
||||||
finished_at: state.FinishedAt || null,
|
|
||||||
restart_count: detail.RestartCount || 0,
|
|
||||||
health: state.Health ? {
|
|
||||||
status: state.Health.Status,
|
|
||||||
failing_streak: state.Health.FailingStreak,
|
|
||||||
log: (state.Health.Log || []).slice(-5).map(l => ({
|
|
||||||
start: l.Start, end: l.End, exit_code: l.ExitCode, output: (l.Output || '').slice(0, 800)
|
|
||||||
})),
|
|
||||||
} : null,
|
|
||||||
},
|
|
||||||
limits: {
|
|
||||||
memory: host.Memory || 0, // bytes; 0 = unlimited
|
|
||||||
memory_swap: host.MemorySwap || 0,
|
|
||||||
memory_reservation: host.MemoryReservation || 0,
|
|
||||||
cpu_shares: host.CpuShares || 0,
|
|
||||||
cpu_quota: host.CpuQuota || 0, // microseconds in CpuPeriod
|
|
||||||
cpu_period: host.CpuPeriod || 0,
|
|
||||||
nano_cpus: host.NanoCpus || 0, // 1e9 = 1 cpu
|
|
||||||
pids: host.PidsLimit || 0,
|
|
||||||
restart_policy: (host.RestartPolicy && host.RestartPolicy.Name) || 'no',
|
|
||||||
restart_max: (host.RestartPolicy && host.RestartPolicy.MaximumRetryCount) || 0,
|
|
||||||
},
|
|
||||||
mounts: (detail.Mounts || []).map(m => ({
|
|
||||||
type: m.Type,
|
|
||||||
source: m.Source,
|
|
||||||
target: m.Destination,
|
|
||||||
mode: m.Mode,
|
|
||||||
rw: m.RW,
|
|
||||||
})),
|
|
||||||
networks: netSettings.Networks
|
|
||||||
? Object.entries(netSettings.Networks).map(([name, v]) => ({
|
|
||||||
name,
|
|
||||||
ip: v?.IPAddress || null,
|
|
||||||
mac: v?.MacAddress || null,
|
|
||||||
gateway: v?.Gateway || null,
|
|
||||||
}))
|
|
||||||
: [],
|
|
||||||
ports: netSettings.Ports
|
|
||||||
? Object.entries(netSettings.Ports).flatMap(([k, bindings]) => {
|
|
||||||
const [containerPort, proto] = k.split('/');
|
|
||||||
if (!Array.isArray(bindings) || !bindings.length) {
|
|
||||||
return [{ container: parseInt(containerPort, 10), proto, host: null, ip: null }];
|
|
||||||
}
|
|
||||||
return bindings.map(b => ({
|
|
||||||
container: parseInt(containerPort, 10),
|
|
||||||
proto,
|
|
||||||
host: b.HostPort ? parseInt(b.HostPort, 10) : null,
|
|
||||||
ip: b.HostIp || null,
|
|
||||||
}));
|
|
||||||
})
|
|
||||||
: [],
|
|
||||||
};
|
|
||||||
res.set('Cache-Control', 'no-store');
|
|
||||||
res.json(out);
|
|
||||||
} catch (err) {
|
|
||||||
if (/404/.test(err.message)) return res.status(404).json({ error: 'not_found' });
|
|
||||||
res.status(500).json({ error: err.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.get('/containers/:id/stats', async (req, res) => {
|
|
||||||
if (!DOCKER_SOCKET) return res.status(503).json({ error: 'docker_socket_unavailable' });
|
|
||||||
const { id } = req.params;
|
|
||||||
if (!safeRef(id)) return res.status(400).json({ error: 'invalid_id' });
|
|
||||||
try {
|
|
||||||
const sample = await statCache.get(`stats:${id}`, () =>
|
|
||||||
dockerRequest('GET', `/containers/${encodeURIComponent(id)}/stats`, { stream: 'false' })
|
|
||||||
);
|
|
||||||
if (!sample) return res.status(404).json({ error: 'not_found' });
|
|
||||||
res.set('Cache-Control', 'no-store');
|
|
||||||
res.json({
|
|
||||||
t: Date.now(),
|
|
||||||
cpu_percent: cpuPercent(sample),
|
|
||||||
memory: memUsage(sample),
|
|
||||||
network: netUsage(sample),
|
|
||||||
blkio: blkioUsage(sample),
|
|
||||||
pids: pidsUsage(sample),
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
if (/404/.test(err.message)) return res.status(404).json({ error: 'not_found' });
|
|
||||||
res.status(500).json({ error: err.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.get('/containers/:id/logs', async (req, res) => {
|
|
||||||
if (!DOCKER_SOCKET) return res.status(503).json({ error: 'docker_socket_unavailable' });
|
|
||||||
const { id } = req.params;
|
|
||||||
if (!safeRef(id)) return res.status(400).json({ error: 'invalid_id' });
|
|
||||||
const tail = Math.max(1, Math.min(2000, parseInt(req.query.tail, 10) || 200));
|
|
||||||
try {
|
|
||||||
const { stream } = await dockerStream(`/containers/${encodeURIComponent(id)}/logs`, {
|
|
||||||
stdout: 'true', stderr: 'true', tail: String(tail), timestamps: 'true',
|
|
||||||
});
|
|
||||||
const chunks = [];
|
|
||||||
for await (const c of stream) chunks.push(c);
|
|
||||||
const text = decodeMultiplexedLog(Buffer.concat(chunks));
|
|
||||||
res.set('Cache-Control', 'no-store');
|
|
||||||
res.type('text/plain').send(text);
|
|
||||||
} catch (err) {
|
|
||||||
if (/404/.test(err.message)) return res.status(404).json({ error: 'not_found' });
|
|
||||||
res.status(500).json({ error: err.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.get('/storage', async (req, res) => {
|
|
||||||
if (!DOCKER_SOCKET) return res.status(503).json({ error: 'docker_socket_unavailable' });
|
|
||||||
try {
|
|
||||||
const df = await storageCache.get('df', () => dockerRequest('GET', '/system/df'));
|
|
||||||
if (!df) return res.status(500).json({ error: 'no_data' });
|
|
||||||
// Roll the verbose response up into headline numbers per category. We
|
|
||||||
// surface only the engine overhead worth acting on — images and build
|
|
||||||
// cache — and skip container writable layers: for LibrePortal that's a
|
|
||||||
// near-zero scratch number (app data lives in bind mounts, shown per-app
|
|
||||||
// elsewhere) that just confuses the picture.
|
|
||||||
// "Reclaimable" reflects exactly what the Reclaim button frees: dangling
|
|
||||||
// images + the whole build cache. Tagged-but-unused images are
|
|
||||||
// deliberately NOT counted — the safe prune leaves them alone — so the
|
|
||||||
// headline matches the button's effect instead of overstating it.
|
|
||||||
const isDangling = (im) => {
|
|
||||||
const tags = im.RepoTags || [];
|
|
||||||
return tags.length === 0 || tags.every(t => t.includes('<none>'));
|
|
||||||
};
|
|
||||||
const sumImages = (df.Images || []).reduce(
|
|
||||||
(a, im) => {
|
|
||||||
a.count++;
|
|
||||||
a.size += im.Size || 0;
|
|
||||||
a.shared += im.SharedSize || 0;
|
|
||||||
if (isDangling(im)) a.reclaimable += im.Size || 0;
|
|
||||||
return a;
|
|
||||||
},
|
|
||||||
{ count: 0, size: 0, shared: 0, reclaimable: 0 }
|
|
||||||
);
|
|
||||||
const sumBuild = (df.BuildCache || []).reduce(
|
|
||||||
(a, b) => {
|
|
||||||
a.count++;
|
|
||||||
a.size += b.Size || 0;
|
|
||||||
if (!b.InUse) a.reclaimable += b.Size || 0;
|
|
||||||
return a;
|
|
||||||
},
|
|
||||||
{ count: 0, size: 0, reclaimable: 0 }
|
|
||||||
);
|
|
||||||
// Every image, largest first — the Storage page lists them all so the
|
|
||||||
// user can remove specific ones (deletion runs through the task/CLI,
|
|
||||||
// not from here). Dangling <none> images are included on purpose.
|
|
||||||
const images = (df.Images || [])
|
|
||||||
.slice()
|
|
||||||
.sort((a, b) => (b.Size || 0) - (a.Size || 0))
|
|
||||||
.map(im => ({
|
|
||||||
id: im.Id,
|
|
||||||
repo_tags: im.RepoTags || [],
|
|
||||||
size: im.Size || 0,
|
|
||||||
shared_size: im.SharedSize || 0,
|
|
||||||
containers: im.Containers || 0,
|
|
||||||
created: im.Created,
|
|
||||||
}));
|
|
||||||
const total = sumImages.size + sumBuild.size;
|
|
||||||
const reclaimable = sumImages.reclaimable + sumBuild.reclaimable;
|
|
||||||
res.set('Cache-Control', 'no-store');
|
|
||||||
res.json({
|
|
||||||
total, reclaimable,
|
|
||||||
images: sumImages,
|
|
||||||
build_cache: sumBuild,
|
|
||||||
image_list: images,
|
|
||||||
updated: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
res.status(500).json({ error: err.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
module.exports = router;
|
|
||||||
@ -1,58 +0,0 @@
|
|||||||
const express = require('express');
|
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
|
|
||||||
const router = express.Router();
|
|
||||||
|
|
||||||
const FEATURES_DIR = path.join(__dirname, '..', '..', 'frontend', 'components');
|
|
||||||
|
|
||||||
/* =========================
|
|
||||||
GET /api/features/list
|
|
||||||
|
|
||||||
Walks frontend/components/<id>/ and returns one entry per directory that
|
|
||||||
contains a feature.json — the WebUI's page manifest, discovered from the
|
|
||||||
folders themselves (exactly how /api/themes/list discovers themes). Drop a
|
|
||||||
components/<id>/ folder in and its page appears; delete it and the page is
|
|
||||||
gone — no central edit. The navigation kernel fetches this and falls back to
|
|
||||||
the checked-in components/manifest.dev.json if the API is unavailable.
|
|
||||||
|
|
||||||
Each feature.json declares: id, routes[], optional module (self-registering
|
|
||||||
index.js), optional handler (legacy fallback method), navId, nav{}, and order
|
|
||||||
(controls list + route-precedence ordering — e.g. apps before app-detail so
|
|
||||||
the '/apps*' wildcard wins over '/app*').
|
|
||||||
|
|
||||||
Public — the page list isn't sensitive and the kernel needs it before login
|
|
||||||
to render the right route (same rationale as the themes list).
|
|
||||||
========================= */
|
|
||||||
router.get('/list', (req, res) => {
|
|
||||||
const features = [];
|
|
||||||
try {
|
|
||||||
if (fs.existsSync(FEATURES_DIR)) {
|
|
||||||
for (const entry of fs.readdirSync(FEATURES_DIR, { withFileTypes: true })) {
|
|
||||||
if (!entry.isDirectory()) continue;
|
|
||||||
const metaPath = path.join(FEATURES_DIR, entry.name, 'feature.json');
|
|
||||||
if (!fs.existsSync(metaPath)) continue;
|
|
||||||
try {
|
|
||||||
const meta = JSON.parse(fs.readFileSync(metaPath, 'utf8'));
|
|
||||||
if (meta && meta.id && Array.isArray(meta.routes)) {
|
|
||||||
features.push(meta);
|
|
||||||
} else {
|
|
||||||
console.warn(`[features] ${entry.name}/feature.json missing id/routes — skipped`);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.warn(`[features] ${entry.name}/feature.json is malformed — skipped:`, e.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error scanning features directory:', err);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ascending `order` controls both nav order and route-registration order
|
|
||||||
// (the latter preserves wildcard precedence). Missing order sorts last.
|
|
||||||
features.sort((a, b) => ((a.order ?? 999) - (b.order ?? 999)));
|
|
||||||
|
|
||||||
res.json({ version: 1, source: 'scan', features });
|
|
||||||
});
|
|
||||||
|
|
||||||
module.exports = router;
|
|
||||||
@ -11,13 +11,10 @@ const PATHS = {
|
|||||||
|
|
||||||
const themeRoutes = require('./theme.js');
|
const themeRoutes = require('./theme.js');
|
||||||
const themesRoutes = require('./themes.js');
|
const themesRoutes = require('./themes.js');
|
||||||
const featuresRoutes = require('./features.js');
|
|
||||||
const authRoutes = require('./auth-routes.js');
|
const authRoutes = require('./auth-routes.js');
|
||||||
const taskRoutes = require('./task-routes.js');
|
const taskRoutes = require('./task-routes.js');
|
||||||
const serviceRoutes = require('./service-routes.js');
|
const serviceRoutes = require('./service-routes.js');
|
||||||
const setupRoutes = require('./setup-routes.js');
|
const setupRoutes = require('./setup-routes.js');
|
||||||
const systemRoutes = require('./system-routes.js');
|
|
||||||
const dockerInfoRoutes = require('./docker-info-routes.js');
|
|
||||||
const { testConnection } = require('../utils/mail.js');
|
const { testConnection } = require('../utils/mail.js');
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
@ -27,17 +24,12 @@ module.exports = {
|
|||||||
// Theme discovery is public so the login overlay can pick the right
|
// Theme discovery is public so the login overlay can pick the right
|
||||||
// palette before the user logs in.
|
// palette before the user logs in.
|
||||||
app.use('/api/themes', themesRoutes);
|
app.use('/api/themes', themesRoutes);
|
||||||
// Feature/page discovery is public for the same reason — the navigation
|
|
||||||
// kernel needs the page manifest before login to route the first paint.
|
|
||||||
app.use('/api/features', featuresRoutes);
|
|
||||||
|
|
||||||
// Protected API routes
|
// Protected API routes
|
||||||
app.use('/api/theme', requireAuth, themeRoutes);
|
app.use('/api/theme', requireAuth, themeRoutes);
|
||||||
app.use('/api/tasks', taskRoutes); // requireAuth applied per-route inside
|
app.use('/api/tasks', taskRoutes); // requireAuth applied per-route inside
|
||||||
app.use('/api/apps', serviceRoutes); // requireAuth applied per-route inside
|
app.use('/api/apps', serviceRoutes); // requireAuth applied per-route inside
|
||||||
app.use('/api/setup', setupRoutes); // requireAuth applied per-route inside
|
app.use('/api/setup', setupRoutes); // requireAuth applied per-route inside
|
||||||
app.use('/api/system', requireAuth, systemRoutes); // live host metrics (/proc)
|
|
||||||
app.use('/api/system', requireAuth, dockerInfoRoutes); // /containers/*, /storage
|
|
||||||
app.post('/api/test-mail-connection', requireAuth, testConnection);
|
app.post('/api/test-mail-connection', requireAuth, testConnection);
|
||||||
|
|
||||||
app.post('/api/gluetun/mullvad-wireguard', requireAuth, async (req, res) => {
|
app.post('/api/gluetun/mullvad-wireguard', requireAuth, async (req, res) => {
|
||||||
|
|||||||
@ -11,11 +11,9 @@
|
|||||||
// directly over the unix socket. That means no extra system deps and
|
// directly over the unix socket. That means no extra system deps and
|
||||||
// no group-level privilege grants — node only sees what the mounted
|
// no group-level privilege grants — node only sees what the mounted
|
||||||
// socket lets it see.
|
// socket lets it see.
|
||||||
// - This file is READ-ONLY surface (status + log tails). Restarting a
|
// - Restart still goes through the existing task system. The bash task
|
||||||
// service is a mutation, so it goes through the task system like every
|
// processor runs on the host (where `docker` IS available) so its
|
||||||
// other mutation: the Services tab dispatches a `service_restart` task
|
// `docker compose restart …` command works fine.
|
||||||
// (core/tasks) that runs `libreportal app restart <app> <service>` on
|
|
||||||
// the host, where `docker` IS available and runs as the right user.
|
|
||||||
// - URLs / port chips for each service are read client-side from the
|
// - URLs / port chips for each service are read client-side from the
|
||||||
// existing /data/apps/generated/apps-services.json — no backend
|
// existing /data/apps/generated/apps-services.json — no backend
|
||||||
// surface needed for that.
|
// surface needed for that.
|
||||||
@ -27,10 +25,14 @@ const path = require('path');
|
|||||||
const http = require('http');
|
const http = require('http');
|
||||||
const { spawn } = require('child_process');
|
const { spawn } = require('child_process');
|
||||||
const { requireAuth } = require('../utils/middleware.js');
|
const { requireAuth } = require('../utils/middleware.js');
|
||||||
|
const { pokeFifo } = require('../utils/fifo.js');
|
||||||
const { fileConfig } = require('../utils/config.js');
|
const { fileConfig } = require('../utils/config.js');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
|
const TASKS_DIR = path.join(__dirname, '..', '..', 'frontend', 'data', 'tasks');
|
||||||
|
const FIFO_PATH = path.join(TASKS_DIR, '.queue.fifo');
|
||||||
|
const CONTAINERS_DIR = '/docker/containers';
|
||||||
const APPS_SERVICES_JSON = path.join(__dirname, '..', '..', 'frontend', 'data', 'apps', 'generated', 'apps-services.json');
|
const APPS_SERVICES_JSON = path.join(__dirname, '..', '..', 'frontend', 'data', 'apps', 'generated', 'apps-services.json');
|
||||||
|
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
@ -303,6 +305,10 @@ async function lookupServiceTransport(appName, serviceName) {
|
|||||||
return { transport: 'docker' };
|
return { transport: 'docker' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function appComposeFile(appName) {
|
||||||
|
return path.join(CONTAINERS_DIR, appName, 'docker-compose.yml');
|
||||||
|
}
|
||||||
|
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
// GET /api/apps/:appName/services/status
|
// GET /api/apps/:appName/services/status
|
||||||
// → [{ serviceName, state, statusText, containerName, containerId }]
|
// → [{ serviceName, state, statusText, containerName, containerId }]
|
||||||
@ -362,6 +368,50 @@ router.get('/:appName/services/status', requireAuth, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// POST /api/apps/:appName/services/:serviceName/restart
|
||||||
|
// Creates a task that runs `docker compose restart <service>` on the
|
||||||
|
// host. The host has `docker` available; this container does not.
|
||||||
|
// =====================================================================
|
||||||
|
router.post('/:appName/services/:serviceName/restart', requireAuth, async (req, res) => {
|
||||||
|
const { appName, serviceName } = req.params;
|
||||||
|
if (!safeName(appName) || !safeName(serviceName)) {
|
||||||
|
return res.status(400).json({ error: 'Invalid app or service name' });
|
||||||
|
}
|
||||||
|
const compose = appComposeFile(appName);
|
||||||
|
if (!fs.existsSync(compose)) {
|
||||||
|
return res.status(404).json({ error: `Compose file not found: ${compose}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = `task_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
const task = {
|
||||||
|
id,
|
||||||
|
command: `docker compose -f "${compose}" restart "${serviceName}"`,
|
||||||
|
type: 'service-restart',
|
||||||
|
app: appName,
|
||||||
|
config: serviceName,
|
||||||
|
status: 'queued',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
startedAt: null,
|
||||||
|
completedAt: null,
|
||||||
|
heartbeatAt: null,
|
||||||
|
exitCode: null,
|
||||||
|
errorMessage: null
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fsp.mkdir(TASKS_DIR, { recursive: true });
|
||||||
|
const taskPath = path.join(TASKS_DIR, `${id}.json`);
|
||||||
|
const tmp = `${taskPath}.tmp`;
|
||||||
|
await fsp.writeFile(tmp, JSON.stringify(task, null, 2));
|
||||||
|
await fsp.rename(tmp, taskPath);
|
||||||
|
pokeFifo(FIFO_PATH, id);
|
||||||
|
res.status(201).json(task);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
// GET /api/apps/:appName/services/:serviceName/logs
|
// GET /api/apps/:appName/services/:serviceName/logs
|
||||||
// SSE-wraps the Docker /containers/<id>/logs?follow=1 stream.
|
// SSE-wraps the Docker /containers/<id>/logs?follow=1 stream.
|
||||||
|
|||||||
@ -154,15 +154,6 @@ router.post('/save', requireAuth, async (req, res) => {
|
|||||||
return res.status(400).json({ error: 'timezone required' });
|
return res.status(400).json({ error: 'timezone required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Experience level seeds the WebUI's Beginner/Advanced UI mode default.
|
|
||||||
// Optional — old WebUIs may not send it — and constrained to the
|
|
||||||
// enum so a bad value can't smuggle anything into the bash applier.
|
|
||||||
if (payload.install_level !== undefined) {
|
|
||||||
if (payload.install_level !== 'beginner' && payload.install_level !== 'advanced') {
|
|
||||||
return res.status(400).json({ error: 'invalid install_level' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Domains are optional but each entry must be a valid hostname. Cap at
|
// Domains are optional but each entry must be a valid hostname. Cap at
|
||||||
// 9 because the config schema only has CFG_DOMAIN_1..CFG_DOMAIN_9.
|
// 9 because the config schema only has CFG_DOMAIN_1..CFG_DOMAIN_9.
|
||||||
const domainRe = /^([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}$/i;
|
const domainRe = /^([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}$/i;
|
||||||
|
|||||||
@ -1,299 +0,0 @@
|
|||||||
// Live system metrics — the fast path behind the Admin → System gauges and the
|
|
||||||
// dashboard "pulse" tiles.
|
|
||||||
//
|
|
||||||
// Periodic host-side data (disks, network, docker, per-app, 24 h history) is
|
|
||||||
// produced by the webui_system_metrics generator into frontend/data/system/.
|
|
||||||
// This file serves the *live* path: CPU / memory / load read straight from
|
|
||||||
// /proc, optionally fused with the latest host JSON snapshot so a single SSE
|
|
||||||
// message carries everything a client needs to draw a frame.
|
|
||||||
//
|
|
||||||
// Endpoints:
|
|
||||||
// GET /live — single-shot JSON snapshot (kept for callers that still poll)
|
|
||||||
// GET /stream — Server-Sent Events; pushes a fused sample once per second.
|
|
||||||
// One /proc read per second across all subscribers (shared
|
|
||||||
// ticker), so 100 open tabs still cost one read/sec.
|
|
||||||
//
|
|
||||||
// Namespace note: this runs *inside* the libreportal container. /proc/stat,
|
|
||||||
// /proc/meminfo and /proc/loadavg are not namespaced, so they report host-wide
|
|
||||||
// values that match the generator's numbers. /proc/net/dev IS per-netns (it
|
|
||||||
// would show only this container's traffic), so the host generator owns
|
|
||||||
// network/disk and we splice its latest snapshot into each SSE message.
|
|
||||||
const express = require('express');
|
|
||||||
const fs = require('fs').promises;
|
|
||||||
const path = require('path');
|
|
||||||
const os = require('os');
|
|
||||||
const metricsWriter = require('../utils/metrics-writer.js');
|
|
||||||
|
|
||||||
const router = express.Router();
|
|
||||||
|
|
||||||
const CORES = os.cpus().length || 1;
|
|
||||||
const MIN_INTERVAL_MS = 750; // serve cache to anything faster than this
|
|
||||||
const STREAM_TICK_MS = 1000; // SSE push cadence — 1 Hz live feel
|
|
||||||
const HEARTBEAT_MS = 25000; // SSE comment frame to keep proxies from idling out
|
|
||||||
const HOST_JSON_DIR = path.join(__dirname, '..', '..', 'frontend', 'data', 'system');
|
|
||||||
|
|
||||||
let prevCpu = null; // { total, idle } from the last read
|
|
||||||
let cache = null; // { sample, at }
|
|
||||||
let inflight = null; // dedupe concurrent cache-miss reads
|
|
||||||
|
|
||||||
async function readCpu() {
|
|
||||||
const data = await fs.readFile('/proc/stat', 'utf8');
|
|
||||||
const first = data.split('\n', 1)[0]; // "cpu u n s i io irq sirq steal ..."
|
|
||||||
const n = first.trim().split(/\s+/).slice(1).map(Number);
|
|
||||||
const idle = (n[3] || 0) + (n[4] || 0); // idle + iowait
|
|
||||||
const total = n.reduce((a, b) => a + (b || 0), 0);
|
|
||||||
return { total, idle };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function readMem() {
|
|
||||||
const data = await fs.readFile('/proc/meminfo', 'utf8');
|
|
||||||
const m = {};
|
|
||||||
for (const line of data.split('\n')) {
|
|
||||||
const mm = line.match(/^(\w+):\s+(\d+)/);
|
|
||||||
if (mm) m[mm[1]] = parseInt(mm[2], 10) * 1024; // kB -> bytes
|
|
||||||
}
|
|
||||||
const total = m.MemTotal || 0;
|
|
||||||
const available = m.MemAvailable || 0;
|
|
||||||
const used = Math.max(0, total - available);
|
|
||||||
const swapTotal = m.SwapTotal || 0;
|
|
||||||
const swapUsed = Math.max(0, swapTotal - (m.SwapFree || 0));
|
|
||||||
return {
|
|
||||||
total, used, available,
|
|
||||||
percent: total ? +(used / total * 100).toFixed(1) : 0,
|
|
||||||
swap_total: swapTotal, swap_used: swapUsed,
|
|
||||||
swap_percent: swapTotal ? +(swapUsed / swapTotal * 100).toFixed(1) : 0
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function readLoad() {
|
|
||||||
const data = await fs.readFile('/proc/loadavg', 'utf8');
|
|
||||||
const [l1, l5, l15] = data.trim().split(/\s+/).map(Number);
|
|
||||||
return { load1: l1 || 0, load5: l5 || 0, load15: l15 || 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function sample() {
|
|
||||||
const [cpuNow, memory, load] = await Promise.all([readCpu(), readMem(), readLoad()]);
|
|
||||||
let percent = 0;
|
|
||||||
if (prevCpu) {
|
|
||||||
const dt = cpuNow.total - prevCpu.total;
|
|
||||||
const di = cpuNow.idle - prevCpu.idle;
|
|
||||||
if (dt > 0) percent = +Math.max(0, Math.min(100, (1 - di / dt) * 100)).toFixed(1);
|
|
||||||
}
|
|
||||||
prevCpu = cpuNow;
|
|
||||||
return {
|
|
||||||
cpu: {
|
|
||||||
percent,
|
|
||||||
cores: CORES,
|
|
||||||
load1: load.load1, load5: load.load5, load15: load.load15,
|
|
||||||
load1_percent: +Math.min(100, load.load1 / CORES * 100).toFixed(1)
|
|
||||||
},
|
|
||||||
memory,
|
|
||||||
t: Date.now()
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
router.get('/live', async (req, res) => {
|
|
||||||
const now = Date.now();
|
|
||||||
if (cache && (now - cache.at) < MIN_INTERVAL_MS) {
|
|
||||||
res.set('Cache-Control', 'no-store');
|
|
||||||
return res.json(cache.sample);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
if (!inflight) {
|
|
||||||
inflight = sample()
|
|
||||||
.then((s) => { cache = { sample: s, at: Date.now() }; return s; })
|
|
||||||
.finally(() => { inflight = null; });
|
|
||||||
}
|
|
||||||
const s = await inflight;
|
|
||||||
res.set('Cache-Control', 'no-store');
|
|
||||||
res.json(s);
|
|
||||||
} catch (err) {
|
|
||||||
res.status(500).json({ error: 'metrics_unavailable' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// SSE live stream
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// One ticker for the whole process. Subscribers join/leave; the ticker only
|
|
||||||
// runs while at least one is connected, so an idle WebUI costs nothing.
|
|
||||||
|
|
||||||
const subscribers = new Set();
|
|
||||||
let tickHandle = null;
|
|
||||||
let heartbeatHandle = null;
|
|
||||||
let lastSample = null;
|
|
||||||
let hostJson = { metrics: null, disk: null, memory: null, apps: null };
|
|
||||||
let hostJsonLoadedAt = 0;
|
|
||||||
const HOST_JSON_REFRESH_MS = 5000; // re-read host snapshots every 5 s (they regen at most 1×/min)
|
|
||||||
|
|
||||||
// Read a JSON file but never throw — missing/invalid → previous value.
|
|
||||||
async function readJsonSafe(file, fallback = null) {
|
|
||||||
try {
|
|
||||||
const txt = await fs.readFile(file, 'utf8');
|
|
||||||
return JSON.parse(txt);
|
|
||||||
} catch (_) {
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Refresh the cached host-side JSON if it's been at least HOST_JSON_REFRESH_MS
|
|
||||||
// since the last read. Cheap when the files haven't changed because the OS
|
|
||||||
// page cache makes the read essentially free.
|
|
||||||
async function refreshHostJson(now) {
|
|
||||||
if (now - hostJsonLoadedAt < HOST_JSON_REFRESH_MS) return;
|
|
||||||
hostJsonLoadedAt = now;
|
|
||||||
const [metrics, disk, memory, apps] = await Promise.all([
|
|
||||||
readJsonSafe(path.join(HOST_JSON_DIR, 'metrics.json'), hostJson.metrics),
|
|
||||||
readJsonSafe(path.join(HOST_JSON_DIR, 'disk_usage.json'), hostJson.disk),
|
|
||||||
readJsonSafe(path.join(HOST_JSON_DIR, 'memory_usage.json'), hostJson.memory),
|
|
||||||
readJsonSafe(path.join(HOST_JSON_DIR, 'metrics_apps.json'), hostJson.apps)
|
|
||||||
]);
|
|
||||||
hostJson = { metrics, disk, memory, apps };
|
|
||||||
}
|
|
||||||
|
|
||||||
function ssePayload(s) {
|
|
||||||
// Fuse the live in-container sample with the latest host-side snapshot so
|
|
||||||
// a client gets everything it needs from one stream. The host fields tick
|
|
||||||
// slowly (≤ 1/min) but live alongside the 1 Hz CPU/mem feed.
|
|
||||||
const m = hostJson.metrics || {};
|
|
||||||
return {
|
|
||||||
t: s.t,
|
|
||||||
cpu: s.cpu,
|
|
||||||
memory: s.memory,
|
|
||||||
disks: Array.isArray(m.disks) ? m.disks : [],
|
|
||||||
network: m.network || { rx_rate: 0, tx_rate: 0 },
|
|
||||||
docker: m.docker || null,
|
|
||||||
apps: (hostJson.apps && Array.isArray(hostJson.apps.apps)) ? hostJson.apps.apps : []
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function tick() {
|
|
||||||
if (subscribers.size === 0) { // nothing to do — defensive
|
|
||||||
stopTicker();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const s = await sample();
|
|
||||||
const now = Date.now();
|
|
||||||
cache = { sample: s, at: now };
|
|
||||||
await refreshHostJson(now);
|
|
||||||
const payload = ssePayload(s);
|
|
||||||
lastSample = payload;
|
|
||||||
const frame = `data: ${JSON.stringify(payload)}\n\n`;
|
|
||||||
for (const res of subscribers) {
|
|
||||||
try { res.write(frame); } catch (_) { /* will be reaped on close */ }
|
|
||||||
}
|
|
||||||
} catch (_) { /* swallow — try again next tick */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
function startTicker() {
|
|
||||||
if (tickHandle) return;
|
|
||||||
tick(); // fire immediately so the first frame is fresh
|
|
||||||
tickHandle = setInterval(tick, STREAM_TICK_MS);
|
|
||||||
// Heartbeat keeps proxies (Traefik/nginx) from idling the connection out;
|
|
||||||
// SSE comments start with ":" and are ignored by EventSource.
|
|
||||||
heartbeatHandle = setInterval(() => {
|
|
||||||
for (const res of subscribers) {
|
|
||||||
try { res.write(': hb\n\n'); } catch (_) {}
|
|
||||||
}
|
|
||||||
}, HEARTBEAT_MS);
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopTicker() {
|
|
||||||
if (tickHandle) { clearInterval(tickHandle); tickHandle = null; }
|
|
||||||
if (heartbeatHandle) { clearInterval(heartbeatHandle); heartbeatHandle = null; }
|
|
||||||
}
|
|
||||||
|
|
||||||
router.get('/stream', async (req, res) => {
|
|
||||||
// SSE handshake. `no-transform` tells the compression middleware not to
|
|
||||||
// gzip this response (gzip buffers and would break streaming). `X-Accel-
|
|
||||||
// Buffering: no` tells nginx/Traefik to flush each event immediately.
|
|
||||||
res.set({
|
|
||||||
'Content-Type': 'text/event-stream; charset=utf-8',
|
|
||||||
'Cache-Control': 'no-store, no-transform',
|
|
||||||
Connection: 'keep-alive',
|
|
||||||
'X-Accel-Buffering': 'no'
|
|
||||||
});
|
|
||||||
res.flushHeaders?.();
|
|
||||||
// Initial "retry" hint — if the connection dies the browser will reopen
|
|
||||||
// after this many ms (default 3000 is fine but explicit is clearer).
|
|
||||||
res.write('retry: 3000\n\n');
|
|
||||||
|
|
||||||
subscribers.add(res);
|
|
||||||
startTicker();
|
|
||||||
|
|
||||||
// If we already have a fresh sample, ship it right now so the client doesn't
|
|
||||||
// have to wait STREAM_TICK_MS for its first frame.
|
|
||||||
if (lastSample) {
|
|
||||||
try { res.write(`data: ${JSON.stringify(lastSample)}\n\n`); } catch (_) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
const cleanup = () => {
|
|
||||||
subscribers.delete(res);
|
|
||||||
if (subscribers.size === 0) stopTicker();
|
|
||||||
};
|
|
||||||
req.on('close', cleanup);
|
|
||||||
req.on('error', cleanup);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// History range query
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// `range` is minutes back from now (1..10080 = 7 days). `keys` is an optional
|
|
||||||
// comma-list of metric names to project (defaults to the whole point).
|
|
||||||
//
|
|
||||||
// Two on-disk binary rings back this:
|
|
||||||
// 1m tier — 1440 pts @ 1-min (24 h)
|
|
||||||
// 5m tier — 2016 pts @ 5-min ( 7 d)
|
|
||||||
//
|
|
||||||
// Tier auto-selects from `range`: ≤ 1440 reads the 1m ring point-for-point;
|
|
||||||
// > 1440 reads the 5m ring (range/5 points). Caller can override with
|
|
||||||
// `?tier=1m|5m`. Falls back to the legacy JSON only if the binary ring is
|
|
||||||
// completely empty (e.g. fresh container, writer hasn't filled it yet).
|
|
||||||
const HISTORY_MAX_MIN = 10080; // 7 days
|
|
||||||
router.get('/history', async (req, res) => {
|
|
||||||
const range = Math.max(1, Math.min(HISTORY_MAX_MIN, parseInt(req.query.range, 10) || 60));
|
|
||||||
const tier = req.query.tier === '5m' || req.query.tier === '1m'
|
|
||||||
? req.query.tier
|
|
||||||
: (range > 1440 ? '5m' : '1m');
|
|
||||||
const wantPoints = tier === '5m' ? Math.ceil(range / 5) : range;
|
|
||||||
const keys = typeof req.query.keys === 'string' && req.query.keys.length
|
|
||||||
? req.query.keys.split(',').map((s) => s.trim()).filter(Boolean)
|
|
||||||
: null;
|
|
||||||
try {
|
|
||||||
let pts = await metricsWriter.read(wantPoints, tier);
|
|
||||||
let updated = null;
|
|
||||||
if (pts.length) {
|
|
||||||
updated = new Date(pts[pts.length - 1].t * 1000).toISOString();
|
|
||||||
} else {
|
|
||||||
// Cold start: writer hasn't filled the ring yet. Serve from the
|
|
||||||
// legacy JSON so the UI still has something to draw.
|
|
||||||
const file = path.join(HOST_JSON_DIR, 'metrics_history.json');
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(await fs.readFile(file, 'utf8'));
|
|
||||||
pts = (Array.isArray(parsed?.points) ? parsed.points : []).slice(-wantPoints);
|
|
||||||
updated = parsed?.updated || null;
|
|
||||||
} catch (_) { /* leave pts empty */ }
|
|
||||||
}
|
|
||||||
const points = keys
|
|
||||||
? pts.map((p) => {
|
|
||||||
const out = { t: p.t };
|
|
||||||
for (const k of keys) if (k in p) out[k] = p[k];
|
|
||||||
return out;
|
|
||||||
})
|
|
||||||
: pts;
|
|
||||||
res.set('Cache-Control', 'no-store');
|
|
||||||
res.json({ range, tier, points, updated });
|
|
||||||
} catch (_) {
|
|
||||||
res.status(500).json({ error: 'history_unavailable', points: [] });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Kick the persistent 1-min writer. It needs the same `sample()` we use for
|
|
||||||
// the SSE stream — passed in to avoid a circular require.
|
|
||||||
metricsWriter.start({
|
|
||||||
sampleFn: sample,
|
|
||||||
hostJsonFn: () => hostJson,
|
|
||||||
});
|
|
||||||
|
|
||||||
module.exports = router;
|
|
||||||
@ -42,7 +42,7 @@ const webuiLoginsConfig = parseConfigFile(path.join(__dirname, '..', '..', 'webu
|
|||||||
const webuiLogsConfig = parseConfigFile(path.join(__dirname, '..', '..', 'webui_logs'));
|
const webuiLogsConfig = parseConfigFile(path.join(__dirname, '..', '..', 'webui_logs'));
|
||||||
|
|
||||||
// Merge: later sources override earlier. webui_logins / webui_logs hold
|
// Merge: later sources override earlier. webui_logins / webui_logs hold
|
||||||
// CFG_WEBUI_* keys generated from the system configs/webui/* and bind-mounted
|
// CFG_WEBUI_* keys generated from /docker/configs/webui/* and bind-mounted
|
||||||
// in via libreportal's compose.
|
// in via libreportal's compose.
|
||||||
const fileConfig = { ...libreportalConfig, ...webuiLoginsConfig, ...webuiLogsConfig };
|
const fileConfig = { ...libreportalConfig, ...webuiLoginsConfig, ...webuiLogsConfig };
|
||||||
|
|
||||||
|
|||||||
@ -1,126 +0,0 @@
|
|||||||
// Tiny Docker Engine API client over the bind-mounted unix socket.
|
|
||||||
//
|
|
||||||
// Extracted from service-routes.js so other routes (per-container stats,
|
|
||||||
// system df, etc.) can talk to the daemon without duplicating the socket-
|
|
||||||
// discovery + http-over-unix-socket dance.
|
|
||||||
//
|
|
||||||
// We deliberately do NOT add the `dockerode` package — node's built-in http
|
|
||||||
// agent already supports `socketPath`, and the small subset of the Engine
|
|
||||||
// API we use fits in a couple of dozen lines. Zero extra deps, easy to audit.
|
|
||||||
|
|
||||||
const fs = require('fs');
|
|
||||||
const http = require('http');
|
|
||||||
|
|
||||||
// Whichever socket the host bind-mounted into the container is the one we
|
|
||||||
// can reach. Rooted installs mount /var/run/docker.sock; rootless mounts
|
|
||||||
// /run/user/<uid>/docker.sock under the runtime dir.
|
|
||||||
function detectDockerSocket() {
|
|
||||||
if (fs.existsSync('/var/run/docker.sock')) return '/var/run/docker.sock';
|
|
||||||
try {
|
|
||||||
for (const entry of fs.readdirSync('/run/user', { withFileTypes: true })) {
|
|
||||||
if (!entry.isDirectory()) continue;
|
|
||||||
const sock = `/run/user/${entry.name}/docker.sock`;
|
|
||||||
if (fs.existsSync(sock)) return sock;
|
|
||||||
}
|
|
||||||
} catch { /* /run/user not readable — fine */ }
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DOCKER_SOCKET = detectDockerSocket();
|
|
||||||
const DOCKER_API_VERSION = 'v1.41'; // Docker 20.10+
|
|
||||||
|
|
||||||
// Simple JSON GET (or other method without a body). Returns parsed JSON.
|
|
||||||
function dockerRequest(method, pathname, query) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
if (!DOCKER_SOCKET) return reject(new Error('No docker socket available'));
|
|
||||||
const qs = query ? '?' + new URLSearchParams(query).toString() : '';
|
|
||||||
const req = http.request(
|
|
||||||
{
|
|
||||||
socketPath: DOCKER_SOCKET,
|
|
||||||
method,
|
|
||||||
path: `/${DOCKER_API_VERSION}${pathname}${qs}`,
|
|
||||||
headers: { Host: 'docker', Accept: 'application/json' },
|
|
||||||
},
|
|
||||||
(res) => {
|
|
||||||
const chunks = [];
|
|
||||||
res.on('data', (c) => chunks.push(c));
|
|
||||||
res.on('end', () => {
|
|
||||||
const body = Buffer.concat(chunks).toString('utf8');
|
|
||||||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
||||||
try { resolve(body ? JSON.parse(body) : null); }
|
|
||||||
catch (e) { reject(new Error(`Docker API parse error: ${e.message}`)); }
|
|
||||||
} else {
|
|
||||||
reject(new Error(`Docker API ${res.statusCode}: ${body}`));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
);
|
|
||||||
req.on('error', reject);
|
|
||||||
req.end();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Streaming GET — caller gets the raw IncomingMessage so they can pipe
|
|
||||||
// or parse multiplexed log frames themselves.
|
|
||||||
function dockerStream(pathname, query) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
if (!DOCKER_SOCKET) return reject(new Error('No docker socket available'));
|
|
||||||
const qs = query ? '?' + new URLSearchParams(query).toString() : '';
|
|
||||||
const req = http.request(
|
|
||||||
{
|
|
||||||
socketPath: DOCKER_SOCKET,
|
|
||||||
method: 'GET',
|
|
||||||
path: `/${DOCKER_API_VERSION}${pathname}${qs}`,
|
|
||||||
headers: { Host: 'docker' },
|
|
||||||
},
|
|
||||||
(res) => {
|
|
||||||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
||||||
resolve({ stream: res, req });
|
|
||||||
} else {
|
|
||||||
const chunks = [];
|
|
||||||
res.on('data', (c) => chunks.push(c));
|
|
||||||
res.on('end', () => reject(new Error(
|
|
||||||
`Docker API ${res.statusCode}: ${Buffer.concat(chunks).toString('utf8')}`
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
req.on('error', reject);
|
|
||||||
req.end();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Docker's log frames over the API are multiplexed when no TTY is attached.
|
|
||||||
// Each frame: 8-byte header [stream(1) 0 0 0 size(4 BE)] + N payload bytes.
|
|
||||||
// stream: 0=stdin (unused), 1=stdout, 2=stderr. This decoder concatenates
|
|
||||||
// the payload as a single string with no markers (callers don't care about
|
|
||||||
// per-stream tagging for our use cases — they just want the text). If a
|
|
||||||
// container WAS started with -t, frames are raw text with no header; we
|
|
||||||
// detect that by failing to parse a sane header and falling back to a raw
|
|
||||||
// utf-8 decode.
|
|
||||||
function decodeMultiplexedLog(buf) {
|
|
||||||
if (!Buffer.isBuffer(buf) || buf.length === 0) return '';
|
|
||||||
const out = [];
|
|
||||||
let i = 0;
|
|
||||||
let sawValidFrame = false;
|
|
||||||
while (i + 8 <= buf.length) {
|
|
||||||
const stream = buf[i];
|
|
||||||
if (stream > 2) break; // not a header — bail to raw fallback
|
|
||||||
const size = buf.readUInt32BE(i + 4);
|
|
||||||
const end = i + 8 + size;
|
|
||||||
if (end > buf.length) break;
|
|
||||||
out.push(buf.slice(i + 8, end).toString('utf8'));
|
|
||||||
sawValidFrame = true;
|
|
||||||
i = end;
|
|
||||||
}
|
|
||||||
if (!sawValidFrame) return buf.toString('utf8');
|
|
||||||
return out.join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
DOCKER_SOCKET,
|
|
||||||
DOCKER_API_VERSION,
|
|
||||||
dockerRequest,
|
|
||||||
dockerStream,
|
|
||||||
decodeMultiplexedLog,
|
|
||||||
};
|
|
||||||
@ -1,225 +0,0 @@
|
|||||||
// Binary ring buffer for system metrics, on-disk.
|
|
||||||
//
|
|
||||||
// Two tiers feed the Admin → System trend charts:
|
|
||||||
// - 1m: 1440 points → 24 h at 1-minute resolution
|
|
||||||
// - 5m: 2016 points → 7 d at 5-minute resolution
|
|
||||||
//
|
|
||||||
// Why binary instead of JSON: JSON parse of a 2016-point array is enough work
|
|
||||||
// (~ms in the libreportal container) that the history endpoint felt sluggish
|
|
||||||
// at 7-day ranges. A fixed 32-byte-per-point binary file is ~10× smaller, has
|
|
||||||
// O(range) cost with no parse, and the file is mmap-friendly if we ever need
|
|
||||||
// even larger windows.
|
|
||||||
//
|
|
||||||
// On-disk layout (little-endian):
|
|
||||||
// HEADER (32 B)
|
|
||||||
// 0 4 magic "LPMR"
|
|
||||||
// 4 1 version 0x01
|
|
||||||
// 5 1 point_size bytes per point (32)
|
|
||||||
// 6 1 field_count metric fields per point, not counting timestamp (7)
|
|
||||||
// 7 1 flags reserved
|
|
||||||
// 8 4 capacity max points
|
|
||||||
// 12 4 head next write index (0..capacity-1)
|
|
||||||
// 16 4 count valid points (<= capacity)
|
|
||||||
// 20 4 bucket_sec seconds per bucket (60 | 300)
|
|
||||||
// 24 4 last_t last bucket timestamp, unix seconds
|
|
||||||
// 28 4 reserved
|
|
||||||
//
|
|
||||||
// POINT (32 B)
|
|
||||||
// 0 4 uint32 t bucket start, unix seconds
|
|
||||||
// 4 4 float32 cpu %
|
|
||||||
// 8 4 float32 mem %
|
|
||||||
// 12 4 float32 swap %
|
|
||||||
// 16 4 float32 disk % (root mount)
|
|
||||||
// 20 4 float32 load1
|
|
||||||
// 24 4 float32 net_rx bytes/sec average over bucket
|
|
||||||
// 28 4 float32 net_tx bytes/sec
|
|
||||||
//
|
|
||||||
// File size: 32 + capacity * 32. For our tiers, 46 KB and 64 KB respectively.
|
|
||||||
// All writes are append-only (no mid-ring rewrites) and atomic at the byte-
|
|
||||||
// range level: we open with O_RDWR, write a single 32-byte point at the slot
|
|
||||||
// offset, then patch the header. A torn write is recoverable (count won't
|
|
||||||
// have advanced; the slot is just garbage that'll be overwritten next tick).
|
|
||||||
|
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
|
|
||||||
const MAGIC = Buffer.from('LPMR', 'ascii');
|
|
||||||
const VERSION = 0x01;
|
|
||||||
const POINT_SIZE = 32;
|
|
||||||
const HEADER_SIZE = 32;
|
|
||||||
const FIELD_COUNT = 7;
|
|
||||||
const FIELDS = ['cpu', 'mem', 'swap', 'disk', 'load1', 'net_rx', 'net_tx'];
|
|
||||||
|
|
||||||
// Build a new header buffer for `capacity` points at `bucketSec` resolution.
|
|
||||||
function newHeader(capacity, bucketSec) {
|
|
||||||
const h = Buffer.alloc(HEADER_SIZE);
|
|
||||||
MAGIC.copy(h, 0);
|
|
||||||
h.writeUInt8(VERSION, 4);
|
|
||||||
h.writeUInt8(POINT_SIZE, 5);
|
|
||||||
h.writeUInt8(FIELD_COUNT, 6);
|
|
||||||
h.writeUInt8(0, 7);
|
|
||||||
h.writeUInt32LE(capacity, 8);
|
|
||||||
h.writeUInt32LE(0, 12); // head
|
|
||||||
h.writeUInt32LE(0, 16); // count
|
|
||||||
h.writeUInt32LE(bucketSec, 20);
|
|
||||||
h.writeUInt32LE(0, 24); // last_t
|
|
||||||
h.writeUInt32LE(0, 28);
|
|
||||||
return h;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseHeader(buf) {
|
|
||||||
if (buf.length < HEADER_SIZE) throw new Error('ring: short header');
|
|
||||||
if (buf.slice(0, 4).compare(MAGIC) !== 0) throw new Error('ring: bad magic');
|
|
||||||
if (buf.readUInt8(4) !== VERSION) throw new Error('ring: unsupported version');
|
|
||||||
if (buf.readUInt8(5) !== POINT_SIZE) throw new Error('ring: wrong point size');
|
|
||||||
return {
|
|
||||||
version: buf.readUInt8(4),
|
|
||||||
pointSize: buf.readUInt8(5),
|
|
||||||
fieldCount: buf.readUInt8(6),
|
|
||||||
capacity: buf.readUInt32LE(8),
|
|
||||||
head: buf.readUInt32LE(12),
|
|
||||||
count: buf.readUInt32LE(16),
|
|
||||||
bucketSec: buf.readUInt32LE(20),
|
|
||||||
lastT: buf.readUInt32LE(24),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function encodePoint(p) {
|
|
||||||
const b = Buffer.alloc(POINT_SIZE);
|
|
||||||
b.writeUInt32LE(Math.max(0, Math.floor(p.t || 0)), 0);
|
|
||||||
b.writeFloatLE(Number(p.cpu) || 0, 4);
|
|
||||||
b.writeFloatLE(Number(p.mem) || 0, 8);
|
|
||||||
b.writeFloatLE(Number(p.swap) || 0, 12);
|
|
||||||
b.writeFloatLE(Number(p.disk) || 0, 16);
|
|
||||||
b.writeFloatLE(Number(p.load1) || 0, 20);
|
|
||||||
b.writeFloatLE(Number(p.net_rx) || 0, 24);
|
|
||||||
b.writeFloatLE(Number(p.net_tx) || 0, 28);
|
|
||||||
return b;
|
|
||||||
}
|
|
||||||
|
|
||||||
function decodePoint(buf, offset = 0) {
|
|
||||||
return {
|
|
||||||
t: buf.readUInt32LE(offset),
|
|
||||||
cpu: +buf.readFloatLE(offset + 4).toFixed(2),
|
|
||||||
mem: +buf.readFloatLE(offset + 8).toFixed(2),
|
|
||||||
swap: +buf.readFloatLE(offset + 12).toFixed(2),
|
|
||||||
disk: +buf.readFloatLE(offset + 16).toFixed(2),
|
|
||||||
load1: +buf.readFloatLE(offset + 20).toFixed(3),
|
|
||||||
net_rx: Math.round(buf.readFloatLE(offset + 24)),
|
|
||||||
net_tx: Math.round(buf.readFloatLE(offset + 28)),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
class MetricsRing {
|
|
||||||
// file: absolute path to the .bin
|
|
||||||
// capacity: max points
|
|
||||||
// bucketSec: seconds per bucket (60 | 300)
|
|
||||||
constructor({ file, capacity, bucketSec }) {
|
|
||||||
this.file = file;
|
|
||||||
this.capacity = capacity;
|
|
||||||
this.bucketSec = bucketSec;
|
|
||||||
this.fd = null;
|
|
||||||
this.header = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Open (creating if missing) and validate. Safe to call repeatedly.
|
|
||||||
async open() {
|
|
||||||
if (this.fd !== null) return;
|
|
||||||
const dir = path.dirname(this.file);
|
|
||||||
try { await fs.promises.mkdir(dir, { recursive: true }); } catch (_) {}
|
|
||||||
let needInit = false;
|
|
||||||
try {
|
|
||||||
const st = await fs.promises.stat(this.file);
|
|
||||||
const expected = HEADER_SIZE + this.capacity * POINT_SIZE;
|
|
||||||
if (st.size !== expected) needInit = true;
|
|
||||||
} catch (_) {
|
|
||||||
needInit = true;
|
|
||||||
}
|
|
||||||
if (needInit) {
|
|
||||||
const full = Buffer.alloc(HEADER_SIZE + this.capacity * POINT_SIZE);
|
|
||||||
newHeader(this.capacity, this.bucketSec).copy(full, 0);
|
|
||||||
await fs.promises.writeFile(this.file, full);
|
|
||||||
}
|
|
||||||
this.fd = await fs.promises.open(this.file, 'r+');
|
|
||||||
const headBuf = Buffer.alloc(HEADER_SIZE);
|
|
||||||
await this.fd.read(headBuf, 0, HEADER_SIZE, 0);
|
|
||||||
this.header = parseHeader(headBuf);
|
|
||||||
if (this.header.capacity !== this.capacity || this.header.bucketSec !== this.bucketSec) {
|
|
||||||
// Capacity / bucket changed (config bump) — start fresh. Old data
|
|
||||||
// wouldn't line up with the new grid anyway.
|
|
||||||
await this.fd.close();
|
|
||||||
this.fd = null;
|
|
||||||
await fs.promises.unlink(this.file);
|
|
||||||
return this.open();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async close() {
|
|
||||||
if (this.fd) { try { await this.fd.close(); } catch (_) {} this.fd = null; }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Append a single point. Caller is responsible for rounding `t` to the
|
|
||||||
// bucket boundary (e.g. Math.floor(now / bucketSec) * bucketSec).
|
|
||||||
async append(point) {
|
|
||||||
await this.open();
|
|
||||||
const slot = this.header.head;
|
|
||||||
const offset = HEADER_SIZE + slot * POINT_SIZE;
|
|
||||||
const buf = encodePoint(point);
|
|
||||||
await this.fd.write(buf, 0, POINT_SIZE, offset);
|
|
||||||
this.header.head = (slot + 1) % this.capacity;
|
|
||||||
this.header.count = Math.min(this.capacity, this.header.count + 1);
|
|
||||||
this.header.lastT = point.t >>> 0;
|
|
||||||
const h = Buffer.alloc(HEADER_SIZE);
|
|
||||||
// Re-encode only the mutable fields; the static prefix stays the same.
|
|
||||||
MAGIC.copy(h, 0);
|
|
||||||
h.writeUInt8(VERSION, 4);
|
|
||||||
h.writeUInt8(POINT_SIZE, 5);
|
|
||||||
h.writeUInt8(FIELD_COUNT, 6);
|
|
||||||
h.writeUInt8(0, 7);
|
|
||||||
h.writeUInt32LE(this.capacity, 8);
|
|
||||||
h.writeUInt32LE(this.header.head, 12);
|
|
||||||
h.writeUInt32LE(this.header.count, 16);
|
|
||||||
h.writeUInt32LE(this.bucketSec, 20);
|
|
||||||
h.writeUInt32LE(this.header.lastT, 24);
|
|
||||||
h.writeUInt32LE(0, 28);
|
|
||||||
await this.fd.write(h, 0, HEADER_SIZE, 0);
|
|
||||||
// fdatasync would be safer against power loss but doubles cost; the
|
|
||||||
// worst case here is losing the most recent minute or two of metrics
|
|
||||||
// — not worth the IO penalty on every append.
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read the last `n` points in chronological order (oldest → newest).
|
|
||||||
// Returns [] if the ring is empty.
|
|
||||||
async readLast(n) {
|
|
||||||
await this.open();
|
|
||||||
const { head, count, capacity } = this.header;
|
|
||||||
if (count === 0) return [];
|
|
||||||
const want = Math.max(1, Math.min(n | 0, count));
|
|
||||||
// The oldest of `want` lives `want` slots before head (modulo cap).
|
|
||||||
const start = (head - want + capacity) % capacity;
|
|
||||||
// Two-segment read so we don't span the wrap unnecessarily.
|
|
||||||
const out = new Array(want);
|
|
||||||
if (start + want <= capacity) {
|
|
||||||
const buf = Buffer.alloc(want * POINT_SIZE);
|
|
||||||
await this.fd.read(buf, 0, buf.length, HEADER_SIZE + start * POINT_SIZE);
|
|
||||||
for (let i = 0; i < want; i++) out[i] = decodePoint(buf, i * POINT_SIZE);
|
|
||||||
} else {
|
|
||||||
const firstLen = capacity - start;
|
|
||||||
const a = Buffer.alloc(firstLen * POINT_SIZE);
|
|
||||||
const b = Buffer.alloc((want - firstLen) * POINT_SIZE);
|
|
||||||
await this.fd.read(a, 0, a.length, HEADER_SIZE + start * POINT_SIZE);
|
|
||||||
await this.fd.read(b, 0, b.length, HEADER_SIZE);
|
|
||||||
for (let i = 0; i < firstLen; i++) out[i] = decodePoint(a, i * POINT_SIZE);
|
|
||||||
for (let i = 0; i < want - firstLen; i++) out[firstLen + i] = decodePoint(b, i * POINT_SIZE);
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convenience: latest bucket timestamp on disk (0 if empty).
|
|
||||||
async lastT() {
|
|
||||||
await this.open();
|
|
||||||
return this.header.lastT || 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { MetricsRing, FIELDS, HEADER_SIZE, POINT_SIZE };
|
|
||||||
@ -1,183 +0,0 @@
|
|||||||
// Persistent metrics-history writer.
|
|
||||||
//
|
|
||||||
// Runs alongside the SSE ticker inside the libreportal container. Every
|
|
||||||
// minute, on the bucket boundary, it composes a single sample from /proc plus
|
|
||||||
// the latest host-side JSON snapshots and appends it to the 1-minute ring;
|
|
||||||
// every 5 minutes it also pushes a (5-pt average) point into the 5-minute
|
|
||||||
// ring. Independent of whether any client is subscribed to /api/system/stream
|
|
||||||
// — the trend charts must keep filling even when nobody's watching.
|
|
||||||
//
|
|
||||||
// On startup, if the 1-minute ring is empty but the legacy metrics_history.
|
|
||||||
// json exists, we backfill from it so first paint already has 24 h of data.
|
|
||||||
|
|
||||||
const fs = require('fs').promises;
|
|
||||||
const path = require('path');
|
|
||||||
const { MetricsRing } = require('./metrics-ring.js');
|
|
||||||
|
|
||||||
const ONE_MIN = 60;
|
|
||||||
const FIVE_MIN = 300;
|
|
||||||
const ONE_MIN_CAP = 1440; // 24 h
|
|
||||||
const FIVE_MIN_CAP = 2016; // 7 d
|
|
||||||
|
|
||||||
const HOST_JSON_DIR = path.join(__dirname, '..', '..', 'frontend', 'data', 'system');
|
|
||||||
const RING_1M = path.join(HOST_JSON_DIR, 'metrics_ring_1m.bin');
|
|
||||||
const RING_5M = path.join(HOST_JSON_DIR, 'metrics_ring_5m.bin');
|
|
||||||
const LEGACY_HIST = path.join(HOST_JSON_DIR, 'metrics_history.json');
|
|
||||||
|
|
||||||
const ring1 = new MetricsRing({ file: RING_1M, capacity: ONE_MIN_CAP, bucketSec: ONE_MIN });
|
|
||||||
const ring5 = new MetricsRing({ file: RING_5M, capacity: FIVE_MIN_CAP, bucketSec: FIVE_MIN });
|
|
||||||
|
|
||||||
// readSampleFn is injected so we don't have a circular require with system-
|
|
||||||
// routes.js (which also wants to use sample()).
|
|
||||||
let readSample = null;
|
|
||||||
let readHostJson = null;
|
|
||||||
let tickHandle = null;
|
|
||||||
let started = false;
|
|
||||||
|
|
||||||
// Floor a unix-seconds timestamp to the given bucket size.
|
|
||||||
const floorBucket = (t, sec) => Math.floor(t / sec) * sec;
|
|
||||||
|
|
||||||
async function safeJson(file) {
|
|
||||||
try { return JSON.parse(await fs.readFile(file, 'utf8')); } catch (_) { return null; }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build a metrics point from a live /proc sample + the latest host JSON.
|
|
||||||
async function composePoint(t) {
|
|
||||||
const live = await readSample();
|
|
||||||
const hostMetrics = await safeJson(path.join(HOST_JSON_DIR, 'metrics.json'));
|
|
||||||
const disks = Array.isArray(hostMetrics?.disks) ? hostMetrics.disks : [];
|
|
||||||
const rootDisk = disks.find(d => d.mount === '/') || disks[0] || {};
|
|
||||||
const net = hostMetrics?.network || {};
|
|
||||||
return {
|
|
||||||
t,
|
|
||||||
cpu: Number(live?.cpu?.percent) || 0,
|
|
||||||
mem: Number(live?.memory?.percent) || 0,
|
|
||||||
swap: Number(live?.memory?.swap_percent) || 0,
|
|
||||||
disk: Number(rootDisk.percent) || 0,
|
|
||||||
load1: Number(live?.cpu?.load1) || 0,
|
|
||||||
net_rx: Number(net.rx_rate) || 0,
|
|
||||||
net_tx: Number(net.tx_rate) || 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Average the latest 5 1-min points into one 5-min bucket. Keeps the same
|
|
||||||
// shape as composePoint() so it slots straight into ring5.append.
|
|
||||||
function averagePoints(pts, t) {
|
|
||||||
if (!pts.length) return null;
|
|
||||||
const sum = { cpu: 0, mem: 0, swap: 0, disk: 0, load1: 0, net_rx: 0, net_tx: 0 };
|
|
||||||
for (const p of pts) for (const k of Object.keys(sum)) sum[k] += Number(p[k]) || 0;
|
|
||||||
const n = pts.length;
|
|
||||||
return {
|
|
||||||
t,
|
|
||||||
cpu: sum.cpu / n,
|
|
||||||
mem: sum.mem / n,
|
|
||||||
swap: sum.swap / n,
|
|
||||||
disk: sum.disk / n,
|
|
||||||
load1: sum.load1 / n,
|
|
||||||
net_rx: sum.net_rx / n,
|
|
||||||
net_tx: sum.net_tx / n,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Backfill the 1-min ring from the legacy JSON if and only if the ring is
|
|
||||||
// empty. Idempotent; safe to call on every startup.
|
|
||||||
async function backfillFromLegacy() {
|
|
||||||
await ring1.open();
|
|
||||||
if ((await ring1.lastT()) > 0) return false;
|
|
||||||
const j = await safeJson(LEGACY_HIST);
|
|
||||||
const pts = Array.isArray(j?.points) ? j.points : [];
|
|
||||||
if (!pts.length) return false;
|
|
||||||
let last = 0;
|
|
||||||
let appended = 0;
|
|
||||||
for (const p of pts) {
|
|
||||||
const t = floorBucket(Number(p.t) || 0, ONE_MIN);
|
|
||||||
if (t <= last) continue; // points must advance monotonically
|
|
||||||
last = t;
|
|
||||||
await ring1.append({
|
|
||||||
t,
|
|
||||||
cpu: Number(p.cpu) || 0,
|
|
||||||
mem: Number(p.mem) || 0,
|
|
||||||
swap: Number(p.swap) || 0,
|
|
||||||
disk: Number(p.disk) || 0,
|
|
||||||
load1: Number(p.load1) || 0,
|
|
||||||
net_rx: Number(p.net_rx) || 0,
|
|
||||||
net_tx: Number(p.net_tx) || 0,
|
|
||||||
});
|
|
||||||
appended++;
|
|
||||||
}
|
|
||||||
return appended;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read one minute / five minute slice in the format the API returns.
|
|
||||||
async function read(rangeMin, tier) {
|
|
||||||
const r = tier === '5m' ? ring5 : ring1;
|
|
||||||
const pts = await r.readLast(rangeMin);
|
|
||||||
return pts;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Single tick. Fires once per minute (give or take a few ms drift) and writes
|
|
||||||
// at most one 1m point + optionally one 5m point. Idempotent within a bucket
|
|
||||||
// — if the ring's last_t already matches the bucket we're about to write,
|
|
||||||
// skip.
|
|
||||||
async function tick() {
|
|
||||||
try {
|
|
||||||
const now = Math.floor(Date.now() / 1000);
|
|
||||||
const bucket1 = floorBucket(now, ONE_MIN);
|
|
||||||
const last1 = await ring1.lastT();
|
|
||||||
if (bucket1 <= last1) return; // already wrote this minute
|
|
||||||
const point = await composePoint(bucket1);
|
|
||||||
await ring1.append(point);
|
|
||||||
|
|
||||||
const bucket5 = floorBucket(now, FIVE_MIN);
|
|
||||||
const last5 = await ring5.lastT();
|
|
||||||
if (bucket5 > last5 && (now - bucket5) < ONE_MIN * 2) {
|
|
||||||
// We've just crossed a 5-min boundary; average the last 5 1-min
|
|
||||||
// points to form the 5-min point. Window the average to the
|
|
||||||
// 5-min bucket so a long run-up doesn't smear into the new one.
|
|
||||||
const recent = await ring1.readLast(5);
|
|
||||||
const inWindow = recent.filter(p => p.t >= bucket5 && p.t < bucket5 + FIVE_MIN);
|
|
||||||
const avgPts = inWindow.length ? inWindow : recent;
|
|
||||||
const avg = averagePoints(avgPts, bucket5);
|
|
||||||
if (avg) await ring5.append(avg);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
// Swallow — a single failed tick mustn't kill the writer. The next
|
|
||||||
// boundary will retry. Log loudly enough to be findable but not so
|
|
||||||
// loudly that a missing JSON file spams the console.
|
|
||||||
if (process.env.METRICS_DEBUG) console.error('metrics-writer tick:', err.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Public API. Pass in the read functions so we don't double-require system-
|
|
||||||
// routes.js (which owns the shared cpu/mem sampler).
|
|
||||||
function start({ sampleFn, hostJsonFn } = {}) {
|
|
||||||
if (started) return;
|
|
||||||
started = true;
|
|
||||||
readSample = sampleFn;
|
|
||||||
readHostJson = hostJsonFn;
|
|
||||||
// Defer the first real tick to the start of the next minute so the
|
|
||||||
// boundary is clean. In the meantime, kick a backfill in the background.
|
|
||||||
backfillFromLegacy().catch(() => {});
|
|
||||||
const align = () => {
|
|
||||||
const ms = Date.now();
|
|
||||||
const toNextMin = ONE_MIN * 1000 - (ms % (ONE_MIN * 1000));
|
|
||||||
setTimeout(() => {
|
|
||||||
tick();
|
|
||||||
tickHandle = setInterval(tick, ONE_MIN * 1000);
|
|
||||||
}, toNextMin + 200); // tiny offset so the host generator has finished its own bucket
|
|
||||||
};
|
|
||||||
align();
|
|
||||||
}
|
|
||||||
|
|
||||||
function stop() {
|
|
||||||
if (tickHandle) { clearInterval(tickHandle); tickHandle = null; }
|
|
||||||
started = false;
|
|
||||||
Promise.all([ring1.close(), ring5.close()]).catch(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
start, stop, read,
|
|
||||||
// exposed for tests / introspection
|
|
||||||
_ring1: ring1, _ring5: ring5,
|
|
||||||
ONE_MIN_CAP, FIVE_MIN_CAP,
|
|
||||||
};
|
|
||||||
@ -4,15 +4,6 @@ const cookieParser = require('cookie-parser');
|
|||||||
const config = require('./config.js');
|
const config = require('./config.js');
|
||||||
const { verifyToken } = require('./auth.js');
|
const { verifyToken } = require('./auth.js');
|
||||||
|
|
||||||
// compression is a new dependency (added to package.json). The Docker image
|
|
||||||
// bakes node_modules at build time and routes/utils/server.js are bind-mounted
|
|
||||||
// in compose.yml — but node_modules is NOT bind-mounted, so a "quick" deploy
|
|
||||||
// (cp + restart) hits the old image without compression installed. We require
|
|
||||||
// it defensively: present after the next image rebuild → ~70 % wire-size
|
|
||||||
// reduction; absent → degrade silently to the previous uncompressed behaviour.
|
|
||||||
let compression = null;
|
|
||||||
try { compression = require('compression'); } catch (_) {}
|
|
||||||
|
|
||||||
function requireAuth(req, res, next) {
|
function requireAuth(req, res, next) {
|
||||||
const token = req.cookies?.libreportal_token;
|
const token = req.cookies?.libreportal_token;
|
||||||
if (!token) return res.status(401).json({ error: 'Unauthorized' });
|
if (!token) return res.status(401).json({ error: 'Unauthorized' });
|
||||||
@ -29,34 +20,7 @@ function noStore(req, res, next) {
|
|||||||
next();
|
next();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Static-asset options:
|
|
||||||
// - 60s maxAge + ETag on JS/CSS/icons. Long enough that rapid in-session
|
|
||||||
// clicks skip the network round-trip, short enough that a deploy is
|
|
||||||
// visible within a minute. Originally tried 1h but that caused stale
|
|
||||||
// cached JS to reference things the new HTML no longer loaded (the
|
|
||||||
// Phase-B lazy-load refactor changed who loads which script).
|
|
||||||
// - HTML files get Cache-Control: no-cache (always revalidates via ETag,
|
|
||||||
// so new deploys land immediately — the SPA shell changes most often).
|
|
||||||
// - dotfiles='ignore' so .auth.json is never served.
|
|
||||||
const staticOptions = {
|
|
||||||
maxAge: '60s',
|
|
||||||
etag: true,
|
|
||||||
dotfiles: 'ignore',
|
|
||||||
setHeaders: (res, filePath) => {
|
|
||||||
if (filePath.endsWith('.html')) {
|
|
||||||
res.setHeader('Cache-Control', 'no-cache');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
function setup(app) {
|
function setup(app) {
|
||||||
// Gzip-compress responses. JS/CSS/HTML/JSON typically shrink ~70 %, so the
|
|
||||||
// 1.7 MB of static assets the SPA loads on a cold cache drop to ~500 KB on
|
|
||||||
// the wire. compression defaults skip already-compressed types (images,
|
|
||||||
// gzipped tarballs) and small responses (<1 KB). Defensive — no-op if the
|
|
||||||
// module isn't installed (image not yet rebuilt with the new dep).
|
|
||||||
if (compression) app.use(compression());
|
|
||||||
|
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
app.use(cookieParser());
|
app.use(cookieParser());
|
||||||
|
|
||||||
@ -67,13 +31,12 @@ function setup(app) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// /data/* requires auth. express.static doesn't generate directory listings,
|
// /data/* requires auth. express.static doesn't generate directory listings,
|
||||||
// so the only way to read anything is to know an exact path. noStore wins
|
// so the only way to read anything is to know an exact path.
|
||||||
// over staticOptions' maxAge for this prefix — auth-sensitive content
|
|
||||||
// should never be cached.
|
|
||||||
app.use('/data', requireAuth, noStore, express.static(path.join(config.FRONTEND_PATH, 'data')));
|
app.use('/data', requireAuth, noStore, express.static(path.join(config.FRONTEND_PATH, 'data')));
|
||||||
|
|
||||||
// All other static assets (js, css, icons, html partials, index.html) remain public.
|
// All other static assets (js, css, icons, html partials, index.html) remain public.
|
||||||
app.use(express.static(config.FRONTEND_PATH, staticOptions));
|
// dotfiles='ignore' by default so .auth.json is never served.
|
||||||
|
app.use(express.static(config.FRONTEND_PATH));
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { setup, requireAuth };
|
module.exports = { setup, requireAuth };
|
||||||
|
|||||||
@ -21,10 +21,8 @@ services:
|
|||||||
- ./backend/utils:/app/backend/utils
|
- ./backend/utils:/app/backend/utils
|
||||||
- ./backend/server.js:/app/backend/server.js
|
- ./backend/server.js:/app/backend/server.js
|
||||||
- ./libreportal.config:/app/libreportal.config:ro
|
- ./libreportal.config:/app/libreportal.config:ro
|
||||||
# Absolute (filled at generation) — the containers root is now separate from
|
- ../../configs/webui/webui_logins:/app/webui_logins:ro
|
||||||
# the system tree, so the old relative ../../configs no longer reaches it.
|
- ../../configs/webui/webui_logs:/app/webui_logs:ro
|
||||||
- CONFIGS_DIR_DATA/webui/webui_logins:/app/webui_logins:ro #LIBREPORTAL|CONFIGS_DIR_TAG|CONFIGS_DIR_DATA
|
|
||||||
- CONFIGS_DIR_DATA/webui/webui_logs:/app/webui_logs:ro #LIBREPORTAL|CONFIGS_DIR_TAG|CONFIGS_DIR_DATA
|
|
||||||
# >>> crowdsec-host-logs >>>
|
# >>> crowdsec-host-logs >>>
|
||||||
#- /var/log/crowdsec.log:/host/var/log/crowdsec.log:ro
|
#- /var/log/crowdsec.log:/host/var/log/crowdsec.log:ro
|
||||||
#- /var/log/crowdsec-firewall-bouncer.log:/host/var/log/crowdsec-firewall-bouncer.log:ro
|
#- /var/log/crowdsec-firewall-bouncer.log:/host/var/log/crowdsec-firewall-bouncer.log:ro
|
||||||
@ -33,7 +31,6 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
FRONTEND_PATH: /data/frontend
|
FRONTEND_PATH: /data/frontend
|
||||||
LIBREPORTAL_CONFIG_PATH: /app/libreportal.config
|
LIBREPORTAL_CONFIG_PATH: /app/libreportal.config
|
||||||
LP_CONTAINERS_DIR: CONTAINERS_DIR_DATA #LIBREPORTAL|CONTAINERS_DIR_TAG|CONTAINERS_DIR_DATA
|
|
||||||
TZ: TIMEZONE_DATA #LIBREPORTAL|TIMEZONE_TAG|TIMEZONE_DATA
|
TZ: TIMEZONE_DATA #LIBREPORTAL|TIMEZONE_TAG|TIMEZONE_DATA
|
||||||
labels:
|
labels:
|
||||||
libreportal.category: "CATEGORY_DATA" #LIBREPORTAL|CATEGORY_TAG|CATEGORY_DATA
|
libreportal.category: "CATEGORY_DATA" #LIBREPORTAL|CATEGORY_TAG|CATEGORY_DATA
|
||||||
|
|||||||
@ -1,162 +0,0 @@
|
|||||||
// Config Sidebar - Handles sidebar population and navigation
|
|
||||||
class ConfigSidebar {
|
|
||||||
constructor() {
|
|
||||||
this.categoriesList = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
populateSidebar() {
|
|
||||||
|
|
||||||
this.categoriesList = document.getElementById('config-categories-list');
|
|
||||||
if (!this.categoriesList) {
|
|
||||||
console.error('ConfigSidebar: config-categories-list element not found');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!window.configData || !window.configData.categories) {
|
|
||||||
console.error('ConfigSidebar: No config data available for sidebar');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.categoriesList.innerHTML = '';
|
|
||||||
|
|
||||||
// Overview — the Admin landing (an ops/health board, not a config form).
|
|
||||||
const overviewItem = document.createElement('div');
|
|
||||||
overviewItem.className = 'category';
|
|
||||||
overviewItem.setAttribute('data-category', 'overview');
|
|
||||||
overviewItem.innerHTML = '<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" style="margin-right:8px;vertical-align:middle"><rect x="3" y="3" width="7" height="9"></rect><rect x="14" y="3" width="7" height="5"></rect><rect x="14" y="12" width="7" height="9"></rect><rect x="3" y="16" width="7" height="5"></rect></svg> Dashboard';
|
|
||||||
overviewItem.addEventListener('click', function () {
|
|
||||||
window.history.pushState({}, '', window.adminPath('overview'));
|
|
||||||
document.querySelectorAll('.category').forEach(function (item) { item.classList.remove('active'); });
|
|
||||||
this.classList.add('active');
|
|
||||||
window.configCategory = 'overview';
|
|
||||||
if (window.configManager && typeof window.configManager.renderConfig === 'function') {
|
|
||||||
window.configManager.renderConfig('overview');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
this.categoriesList.appendChild(overviewItem);
|
|
||||||
|
|
||||||
// System sits right under Overview — both are admin-landing surfaces
|
|
||||||
// (Overview = ops/health summary, System = live host + per-app stats),
|
|
||||||
// distinct from the config form pages or the Tools utilities below.
|
|
||||||
const systemItem = document.createElement('div');
|
|
||||||
systemItem.className = 'category';
|
|
||||||
systemItem.setAttribute('data-category', 'system');
|
|
||||||
systemItem.innerHTML = '<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" style="margin-right:8px;vertical-align:middle"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"></polyline></svg> System';
|
|
||||||
systemItem.addEventListener('click', function () {
|
|
||||||
window.history.pushState({}, '', window.adminPath('system'));
|
|
||||||
document.querySelectorAll('.category').forEach(function (item) { item.classList.remove('active'); });
|
|
||||||
this.classList.add('active');
|
|
||||||
window.configCategory = 'system';
|
|
||||||
if (window.configManager && typeof window.configManager.renderConfig === 'function') {
|
|
||||||
window.configManager.renderConfig('system');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
this.categoriesList.appendChild(systemItem);
|
|
||||||
|
|
||||||
// "Config" group heading above the configuration categories (mirrors the
|
|
||||||
// "Tools" heading below).
|
|
||||||
const configLabel = document.createElement('div');
|
|
||||||
configLabel.className = 'sidebar-group-label';
|
|
||||||
configLabel.textContent = 'Config';
|
|
||||||
this.categoriesList.appendChild(configLabel);
|
|
||||||
|
|
||||||
// Convert categories object to array and sort by ORDER
|
|
||||||
const categoriesArray = Object.entries(window.configData.categories).map(([key, value]) => ({
|
|
||||||
id: key,
|
|
||||||
...value
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Sort by ORDER if available, otherwise by title
|
|
||||||
categoriesArray.sort(function(a, b) {
|
|
||||||
const orderA = parseInt(a.order) || 999;
|
|
||||||
const orderB = parseInt(b.order) || 999;
|
|
||||||
return orderA - orderB;
|
|
||||||
});
|
|
||||||
|
|
||||||
var self = this; // Preserve 'this' context
|
|
||||||
|
|
||||||
categoriesArray.forEach(function(category) {
|
|
||||||
// Backup config (engine/schedule/retention) now lives in the Backups tab's
|
|
||||||
// embedded center (Overview › Backups › Configuration), so it's hidden from
|
|
||||||
// the Admin config sidebar to avoid a second surface for the same data.
|
|
||||||
if (category.id === 'backup') return;
|
|
||||||
|
|
||||||
const categoryItem = document.createElement('div');
|
|
||||||
categoryItem.className = 'category';
|
|
||||||
categoryItem.setAttribute('data-category', category.id);
|
|
||||||
|
|
||||||
// Use correct icon from our new structure
|
|
||||||
const iconName = category.icon || category.id;
|
|
||||||
const iconPath = '/components/admin/config/icons/' + iconName + '.svg';
|
|
||||||
|
|
||||||
categoryItem.innerHTML = '<img src="' + iconPath + '" alt="' + category.title + '" style="width: 20px; height: 20px; margin-right: 8px;"/> ' + category.title;
|
|
||||||
|
|
||||||
categoryItem.addEventListener('click', function() {
|
|
||||||
// Update URL without full page reload
|
|
||||||
window.history.pushState({}, '', window.adminPath(category.id));
|
|
||||||
|
|
||||||
// Update active state
|
|
||||||
document.querySelectorAll('.category').forEach(function(item) {
|
|
||||||
item.classList.remove('active');
|
|
||||||
});
|
|
||||||
this.classList.add('active');
|
|
||||||
|
|
||||||
// Update global category and load dynamically
|
|
||||||
window.configCategory = category.id;
|
|
||||||
|
|
||||||
// Load config dynamically without page refresh
|
|
||||||
if (window.configManager && typeof window.configManager.renderConfig === 'function') {
|
|
||||||
window.configManager.renderConfig(category.id);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
self.categoriesList.appendChild(categoryItem);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Tools group — admin pages that live in this area but aren't config
|
|
||||||
// categories (rendered by their own controller, not the config form).
|
|
||||||
const toolsLabel = document.createElement('div');
|
|
||||||
toolsLabel.className = 'sidebar-group-label';
|
|
||||||
toolsLabel.textContent = 'Tools';
|
|
||||||
self.categoriesList.appendChild(toolsLabel);
|
|
||||||
|
|
||||||
const sshItem = document.createElement('div');
|
|
||||||
sshItem.className = 'category';
|
|
||||||
sshItem.setAttribute('data-category', 'ssh-access');
|
|
||||||
// Inline key icon (currentColor so it follows the theme — security.svg
|
|
||||||
// hardcodes a fixed blue stroke and so visually goes missing on certain
|
|
||||||
// themes; the other Tools/admin items all use inline SVGs).
|
|
||||||
sshItem.innerHTML = '<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:8px;vertical-align:middle"><circle cx="7.5" cy="15.5" r="5.5"></circle><line x1="11" y1="12" x2="21" y2="2"></line><line x1="17" y1="6" x2="20" y2="9"></line><line x1="14" y1="9" x2="17" y2="12"></line></svg> SSH Access';
|
|
||||||
sshItem.addEventListener('click', function () {
|
|
||||||
window.history.pushState({}, '', window.adminPath('ssh-access'));
|
|
||||||
document.querySelectorAll('.category').forEach(function (item) { item.classList.remove('active'); });
|
|
||||||
this.classList.add('active');
|
|
||||||
window.configCategory = 'ssh-access';
|
|
||||||
if (window.configManager && typeof window.configManager.renderConfig === 'function') {
|
|
||||||
window.configManager.renderConfig('ssh-access');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
self.categoriesList.appendChild(sshItem);
|
|
||||||
|
|
||||||
// Peers moved out of Admin → Overview › Migrate › Peers (it pairs with the
|
|
||||||
// cross-host Restore there). /admin/tools/peers + /peers redirect there.
|
|
||||||
|
|
||||||
// Set initial active category
|
|
||||||
this.setActiveCategory(window.configCategory || 'overview');
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
setActiveCategory(categoryId) {
|
|
||||||
// Update active state
|
|
||||||
document.querySelectorAll('.category').forEach(function(item) {
|
|
||||||
item.classList.remove('active');
|
|
||||||
});
|
|
||||||
var activeItem = document.querySelector('[data-category="' + categoryId + '"]');
|
|
||||||
if (activeItem) {
|
|
||||||
activeItem.classList.add('active');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Export to global scope
|
|
||||||
window.ConfigSidebar = ConfigSidebar;
|
|
||||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user