2026-03-04 05:23:42 +00:00
use super ::RpcHandler ;
2026-02-17 15:03:34 +00:00
use crate ::port_allocator ::PortAllocator ;
2026-01-24 22:59:20 +00:00
use anyhow ::{ Context , Result } ;
feat: cloud native file browser, settings Claude auth, deploy hardening
- Add native Cloud file browser with FileBrowser API integration
- Add cloud store, filebrowser-client, useAudioPlayer, useFileType composables
- Add Cloud components: FileGrid, FileCard, FileCardGrid, CloudToolbar
- Add Claude authentication section to Settings with OAuth status check
- Harden deploy script to preserve /aiui/ and claude-login.html
- Add nginx proxies for btcpay, homeassistant, filebrowser (HTTPS block)
- Add app configs for filebrowser, searxng, penpot in package.rs
- Update goal progress tracking with app aliases
- Improve mobile back button composable with ResizeObserver
- Update various views with cloud integration and UI refinements
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 23:05:01 +00:00
use tracing ::{ debug , info } ;
2026-01-24 22:59:20 +00:00
impl RpcHandler {
2026-02-01 18:46:35 +00:00
/// Install a package from a Docker image
/// Security: Image verification, resource limits, network isolation
2026-03-04 05:23:42 +00:00
pub ( super ) async fn handle_package_install (
2026-02-01 18:46:35 +00:00
& self ,
params : Option < serde_json ::Value > ,
) -> Result < serde_json ::Value > {
let params = params . ok_or_else ( | | anyhow ::anyhow! ( " Missing params " ) ) ? ;
2026-03-04 05:23:42 +00:00
2026-02-01 18:46:35 +00:00
let package_id = params
. get ( " id " )
. and_then ( | v | v . as_str ( ) )
. ok_or_else ( | | anyhow ::anyhow! ( " Missing package id " ) ) ? ;
2026-03-06 03:26:56 +00:00
validate_app_id ( package_id ) ? ;
2026-03-04 05:23:42 +00:00
2026-02-01 18:46:35 +00:00
let docker_image = params
. get ( " dockerImage " )
. and_then ( | v | v . as_str ( ) )
. ok_or_else ( | | anyhow ::anyhow! ( " Missing dockerImage " ) ) ? ;
debug! ( " Installing package {} from image {} " , package_id , docker_image ) ;
// Security: Validate image name format (prevent injection)
if ! is_valid_docker_image ( docker_image ) {
return Err ( anyhow ::anyhow! ( " Invalid Docker image format " ) ) ;
}
2026-03-01 17:53:18 +00:00
// Virtual app: Indeehub (no container, opens external URL)
if package_id = = " indeedhub " {
return Ok ( serde_json ::json! ( { " success " : true } ) ) ;
}
2026-02-25 18:04:41 +00:00
// Multi-container apps: create full stack
if package_id = = " immich " {
return self . install_immich_stack ( ) . await ;
}
if package_id = = " penpot " | | package_id = = " penpot-frontend " {
return self . install_penpot_stack ( ) . await ;
}
2026-03-08 02:16:02 +00:00
// Dependency check: electrs requires a Bitcoin node
if package_id = = " mempool-electrs " | | package_id = = " electrs " {
let btc_check = tokio ::process ::Command ::new ( " sudo " )
. args ( [ " podman " , " ps " , " --format " , " {{.Names}} " ] )
. output ( )
. await
. context ( " Failed to check running containers " ) ? ;
let running = String ::from_utf8_lossy ( & btc_check . stdout ) ;
let has_bitcoin = running . lines ( ) . any ( | l | {
let name = l . trim ( ) ;
name = = " bitcoin-knots " | | name = = " bitcoin-core " | | name = = " bitcoin "
} ) ;
if ! has_bitcoin {
return Err ( anyhow ::anyhow! (
" Electrs requires a running Bitcoin node (Bitcoin Knots or Bitcoin Core). Please install and start Bitcoin Knots first. "
) ) ;
}
}
2026-02-01 18:46:35 +00:00
// Check if container already exists
let check_output = tokio ::process ::Command ::new ( " sudo " )
. args ( [ " podman " , " ps " , " -a " , " --format " , " {{.Names}} " , " --filter " , & format! ( " name=^ {} $ " , package_id ) ] )
. output ( )
. await
. context ( " Failed to check existing containers " ) ? ;
if ! String ::from_utf8_lossy ( & check_output . stdout ) . trim ( ) . is_empty ( ) {
return Err ( anyhow ::anyhow! ( " Container {} already exists. Stop and remove it first. " , package_id ) ) ;
}
2026-03-01 17:53:18 +00:00
// Pull the image (skip for local images - must be built locally first)
let is_local_image = docker_image . starts_with ( " localhost/ " ) ;
if ! is_local_image {
debug! ( " Pulling image: {} " , docker_image ) ;
let pull_output = tokio ::process ::Command ::new ( " sudo " )
. args ( [ " podman " , " pull " , docker_image ] )
. output ( )
. await
. context ( " Failed to pull image " ) ? ;
2026-02-01 18:46:35 +00:00
2026-03-01 17:53:18 +00:00
if ! pull_output . status . success ( ) {
let stderr = String ::from_utf8_lossy ( & pull_output . stderr ) ;
return Err ( anyhow ::anyhow! ( " Failed to pull image: {} " , stderr ) ) ;
}
} else {
// Verify local image exists
let images_output = tokio ::process ::Command ::new ( " sudo " )
. args ( [ " podman " , " images " , " -q " , docker_image ] )
. output ( )
. await
. context ( " Failed to check local image " ) ? ;
if String ::from_utf8_lossy ( & images_output . stdout ) . trim ( ) . is_empty ( ) {
return Err ( anyhow ::anyhow! (
" Local image {} not found. Run ./deploy-to-archipelago.sh from the Indeehub Prototype project on your Mac—it builds the image on this server and starts the app. " ,
docker_image
) ) ;
}
debug! ( " Using local image: {} " , docker_image ) ;
2026-02-01 18:46:35 +00:00
}
2026-03-08 02:16:02 +00:00
// Normalize container name: "electrs" alias -> "mempool-electrs"
let container_name = if package_id = = " electrs " { " mempool-electrs " } else { package_id } ;
2026-02-01 18:46:35 +00:00
// Create and start container with security constraints
let mut run_args = vec! [
" podman " , " run " ,
" -d " , // Detached
2026-03-08 02:16:02 +00:00
" --name " , container_name ,
2026-02-01 18:46:35 +00:00
" --restart=unless-stopped " , // Auto-restart policy
] ;
// App-specific configuration (should come from manifest)
2026-02-17 15:03:34 +00:00
let ( ports , volumes , env_vars , custom_command , custom_args ) = {
let mut allocator = self . port_allocator . lock ( ) . map_err ( | e | {
anyhow ::anyhow! ( " Port allocator lock poisoned: {} " , e )
} ) ? ;
get_app_config ( package_id , & self . config . host_ip , & mut allocator )
} ;
2026-03-04 05:23:42 +00:00
2026-02-17 15:03:34 +00:00
// Special handling: Tailscale needs host network; mempool stack needs archy-net
2026-02-01 18:46:35 +00:00
let is_tailscale = package_id = = " tailscale " ;
2026-02-17 15:03:34 +00:00
let needs_archy_net = matches! (
package_id ,
2026-02-25 18:04:41 +00:00
" bitcoin-knots " | " bitcoin " | " bitcoin-core "
2026-03-08 02:16:02 +00:00
| " mempool " | " mempool-web " | " mempool-api " | " mempool-electrs " | " electrs " | " mysql-mempool " | " archy-mempool-db " | " archy-mempool-web "
2026-02-17 15:03:34 +00:00
| " btcpay-server " | " btcpayserver " | " archy-btcpay-db "
) ;
2026-02-01 18:46:35 +00:00
if is_tailscale {
run_args . push ( " --network=host " ) ;
run_args . push ( " --privileged " ) ;
run_args . push ( " --cap-add=NET_ADMIN " ) ;
run_args . push ( " --cap-add=NET_RAW " ) ;
run_args . push ( " --device=/dev/net/tun " ) ;
2026-02-17 15:03:34 +00:00
} else if needs_archy_net {
let _ = tokio ::process ::Command ::new ( " sudo " )
. args ( [ " podman " , " network " , " create " , " archy-net " ] )
. output ( )
. await ;
run_args . push ( " --network=archy-net " ) ;
2026-02-01 18:46:35 +00:00
}
2026-03-04 05:23:42 +00:00
2026-03-05 08:24:56 +00:00
// Security hardening (skip for privileged containers like Tailscale)
let security_caps : Vec < String > = if ! is_tailscale {
get_app_capabilities ( package_id )
} else {
vec! [ ]
} ;
let readonly_compatible = ! is_tailscale & & is_readonly_compatible ( package_id ) ;
if ! is_tailscale {
run_args . push ( " --cap-drop=ALL " ) ;
run_args . push ( " --security-opt=no-new-privileges:true " ) ;
for cap in & security_caps {
run_args . push ( cap ) ;
}
if readonly_compatible {
run_args . push ( " --read-only " ) ;
run_args . push ( " --tmpfs=/tmp:rw,noexec,nosuid,size=256m " ) ;
run_args . push ( " --tmpfs=/run:rw,noexec,nosuid,size=64m " ) ;
}
}
2026-02-01 18:46:35 +00:00
// Create data directories if they don't exist
for volume in & volumes {
if let Some ( host_path ) = volume . split ( ':' ) . next ( ) {
if host_path . starts_with ( " /var/lib/archipelago/ " ) {
debug! ( " Creating directory: {} " , host_path ) ;
let create_dir = tokio ::process ::Command ::new ( " sudo " )
. args ( [ " mkdir " , " -p " , host_path ] )
. output ( )
. await ;
2026-03-04 05:23:42 +00:00
2026-02-01 18:46:35 +00:00
if let Err ( e ) = create_dir {
debug! ( " Failed to create directory {}: {} " , host_path , e ) ;
}
2026-02-25 18:04:41 +00:00
// Grafana runs as UID 472 - fix permissions so it can write
if package_id = = " grafana " & & host_path . contains ( " grafana " ) {
let _ = tokio ::process ::Command ::new ( " sudo " )
. args ( [ " chown " , " -R " , " 472:472 " , host_path ] )
. output ( )
. await ;
}
2026-02-01 18:46:35 +00:00
}
}
}
2026-03-04 05:23:42 +00:00
2026-03-08 02:16:02 +00:00
// Pre-install: Create bitcoin.conf for Bitcoin nodes with RPC + txindex
if matches! ( package_id , " bitcoin " | " bitcoin-core " | " bitcoin-knots " ) {
let bitcoin_dir = " /var/lib/archipelago/bitcoin " ;
let conf_path = format! ( " {} /bitcoin.conf " , bitcoin_dir ) ;
let bitcoin_conf = " \
server = 1 \ n \
txindex = 1 \ n \
rpcuser = archipelago \ n \
rpcpassword = archipelago123 \ n \
rpcbind = 0. 0. 0.0 \ n \
rpcallowip = 0. 0. 0.0 / 0 \ n \
rpcport = 8332 \ n \
listen = 1 \ n \
printtoconsole = 1 \ n " ;
let _ = tokio ::process ::Command ::new ( " sudo " )
. args ( [ " sh " , " -c " , & format! ( " echo ' {} ' > {} " , bitcoin_conf , conf_path ) ] )
. output ( )
. await ;
info! ( " Created bitcoin.conf at {} with RPC + txindex enabled " , conf_path ) ;
}
2026-02-01 18:46:35 +00:00
// Add port mappings (skip if host network mode like Tailscale)
if ! is_tailscale {
for port in & ports {
run_args . push ( " -p " ) ;
run_args . push ( port ) ;
}
}
2026-03-04 05:23:42 +00:00
2026-02-01 18:46:35 +00:00
// Add volume mounts
for volume in & volumes {
run_args . push ( " -v " ) ;
run_args . push ( volume ) ;
}
2026-03-04 05:23:42 +00:00
2026-02-01 18:46:35 +00:00
// Add environment variables
for env in & env_vars {
run_args . push ( " -e " ) ;
run_args . push ( env ) ;
}
2026-03-04 05:23:42 +00:00
2026-02-01 18:46:35 +00:00
// Security: Resource limits (from manifest)
2026-02-25 18:04:41 +00:00
let memory_limit = if package_id = = " ollama " { " 4g " } else { " 2g " } ;
let mem_arg = format! ( " --memory= {} " , memory_limit ) ;
run_args . push ( & mem_arg ) ;
run_args . push ( " --cpus=2 " ) ;
2026-03-04 05:23:42 +00:00
2026-02-01 18:46:35 +00:00
// Finally, the image
run_args . push ( docker_image ) ;
debug! ( " Running container with args: {:?} " , run_args ) ;
2026-03-04 05:23:42 +00:00
2026-02-01 18:46:35 +00:00
// Build command with optional custom command
let mut cmd = tokio ::process ::Command ::new ( " sudo " ) ;
cmd . args ( & run_args ) ;
2026-03-04 05:23:42 +00:00
// Add custom command/args if specified
2026-02-01 18:46:35 +00:00
if let Some ( custom_cmd ) = custom_command {
cmd . arg ( custom_cmd ) ;
2026-02-17 15:03:34 +00:00
} else if let Some ( args ) = custom_args {
cmd . args ( args ) ;
2026-02-01 18:46:35 +00:00
}
2026-03-04 05:23:42 +00:00
2026-02-01 18:46:35 +00:00
let run_output = cmd
. output ( )
. await
. context ( " Failed to run container " ) ? ;
if ! run_output . status . success ( ) {
let stderr = String ::from_utf8_lossy ( & run_output . stderr ) ;
return Err ( anyhow ::anyhow! ( " Failed to start container: {} " , stderr ) ) ;
}
let container_id = String ::from_utf8_lossy ( & run_output . stdout ) . trim ( ) . to_string ( ) ;
2026-03-04 05:23:42 +00:00
feat: cloud native file browser, settings Claude auth, deploy hardening
- Add native Cloud file browser with FileBrowser API integration
- Add cloud store, filebrowser-client, useAudioPlayer, useFileType composables
- Add Cloud components: FileGrid, FileCard, FileCardGrid, CloudToolbar
- Add Claude authentication section to Settings with OAuth status check
- Harden deploy script to preserve /aiui/ and claude-login.html
- Add nginx proxies for btcpay, homeassistant, filebrowser (HTTPS block)
- Add app configs for filebrowser, searxng, penpot in package.rs
- Update goal progress tracking with app aliases
- Improve mobile back button composable with ResizeObserver
- Update various views with cloud integration and UI refinements
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 23:05:01 +00:00
// Post-install: Nextcloud needs trusted domains configured for iframe embedding
if package_id = = " nextcloud " {
let host_ip = self . config . host_ip . clone ( ) ;
tokio ::spawn ( async move {
// Wait for Nextcloud to finish first-run initialization
tokio ::time ::sleep ( std ::time ::Duration ::from_secs ( 30 ) ) . await ;
for domain_idx in 1 ..= 2 u8 {
let value = if domain_idx = = 1 { host_ip . as_str ( ) } else { " localhost " } ;
let _ = tokio ::process ::Command ::new ( " sudo " )
. args ( [
" podman " , " exec " , " -u " , " 33 " , " nextcloud " ,
" php " , " occ " , " config:system:set " ,
" trusted_domains " , & domain_idx . to_string ( ) ,
" --value " , value ,
] )
. output ( )
. await ;
}
info! ( " Nextcloud trusted domains configured for {} " , host_ip ) ;
} ) ;
}
2026-03-08 02:16:02 +00:00
// Post-install: Start electrs-ui container for electrs
if matches! ( package_id , " mempool-electrs " | " electrs " ) {
tokio ::spawn ( async move {
// Build and start electrs-ui with host networking so it can reach backend on 127.0.0.1:5678
let ui_dir = " /opt/archipelago/docker/electrs-ui " ;
let _ = tokio ::process ::Command ::new ( " sudo " )
. args ( [ " podman " , " build " , " -t " , " localhost/electrs-ui " , ui_dir ] )
. output ( )
. await ;
// Remove old UI container if it exists
let _ = tokio ::process ::Command ::new ( " sudo " )
. args ( [ " podman " , " rm " , " -f " , " electrs-ui " ] )
. output ( )
. await ;
let _ = tokio ::process ::Command ::new ( " sudo " )
. args ( [
" podman " , " run " , " -d " , " --name " , " electrs-ui " ,
" --restart=unless-stopped " , " --network=host " ,
" localhost/electrs-ui " ,
] )
. output ( )
. await ;
info! ( " Electrs UI container started on port 50002 " ) ;
} ) ;
}
2026-02-01 18:46:35 +00:00
Ok ( serde_json ::json! ( {
" success " : true ,
" package_id " : package_id ,
" container_id " : container_id ,
" message " : format ! ( " Package {} installed and started " , package_id )
} ) )
}
2026-02-25 18:04:41 +00:00
/// Install Immich stack (postgres + redis + server)
async fn install_immich_stack ( & self ) -> Result < serde_json ::Value > {
let check = tokio ::process ::Command ::new ( " sudo " )
. args ( [ " podman " , " ps " , " -a " , " --format " , " {{.Names}} " ] )
. output ( )
. await
. context ( " Failed to list containers " ) ? ;
let stdout = String ::from_utf8_lossy ( & check . stdout ) ;
if stdout . contains ( " immich_server " ) {
return Err ( anyhow ::anyhow! ( " Immich already installed. Stop and remove it first. " ) ) ;
}
2026-02-25 18:20:50 +00:00
if stdout . contains ( " immich \n " ) | | stdout . lines ( ) . any ( | l | l . trim ( ) = = " immich " ) {
let _ = tokio ::process ::Command ::new ( " sudo " )
. args ( [ " podman " , " stop " , " immich " ] )
. output ( )
. await ;
let _ = tokio ::process ::Command ::new ( " sudo " )
. args ( [ " podman " , " rm " , " -f " , " immich " ] )
. output ( )
. await ;
}
2026-02-25 18:04:41 +00:00
let images = [
" ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0 " ,
" docker.io/valkey/valkey:7-alpine " ,
" ghcr.io/immich-app/immich-server:release " ,
] ;
for img in & images {
let _ = tokio ::process ::Command ::new ( " sudo " )
. args ( [ " podman " , " pull " , img ] )
. output ( )
. await ;
}
let _ = tokio ::process ::Command ::new ( " sudo " )
. args ( [ " mkdir " , " -p " , " /var/lib/archipelago/immich " , " /var/lib/archipelago/immich-db " ] )
. output ( )
. await ;
let _ = tokio ::process ::Command ::new ( " sudo " )
. args ( [ " podman " , " network " , " create " , " immich-net " ] )
. output ( )
. await ;
let _ = tokio ::process ::Command ::new ( " sudo " )
. args ( [
" podman " , " run " , " -d " , " --name " , " immich_postgres " , " --restart " , " unless-stopped " ,
" --network " , " immich-net " ,
" -v " , " /var/lib/archipelago/immich-db:/var/lib/postgresql/data " ,
" -e " , " POSTGRES_PASSWORD=immichpass " , " -e " , " POSTGRES_USER=postgres " , " -e " , " POSTGRES_DB=immich " ,
" ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0 " ,
] )
. output ( )
. await ;
tokio ::time ::sleep ( std ::time ::Duration ::from_secs ( 5 ) ) . await ;
let _ = tokio ::process ::Command ::new ( " sudo " )
. args ( [
" podman " , " run " , " -d " , " --name " , " immich_redis " , " --restart " , " unless-stopped " ,
" --network " , " immich-net " ,
" docker.io/valkey/valkey:7-alpine " ,
] )
. output ( )
. await ;
tokio ::time ::sleep ( std ::time ::Duration ::from_secs ( 2 ) ) . await ;
let run = tokio ::process ::Command ::new ( " sudo " )
. args ( [
" podman " , " run " , " -d " , " --name " , " immich_server " , " --restart " , " unless-stopped " ,
" --network " , " immich-net " , " -p " , " 2283:2283 " ,
" -v " , " /var/lib/archipelago/immich:/usr/src/app/upload " ,
" -e " , " DB_HOSTNAME=immich_postgres " , " -e " , " DB_USERNAME=postgres " ,
" -e " , " DB_PASSWORD=immichpass " , " -e " , " DB_DATABASE_NAME=immich " ,
" -e " , " REDIS_HOSTNAME=immich_redis " , " -e " , " UPLOAD_LOCATION=/usr/src/app/upload " ,
" ghcr.io/immich-app/immich-server:release " ,
] )
. output ( )
. await
. context ( " Failed to start immich_server " ) ? ;
if ! run . status . success ( ) {
let stderr = String ::from_utf8_lossy ( & run . stderr ) ;
return Err ( anyhow ::anyhow! ( " Failed to start Immich server: {} " , stderr ) ) ;
}
Ok ( serde_json ::json! ( {
" success " : true ,
" package_id " : " immich " ,
" message " : " Immich stack installed and started "
} ) )
}
/// Install Penpot stack (postgres + valkey + backend + exporter + frontend)
async fn install_penpot_stack ( & self ) -> Result < serde_json ::Value > {
let check = tokio ::process ::Command ::new ( " sudo " )
. args ( [ " podman " , " ps " , " -a " , " --format " , " {{.Names}} " ] )
. output ( )
. await
. context ( " Failed to list containers " ) ? ;
let stdout = String ::from_utf8_lossy ( & check . stdout ) ;
if stdout . contains ( " penpot-frontend " ) {
return Err ( anyhow ::anyhow! ( " Penpot already installed. Stop and remove it first. " ) ) ;
}
let images = [
" docker.io/postgres:15 " ,
" docker.io/valkey/valkey:8.1 " ,
" docker.io/penpotapp/backend:latest " ,
" docker.io/penpotapp/exporter:latest " ,
" docker.io/penpotapp/frontend:latest " ,
] ;
for img in & images {
let _ = tokio ::process ::Command ::new ( " sudo " )
. args ( [ " podman " , " pull " , img ] )
. output ( )
. await ;
}
let _ = tokio ::process ::Command ::new ( " sudo " )
. args ( [ " mkdir " , " -p " , " /var/lib/archipelago/penpot-assets " ] )
. output ( )
. await ;
let _ = tokio ::process ::Command ::new ( " sudo " )
. args ( [ " podman " , " network " , " create " , " penpot-net " ] )
. output ( )
. await ;
let secret = " archipelago-penpot-secret-key-change-in-production " ;
let host_ip = & self . config . host_ip ;
let _ = tokio ::process ::Command ::new ( " sudo " )
. args ( [
" podman " , " run " , " -d " , " --name " , " penpot-postgres " , " --restart " , " unless-stopped " ,
" --network " , " penpot-net " ,
" -v " , " /var/lib/archipelago/penpot-postgres:/var/lib/postgresql/data " ,
" -e " , " POSTGRES_DB=penpot " , " -e " , " POSTGRES_USER=penpot " , " -e " , " POSTGRES_PASSWORD=penpot " ,
" docker.io/postgres:15 " ,
] )
. output ( )
. await ;
tokio ::time ::sleep ( std ::time ::Duration ::from_secs ( 5 ) ) . await ;
let _ = tokio ::process ::Command ::new ( " sudo " )
. args ( [
" podman " , " run " , " -d " , " --name " , " penpot-valkey " , " --restart " , " unless-stopped " ,
" --network " , " penpot-net " ,
" -e " , " VALKEY_EXTRA_FLAGS=--maxmemory 128mb --maxmemory-policy volatile-lfu " ,
" docker.io/valkey/valkey:8.1 " ,
] )
. output ( )
. await ;
tokio ::time ::sleep ( std ::time ::Duration ::from_secs ( 3 ) ) . await ;
let _ = tokio ::process ::Command ::new ( " sudo " )
. args ( [
" podman " , " run " , " -d " , " --name " , " penpot-backend " , " --restart " , " unless-stopped " ,
" --network " , " penpot-net " ,
" -v " , " /var/lib/archipelago/penpot-assets:/opt/data/assets " ,
" -e " , & format! ( " PENPOT_PUBLIC_URI=http:// {} :9001 " , host_ip ) ,
" -e " , & format! ( " PENPOT_SECRET_KEY= {} " , secret ) ,
" -e " , " PENPOT_DATABASE_URI=postgresql://penpot-postgres/penpot " ,
" -e " , " PENPOT_DATABASE_USERNAME=penpot " , " -e " , " PENPOT_DATABASE_PASSWORD=penpot " ,
" -e " , " PENPOT_REDIS_URI=redis://penpot-valkey/0 " ,
" -e " , " PENPOT_OBJECTS_STORAGE_BACKEND=fs " ,
" -e " , " PENPOT_OBJECTS_STORAGE_FS_DIRECTORY=/opt/data/assets " ,
" -e " , " PENPOT_FLAGS=disable-email-verification enable-smtp enable-prepl-server disable-secure-session-cookies " ,
" docker.io/penpotapp/backend:latest " ,
] )
. output ( )
. await ;
tokio ::time ::sleep ( std ::time ::Duration ::from_secs ( 5 ) ) . await ;
let _ = tokio ::process ::Command ::new ( " sudo " )
. args ( [
" podman " , " run " , " -d " , " --name " , " penpot-exporter " , " --restart " , " unless-stopped " ,
" --network " , " penpot-net " ,
" -e " , & format! ( " PENPOT_SECRET_KEY= {} " , secret ) ,
" -e " , " PENPOT_PUBLIC_URI=http://penpot-frontend:8080 " ,
" -e " , " PENPOT_REDIS_URI=redis://penpot-valkey/0 " ,
" docker.io/penpotapp/exporter:latest " ,
] )
. output ( )
. await ;
tokio ::time ::sleep ( std ::time ::Duration ::from_secs ( 2 ) ) . await ;
let run = tokio ::process ::Command ::new ( " sudo " )
. args ( [
" podman " , " run " , " -d " , " --name " , " penpot-frontend " , " --restart " , " unless-stopped " ,
" --network " , " penpot-net " , " -p " , " 9001:8080 " ,
" -v " , " /var/lib/archipelago/penpot-assets:/opt/data/assets " ,
" -e " , & format! ( " PENPOT_PUBLIC_URI=http:// {} :9001 " , host_ip ) ,
" -e " , " PENPOT_FLAGS=disable-email-verification enable-smtp enable-prepl-server disable-secure-session-cookies " ,
" docker.io/penpotapp/frontend:latest " ,
] )
. output ( )
. await
. context ( " Failed to start penpot-frontend " ) ? ;
if ! run . status . success ( ) {
let stderr = String ::from_utf8_lossy ( & run . stderr ) ;
return Err ( anyhow ::anyhow! ( " Failed to start Penpot frontend: {} " , stderr ) ) ;
}
Ok ( serde_json ::json! ( {
" success " : true ,
" package_id " : " penpot " ,
" message " : " Penpot stack installed and started "
} ) )
}
2026-03-04 05:23:42 +00:00
pub ( super ) async fn handle_package_start (
2026-01-27 23:21:26 +00:00
& self ,
params : Option < serde_json ::Value > ,
) -> Result < serde_json ::Value > {
let params = params . ok_or_else ( | | anyhow ::anyhow! ( " Missing params " ) ) ? ;
let package_id = params
. get ( " id " )
. and_then ( | v | v . as_str ( ) )
. ok_or_else ( | | anyhow ::anyhow! ( " Missing package id " ) ) ? ;
2026-02-17 15:03:34 +00:00
let containers = get_containers_for_app ( package_id ) . await ? ;
let to_start : Vec < String > = if containers . is_empty ( ) {
vec! [ format! ( " archy- {} " , package_id ) ]
2026-02-01 13:24:03 +00:00
} else {
2026-02-25 18:04:41 +00:00
let order : & [ & str ] = match package_id {
" mempool " | " mempool-web " = > & [ " archy-mempool-db " , " mysql-mempool " , " mempool-electrs " , " mempool-api " , " archy-mempool-api " , " archy-mempool-web " , " mempool " ] ,
" immich " = > & [ " immich_postgres " , " immich_redis " , " immich_server " ] ,
" penpot " | " penpot-frontend " = > & [ " penpot-postgres " , " penpot-valkey " , " penpot-backend " , " penpot-exporter " , " penpot-frontend " ] ,
_ = > & [ " archy-mempool-db " , " mysql-mempool " , " mempool-electrs " , " mempool-api " , " archy-mempool-api " , " archy-mempool-web " , " mempool " ] ,
} ;
2026-02-17 15:03:34 +00:00
let mut sorted = containers ;
sorted . sort_by_key ( | c | order . iter ( ) . position ( | o | * o = = c ) . unwrap_or ( 99 ) ) ;
sorted
2026-02-01 13:24:03 +00:00
} ;
2026-01-27 23:21:26 +00:00
2026-02-17 15:03:34 +00:00
for name in to_start {
let _ = tokio ::process ::Command ::new ( " sudo " )
. args ( [ " podman " , " start " , & name ] )
. output ( )
. await ;
2026-01-27 23:21:26 +00:00
}
Ok ( serde_json ::Value ::Null )
}
2026-03-04 05:23:42 +00:00
pub ( super ) async fn handle_package_stop (
2026-01-27 23:21:26 +00:00
& self ,
params : Option < serde_json ::Value > ,
) -> Result < serde_json ::Value > {
let params = params . ok_or_else ( | | anyhow ::anyhow! ( " Missing params " ) ) ? ;
let package_id = params
. get ( " id " )
. and_then ( | v | v . as_str ( ) )
. ok_or_else ( | | anyhow ::anyhow! ( " Missing package id " ) ) ? ;
2026-02-17 15:03:34 +00:00
let containers = get_containers_for_app ( package_id ) . await ? ;
if containers . is_empty ( ) {
let container_name = format! ( " archy- {} " , package_id ) ;
let _ = tokio ::process ::Command ::new ( " sudo " )
. args ( [ " podman " , " stop " , & container_name ] )
. output ( )
. await ;
return Ok ( serde_json ::Value ::Null ) ;
}
2026-01-27 23:21:26 +00:00
2026-02-17 15:03:34 +00:00
for name in containers {
let _ = tokio ::process ::Command ::new ( " sudo " )
. args ( [ " podman " , " stop " , & name ] )
. output ( )
. await ;
2026-01-27 23:21:26 +00:00
}
Ok ( serde_json ::Value ::Null )
}
2026-03-04 05:23:42 +00:00
pub ( super ) async fn handle_package_restart (
2026-01-27 23:21:26 +00:00
& self ,
params : Option < serde_json ::Value > ,
) -> Result < serde_json ::Value > {
let params = params . ok_or_else ( | | anyhow ::anyhow! ( " Missing params " ) ) ? ;
let package_id = params
. get ( " id " )
. and_then ( | v | v . as_str ( ) )
. ok_or_else ( | | anyhow ::anyhow! ( " Missing package id " ) ) ? ;
2026-02-17 15:03:34 +00:00
let containers = get_containers_for_app ( package_id ) . await ? ;
if containers . is_empty ( ) {
let container_name = format! ( " archy- {} " , package_id ) ;
let _ = tokio ::process ::Command ::new ( " sudo " )
. args ( [ " podman " , " restart " , & container_name ] )
. output ( )
. await ;
return Ok ( serde_json ::Value ::Null ) ;
}
2026-01-27 23:21:26 +00:00
2026-02-17 15:03:34 +00:00
for name in containers {
let _ = tokio ::process ::Command ::new ( " sudo " )
. args ( [ " podman " , " restart " , & name ] )
. output ( )
. await ;
2026-01-27 23:21:26 +00:00
}
Ok ( serde_json ::Value ::Null )
}
2026-02-01 06:04:36 +00:00
2026-03-04 05:23:42 +00:00
/// Uninstall a package: stop and remove all related containers, clean data.
pub ( super ) async fn handle_package_uninstall (
2026-02-17 15:03:34 +00:00
& self ,
params : Option < serde_json ::Value > ,
) -> Result < serde_json ::Value > {
let params = params . ok_or_else ( | | anyhow ::anyhow! ( " Missing params " ) ) ? ;
let package_id = params
. get ( " id " )
. and_then ( | v | v . as_str ( ) )
. ok_or_else ( | | anyhow ::anyhow! ( " Missing package id " ) ) ? ;
2026-03-06 03:26:56 +00:00
validate_app_id ( package_id ) ? ;
2026-02-17 15:03:34 +00:00
let preserve_data = params
. get ( " preserve_data " )
. and_then ( | v | v . as_bool ( ) )
. unwrap_or ( false ) ;
let containers_to_remove = get_containers_for_app ( package_id ) . await ? ;
for name in & containers_to_remove {
let _ = tokio ::process ::Command ::new ( " sudo " )
. args ( [ " podman " , " stop " , name ] )
. output ( )
. await ;
let _ = tokio ::process ::Command ::new ( " sudo " )
. args ( [ " podman " , " rm " , " -f " , name ] )
. output ( )
. await ;
}
// Release port allocation
if let Ok ( mut allocator ) = self . port_allocator . lock ( ) {
let _ = allocator . release ( package_id ) ;
}
// Clean data directories unless preserve_data
if ! preserve_data {
let data_dirs = get_data_dirs_for_app ( package_id ) ;
for dir in & data_dirs {
let _ = tokio ::process ::Command ::new ( " sudo " )
. args ( [ " rm " , " -rf " , dir ] )
. output ( )
. await ;
}
}
Ok ( serde_json ::json! ( { " status " : " uninstalled " } ) )
}
2026-02-01 06:04:36 +00:00
/// Start a bundled app (create container from pre-loaded image if needed, then start)
2026-03-04 05:23:42 +00:00
pub ( super ) async fn handle_bundled_app_start (
2026-02-01 06:04:36 +00:00
& self ,
params : Option < serde_json ::Value > ,
) -> Result < serde_json ::Value > {
let params = params . ok_or_else ( | | anyhow ::anyhow! ( " Missing params " ) ) ? ;
let app_id = params
. get ( " app_id " )
. and_then ( | v | v . as_str ( ) )
. ok_or_else ( | | anyhow ::anyhow! ( " Missing app_id " ) ) ? ;
let image = params
. get ( " image " )
. and_then ( | v | v . as_str ( ) )
. ok_or_else ( | | anyhow ::anyhow! ( " Missing image " ) ) ? ;
let ports = params
. get ( " ports " )
. and_then ( | v | v . as_array ( ) )
. ok_or_else ( | | anyhow ::anyhow! ( " Missing ports " ) ) ? ;
let volumes = params
. get ( " volumes " )
. and_then ( | v | v . as_array ( ) )
. ok_or_else ( | | anyhow ::anyhow! ( " Missing volumes " ) ) ? ;
let check_output = tokio ::process ::Command ::new ( " sudo " )
. args ( [ " podman " , " ps " , " -a " , " --format " , " {{.Names}} " , " --filter " , & format! ( " name= {} " , app_id ) ] )
. output ( )
. await
. context ( " Failed to check container " ) ? ;
let existing = String ::from_utf8_lossy ( & check_output . stdout ) ;
2026-03-04 05:23:42 +00:00
2026-02-01 06:04:36 +00:00
if existing . trim ( ) . is_empty ( ) {
let mut cmd = tokio ::process ::Command ::new ( " sudo " ) ;
cmd . args ( [ " podman " , " run " , " -d " , " --name " , app_id ] ) ;
for port in ports {
if let ( Some ( host ) , Some ( container ) ) = (
port . get ( " host " ) . and_then ( | v | v . as_u64 ( ) ) ,
port . get ( " container " ) . and_then ( | v | v . as_u64 ( ) ) ,
) {
cmd . arg ( " -p " ) . arg ( format! ( " {} : {} " , host , container ) ) ;
}
}
for volume in volumes {
if let ( Some ( host ) , Some ( container ) ) = (
volume . get ( " host " ) . and_then ( | v | v . as_str ( ) ) ,
volume . get ( " container " ) . and_then ( | v | v . as_str ( ) ) ,
) {
let _ = tokio ::process ::Command ::new ( " sudo " )
. args ( [ " mkdir " , " -p " , host ] )
. output ( )
. await ;
cmd . arg ( " -v " ) . arg ( format! ( " {} : {} " , host , container ) ) ;
}
}
cmd . arg ( image ) ;
let output = cmd . output ( ) . await . context ( " Failed to create container " ) ? ;
if ! output . status . success ( ) {
let stderr = String ::from_utf8_lossy ( & output . stderr ) ;
return Err ( anyhow ::anyhow! ( " Failed to create container: {} " , stderr ) ) ;
}
} else {
let output = tokio ::process ::Command ::new ( " sudo " )
. args ( [ " podman " , " start " , app_id ] )
. output ( )
. await
. context ( " Failed to start container " ) ? ;
if ! output . status . success ( ) {
let stderr = String ::from_utf8_lossy ( & output . stderr ) ;
return Err ( anyhow ::anyhow! ( " Failed to start container: {} " , stderr ) ) ;
}
}
Ok ( serde_json ::json! ( { " status " : " started " , " app_id " : app_id } ) )
}
/// Stop a bundled app
2026-03-04 05:23:42 +00:00
pub ( super ) async fn handle_bundled_app_stop (
2026-02-01 06:04:36 +00:00
& self ,
params : Option < serde_json ::Value > ,
) -> Result < serde_json ::Value > {
let params = params . ok_or_else ( | | anyhow ::anyhow! ( " Missing params " ) ) ? ;
let app_id = params
. get ( " app_id " )
. and_then ( | v | v . as_str ( ) )
. ok_or_else ( | | anyhow ::anyhow! ( " Missing app_id " ) ) ? ;
let output = tokio ::process ::Command ::new ( " sudo " )
. args ( [ " podman " , " stop " , app_id ] )
. output ( )
. await
. context ( " Failed to stop container " ) ? ;
if ! output . status . success ( ) {
let stderr = String ::from_utf8_lossy ( & output . stderr ) ;
return Err ( anyhow ::anyhow! ( " Failed to stop container: {} " , stderr ) ) ;
}
Ok ( serde_json ::json! ( { " status " : " stopped " , " app_id " : app_id } ) )
}
2026-02-17 15:03:34 +00:00
}
/// Get all container names for an app (handles multi-container apps like mempool)
async fn get_containers_for_app ( package_id : & str ) -> Result < Vec < String > > {
2026-03-06 03:26:56 +00:00
validate_app_id ( package_id ) ? ;
2026-02-17 15:03:34 +00:00
let output = tokio ::process ::Command ::new ( " sudo " )
. args ( [ " podman " , " ps " , " -a " , " --format " , " {{.Names}} " ] )
. output ( )
. await
. context ( " Failed to list containers " ) ? ;
let stdout = String ::from_utf8_lossy ( & output . stdout ) ;
let all : Vec < & str > = stdout . lines ( ) . filter ( | s | ! s . is_empty ( ) ) . collect ( ) ;
let patterns : Vec < String > = match package_id {
" mempool " | " mempool-web " = > {
vec! [
" mempool-electrs " . into ( ) ,
" mempool-api " . into ( ) ,
" archy-mempool-api " . into ( ) ,
" archy-mempool-web " . into ( ) ,
" mempool " . into ( ) ,
" archy-mempool-db " . into ( ) ,
" mysql-mempool " . into ( ) ,
]
}
" fedimint " = > vec! [ " fedimint " . into ( ) , " fedimint-ui " . into ( ) , " archy-fedimint " . into ( ) ] ,
2026-02-25 18:04:41 +00:00
" immich " = > vec! [
" immich_postgres " . into ( ) ,
" immich_redis " . into ( ) ,
" immich_server " . into ( ) ,
] ,
" penpot " | " penpot-frontend " = > vec! [
" penpot-postgres " . into ( ) ,
" penpot-valkey " . into ( ) ,
" penpot-backend " . into ( ) ,
" penpot-exporter " . into ( ) ,
" penpot-frontend " . into ( ) ,
] ,
2026-02-17 15:03:34 +00:00
_ = > vec! [ package_id . to_string ( ) , format! ( " archy- {} " , package_id ) ] ,
} ;
let mut result = Vec ::new ( ) ;
for name in all {
for pat in & patterns {
if name = = pat {
result . push ( name . to_string ( ) ) ;
break ;
}
}
}
Ok ( result )
}
2026-03-06 03:26:56 +00:00
/// Get data directories to clean for an app.
/// Caller must validate package_id before calling.
2026-02-17 15:03:34 +00:00
fn get_data_dirs_for_app ( package_id : & str ) -> Vec < String > {
let base = " /var/lib/archipelago " ;
match package_id {
" mempool " | " mempool-web " = > vec! [
format! ( " {} /mempool " , base ) ,
format! ( " {} /mysql-mempool " , base ) ,
format! ( " {} /mempool-electrs " , base ) ,
] ,
" fedimint " = > vec! [ format! ( " {} /fedimint " , base ) ] ,
2026-02-25 18:04:41 +00:00
" immich " = > vec! [
format! ( " {} /immich " , base ) ,
format! ( " {} /immich-db " , base ) ,
] ,
" penpot " | " penpot-frontend " = > vec! [
format! ( " {} /penpot-assets " , base ) ,
format! ( " {} /penpot-postgres " , base ) ,
] ,
2026-02-17 15:03:34 +00:00
_ = > vec! [ format! ( " {} / {} " , base , package_id ) ] ,
}
2026-01-24 22:59:20 +00:00
}
2026-02-01 18:46:35 +00:00
2026-03-06 03:26:56 +00:00
/// Trusted Docker registries. Only images from these sources are allowed.
const TRUSTED_REGISTRIES : & [ & str ] = & [
" docker.io/ " ,
" ghcr.io/ " ,
" localhost/ " ,
] ;
/// Validate Docker image against trusted registry allowlist.
2026-03-08 02:16:02 +00:00
/// Detect which Bitcoin container is running on archy-net for DNS resolution.
/// Returns the container name to use as the RPC host (e.g., "bitcoin-knots").
fn detect_bitcoin_container_name ( ) -> String {
// Synchronous check — called from get_app_config which is sync
let output = std ::process ::Command ::new ( " sudo " )
. args ( [ " podman " , " ps " , " --format " , " {{.Names}} " ] )
. output ( ) ;
if let Ok ( out ) = output {
let names = String ::from_utf8_lossy ( & out . stdout ) ;
for candidate in & [ " bitcoin-knots " , " bitcoin-core " , " bitcoin " ] {
if names . lines ( ) . any ( | l | l . trim ( ) = = * candidate ) {
return candidate . to_string ( ) ;
}
}
}
// Default to bitcoin-knots (most common)
" bitcoin-knots " . to_string ( )
}
2026-02-01 18:46:35 +00:00
fn is_valid_docker_image ( image : & str ) -> bool {
2026-03-06 03:26:56 +00:00
if image . is_empty ( ) | | image . len ( ) > 256 {
return false ;
}
// Reject shell metacharacters
2026-02-01 18:46:35 +00:00
let dangerous_chars = [ '&' , '|' , ';' , '`' , '$' , '(' , ')' , '<' , '>' , '\n' , '\r' ] ;
if image . chars ( ) . any ( | c | dangerous_chars . contains ( & c ) ) {
return false ;
}
2026-03-06 03:26:56 +00:00
// Must come from a trusted registry
TRUSTED_REGISTRIES . iter ( ) . any ( | r | image . starts_with ( r ) )
}
/// Validate that a package/app ID is safe (lowercase alphanumeric + hyphens, 1-64 chars).
fn validate_app_id ( id : & str ) -> Result < ( ) > {
if id . is_empty ( ) | | id . len ( ) > 64 {
anyhow ::bail! ( " Invalid app id: must be 1-64 characters " ) ;
2026-02-01 18:46:35 +00:00
}
2026-03-06 03:26:56 +00:00
if ! id . bytes ( ) . all ( | b | b . is_ascii_lowercase ( ) | | b . is_ascii_digit ( ) | | b = = b '-' ) {
anyhow ::bail! ( " Invalid app id: only lowercase letters, digits, and hyphens allowed " ) ;
}
if id . starts_with ( '-' ) {
anyhow ::bail! ( " Invalid app id: must not start with a hyphen " ) ;
2026-02-01 18:46:35 +00:00
}
2026-03-06 03:26:56 +00:00
Ok ( ( ) )
2026-02-01 18:46:35 +00:00
}
2026-03-05 08:24:56 +00:00
/// Per-app Linux capabilities needed beyond the default cap-drop=ALL.
/// Most apps need CHOWN/SETUID/SETGID for internal user switching.
fn get_app_capabilities ( app_id : & str ) -> Vec < String > {
match app_id {
// Apps that need user switching and file ownership changes
" nextcloud " | " homeassistant " | " home-assistant " | " btcpay-server " | " btcpayserver "
| " jellyfin " | " onlyoffice " | " onlyoffice-documentserver " | " portainer " = > vec! [
" --cap-add=CHOWN " . to_string ( ) ,
" --cap-add=SETUID " . to_string ( ) ,
" --cap-add=SETGID " . to_string ( ) ,
" --cap-add=DAC_OVERRIDE " . to_string ( ) ,
] ,
// Nginx Proxy Manager needs to bind low ports
" nginx-proxy-manager " = > vec! [
" --cap-add=CHOWN " . to_string ( ) ,
" --cap-add=SETUID " . to_string ( ) ,
" --cap-add=SETGID " . to_string ( ) ,
" --cap-add=NET_BIND_SERVICE " . to_string ( ) ,
] ,
// Bitcoin and Lightning need file ownership ops
" bitcoin " | " bitcoin-core " | " bitcoin-knots " | " lnd " | " fedimint " = > vec! [
" --cap-add=CHOWN " . to_string ( ) ,
" --cap-add=SETUID " . to_string ( ) ,
" --cap-add=SETGID " . to_string ( ) ,
] ,
// Grafana runs as specific UID (472)
" grafana " = > vec! [
" --cap-add=CHOWN " . to_string ( ) ,
" --cap-add=SETUID " . to_string ( ) ,
" --cap-add=SETGID " . to_string ( ) ,
] ,
// Minimal apps (searxng, filebrowser, uptime-kuma, etc.) need no extra caps
_ = > vec! [ ] ,
}
}
/// Apps safe to run with --read-only root filesystem.
/// These work correctly with volume mounts + tmpfs for /tmp and /run.
fn is_readonly_compatible ( app_id : & str ) -> bool {
matches! (
app_id ,
" searxng " | " grafana " | " uptime-kuma " | " filebrowser " | " photoprism " | " vaultwarden "
)
}
2026-02-01 18:46:35 +00:00
/// Get app-specific configuration
2026-02-17 15:03:34 +00:00
/// Returns: (ports, volumes, env_vars, custom_command, custom_args)
fn get_app_config (
app_id : & str ,
host_ip : & str ,
allocator : & mut PortAllocator ,
) -> ( Vec < String > , Vec < String > , Vec < String > , Option < String > , Option < Vec < String > > ) {
2026-02-01 18:46:35 +00:00
match app_id {
" homeassistant " | " home-assistant " = > (
vec! [ " 8123:8123 " . to_string ( ) ] ,
vec! [ " /var/lib/archipelago/home-assistant:/config " . to_string ( ) ] ,
vec! [ " TZ=UTC " . to_string ( ) ] ,
None ,
2026-02-17 15:03:34 +00:00
None ,
2026-02-01 18:46:35 +00:00
) ,
2026-02-25 18:04:41 +00:00
" bitcoin " | " bitcoin-core " | " bitcoin-knots " = > (
2026-02-01 18:46:35 +00:00
vec! [ " 8332:8332 " . to_string ( ) , " 8333:8333 " . to_string ( ) ] ,
2026-02-25 18:04:41 +00:00
vec! [ " /var/lib/archipelago/bitcoin:/home/bitcoin/.bitcoin " . to_string ( ) ] ,
2026-02-01 18:46:35 +00:00
vec! [ ] ,
None ,
2026-02-17 15:03:34 +00:00
None ,
2026-02-01 18:46:35 +00:00
) ,
" lnd " = > (
vec! [ " 9735:9735 " . to_string ( ) , " 10009:10009 " . to_string ( ) , " 8080:8080 " . to_string ( ) ] ,
vec! [ " /var/lib/archipelago/lnd:/root/.lnd " . to_string ( ) ] ,
vec! [ " BITCOIN_ACTIVE=1 " . to_string ( ) ] ,
None ,
2026-02-17 15:03:34 +00:00
None ,
2026-02-01 18:46:35 +00:00
) ,
" btcpay-server " | " btcpayserver " = > (
vec! [ " 23000:49392 " . to_string ( ) ] ,
vec! [ " /var/lib/archipelago/btcpay:/datadir " . to_string ( ) ] ,
2026-02-17 15:03:34 +00:00
vec! [
" ASPNETCORE_URLS=http://0.0.0.0:49392 " . to_string ( ) ,
" BTCPAY_PROTOCOL=http " . to_string ( ) ,
format! ( " BTCPAY_HOST= {} :23000 " , host_ip ) ,
" BTCPAY_CHAINS=btc " . to_string ( ) ,
format! ( " BTCPAY_BTCRPCURL=http:// {} :8332 " , host_ip ) ,
" BTCPAY_BTCRPCUSER=archipelago " . to_string ( ) ,
" BTCPAY_BTCRPCPASSWORD=archipelago123 " . to_string ( ) ,
" BTCPAY_POSTGRES=User ID=btcpay;Password=btcpaypass;Host=archy-btcpay-db;Port=5432;Database=btcpay;Include Error Detail=true " . to_string ( ) ,
] ,
None ,
None ,
) ,
" mempool " | " mempool-web " = > (
vec! [ " 4080:8080 " . to_string ( ) ] ,
2026-02-01 18:46:35 +00:00
vec! [ ] ,
2026-02-17 15:03:34 +00:00
vec! [ format! ( " BACKEND_MAINNET_HTTP_HOST= {} " , host_ip ) ] ,
None ,
None ,
) ,
" mempool-api " = > (
vec! [ " 8999:8999 " . to_string ( ) ] ,
vec! [ " /var/lib/archipelago/mempool:/data " . to_string ( ) ] ,
vec! [
" MEMPOOL_BACKEND=electrum " . to_string ( ) ,
" ELECTRUM_HOST=mempool-electrs " . to_string ( ) ,
" ELECTRUM_PORT=50001 " . to_string ( ) ,
" ELECTRUM_TLS_ENABLED=false " . to_string ( ) ,
format! ( " CORE_RPC_HOST= {} " , host_ip ) ,
" CORE_RPC_PORT=8332 " . to_string ( ) ,
2026-02-25 17:23:38 +00:00
" CORE_RPC_USERNAME=archipelago " . to_string ( ) ,
" CORE_RPC_PASSWORD=archipelago123 " . to_string ( ) ,
2026-02-17 15:03:34 +00:00
" DATABASE_ENABLED=true " . to_string ( ) ,
" DATABASE_HOST=archy-mempool-db " . to_string ( ) ,
" DATABASE_DATABASE=mempool " . to_string ( ) ,
" DATABASE_USERNAME=mempool " . to_string ( ) ,
" DATABASE_PASSWORD=mempoolpass " . to_string ( ) ,
] ,
None ,
2026-02-01 18:46:35 +00:00
None ,
) ,
2026-03-08 02:16:02 +00:00
" mempool-electrs " | " electrs " = > {
// Detect which bitcoin container is running for archy-net DNS resolution
let bitcoin_host = detect_bitcoin_container_name ( ) ;
(
vec! [ " 50001:50001 " . to_string ( ) ] ,
vec! [ " /var/lib/archipelago/mempool-electrs:/data " . to_string ( ) ] ,
vec! [ ] ,
None ,
Some ( vec! [
" --daemon-rpc-addr " . to_string ( ) ,
format! ( " {} :8332 " , bitcoin_host ) ,
" --cookie " . to_string ( ) ,
" archipelago:archipelago123 " . to_string ( ) ,
" --jsonrpc-import " . to_string ( ) ,
" --electrum-rpc-addr " . to_string ( ) ,
" 0.0.0.0:50001 " . to_string ( ) ,
" --db-dir " . to_string ( ) ,
" /data " . to_string ( ) ,
" --lightmode " . to_string ( ) ,
] ) ,
)
} ,
2026-02-17 15:03:34 +00:00
" mysql-mempool " = > (
2026-02-01 18:46:35 +00:00
vec! [ ] ,
2026-02-17 15:03:34 +00:00
vec! [ " /var/lib/archipelago/mysql-mempool:/var/lib/mysql " . to_string ( ) ] ,
vec! [
" MYSQL_DATABASE=mempool " . to_string ( ) ,
" MYSQL_USER=mempool " . to_string ( ) ,
" MYSQL_PASSWORD=mempoolpass " . to_string ( ) ,
" MYSQL_ROOT_PASSWORD=rootpass " . to_string ( ) ,
] ,
None ,
2026-02-01 18:46:35 +00:00
None ,
) ,
" grafana " = > (
vec! [ " 3000:3000 " . to_string ( ) ] ,
vec! [ " /var/lib/archipelago/grafana:/var/lib/grafana " . to_string ( ) ] ,
2026-02-25 18:04:41 +00:00
vec! [ " GF_PATHS_DATA=/var/lib/grafana " . to_string ( ) , " GF_USERS_ALLOW_SIGN_UP=false " . to_string ( ) ] ,
2026-02-01 18:46:35 +00:00
None ,
2026-02-17 15:03:34 +00:00
None ,
2026-02-01 18:46:35 +00:00
) ,
" searxng " = > (
vec! [ " 8888:8080 " . to_string ( ) ] ,
vec! [ ] ,
vec! [ ] ,
None ,
2026-02-17 15:03:34 +00:00
None ,
2026-02-01 18:46:35 +00:00
) ,
" ollama " = > (
vec! [ " 11434:11434 " . to_string ( ) ] ,
vec! [ " /var/lib/archipelago/ollama:/root/.ollama " . to_string ( ) ] ,
vec! [ ] ,
None ,
2026-02-17 15:03:34 +00:00
None ,
2026-02-01 18:46:35 +00:00
) ,
" onlyoffice " | " onlyoffice-documentserver " = > (
vec! [ " 9980:80 " . to_string ( ) ] ,
vec! [ ] ,
vec! [ ] ,
None ,
2026-02-17 15:03:34 +00:00
None ,
2026-02-01 18:46:35 +00:00
) ,
" penpot " | " penpot-frontend " = > (
vec! [ " 9001:80 " . to_string ( ) ] ,
vec! [ ] ,
vec! [ ] ,
None ,
None ,
) ,
2026-02-17 15:03:34 +00:00
" nextcloud " = > {
let host_port = allocator
. allocate_or_get ( app_id , 8085 , 80 )
. unwrap_or ( 8085 ) ;
(
vec! [ format! ( " {} :80 " , host_port ) ] ,
vec! [ " /var/lib/archipelago/nextcloud:/var/www/html " . to_string ( ) ] ,
vec! [ ] ,
None ,
None ,
)
}
" vaultwarden " = > {
let host_port = allocator
. allocate_or_get ( app_id , 8082 , 80 )
. unwrap_or ( 8082 ) ;
(
vec! [ format! ( " {} :80 " , host_port ) ] ,
vec! [ " /var/lib/archipelago/vaultwarden:/data " . to_string ( ) ] ,
vec! [ ] ,
None ,
None ,
)
}
2026-02-01 18:46:35 +00:00
" jellyfin " = > (
vec! [ " 8096:8096 " . to_string ( ) ] ,
vec! [ " /var/lib/archipelago/jellyfin/config:/config " . to_string ( ) , " /var/lib/archipelago/jellyfin/cache:/cache " . to_string ( ) ] ,
vec! [ ] ,
None ,
2026-02-17 15:03:34 +00:00
None ,
2026-02-01 18:46:35 +00:00
) ,
" photoprism " = > (
vec! [ " 2342:2342 " . to_string ( ) ] ,
vec! [ " /var/lib/archipelago/photoprism:/photoprism/storage " . to_string ( ) ] ,
2026-02-25 18:04:41 +00:00
vec! [ " PHOTOPRISM_ADMIN_PASSWORD=archipelago " . to_string ( ) , " PHOTOPRISM_DEFAULT_LOCALE=en " . to_string ( ) ] ,
2026-02-01 18:46:35 +00:00
None ,
2026-02-17 15:03:34 +00:00
None ,
2026-02-01 18:46:35 +00:00
) ,
" immich " = > (
2026-02-25 18:04:41 +00:00
vec! [ " 2283:2283 " . to_string ( ) ] ,
2026-02-01 18:46:35 +00:00
vec! [ " /var/lib/archipelago/immich:/usr/src/app/upload " . to_string ( ) ] ,
2026-02-25 18:04:41 +00:00
vec! [
" DB_HOSTNAME=immich_postgres " . to_string ( ) ,
" DB_USERNAME=postgres " . to_string ( ) ,
" DB_PASSWORD=immichpass " . to_string ( ) ,
" DB_DATABASE_NAME=immich " . to_string ( ) ,
" REDIS_HOSTNAME=immich_redis " . to_string ( ) ,
" UPLOAD_LOCATION=/usr/src/app/upload " . to_string ( ) ,
] ,
2026-02-01 18:46:35 +00:00
None ,
None ,
) ,
2026-02-17 15:03:34 +00:00
" filebrowser " = > {
let host_port = allocator
. allocate_or_get ( app_id , 8083 , 80 )
. unwrap_or ( 8083 ) ;
(
vec! [ format! ( " {} :80 " , host_port ) ] ,
vec! [ " /var/lib/archipelago/filebrowser:/srv " . to_string ( ) ] ,
vec! [ ] ,
None ,
None ,
)
}
2026-02-01 18:46:35 +00:00
" nginx-proxy-manager " = > (
vec! [ " 81:81 " . to_string ( ) , " 8084:80 " . to_string ( ) , " 8443:443 " . to_string ( ) ] ,
vec! [
" /var/lib/archipelago/nginx-proxy-manager/data:/data " . to_string ( ) ,
" /var/lib/archipelago/nginx-proxy-manager/letsencrypt:/etc/letsencrypt " . to_string ( ) ,
] ,
vec! [ ] ,
None ,
2026-02-17 15:03:34 +00:00
None ,
2026-02-01 18:46:35 +00:00
) ,
" portainer " = > (
vec! [ " 9000:9000 " . to_string ( ) ] ,
vec! [ " /var/lib/archipelago/portainer:/data " . to_string ( ) , " /var/run/podman/podman.sock:/var/run/docker.sock " . to_string ( ) ] ,
vec! [ ] ,
None ,
2026-02-17 15:03:34 +00:00
None ,
2026-02-01 18:46:35 +00:00
) ,
" uptime-kuma " = > (
vec! [ " 3001:3001 " . to_string ( ) ] ,
vec! [ " /var/lib/archipelago/uptime-kuma:/app/data " . to_string ( ) ] ,
2026-02-25 18:04:41 +00:00
vec! [ " TZ=UTC " . to_string ( ) ] ,
2026-02-01 18:46:35 +00:00
None ,
2026-02-17 15:03:34 +00:00
None ,
2026-02-01 18:46:35 +00:00
) ,
" tailscale " = > (
2026-03-04 05:23:42 +00:00
vec! [ " 8240:8240 " . to_string ( ) ] ,
2026-02-01 18:46:35 +00:00
vec! [
" /var/lib/archipelago/tailscale:/var/lib/tailscale " . to_string ( ) ,
] ,
vec! [
" TS_STATE_DIR=/var/lib/tailscale " . to_string ( ) ,
] ,
Some ( " sh -c 'tailscale web --listen 0.0.0.0:8240 & exec tailscaled' " . to_string ( ) ) ,
2026-02-17 15:03:34 +00:00
None ,
2026-02-01 18:46:35 +00:00
) ,
" fedimint " = > (
2026-02-17 15:03:34 +00:00
vec! [
2026-03-04 05:23:42 +00:00
" 8173:8173 " . to_string ( ) ,
" 8174:8174 " . to_string ( ) ,
" 8175:8175 " . to_string ( ) ,
2026-02-17 15:03:34 +00:00
] ,
2026-02-01 18:46:35 +00:00
vec! [ " /var/lib/archipelago/fedimint:/data " . to_string ( ) ] ,
vec! [
2026-02-17 15:03:34 +00:00
" FM_DATA_DIR=/data " . to_string ( ) ,
2026-02-25 17:23:38 +00:00
" FM_BITCOIND_USERNAME=archipelago " . to_string ( ) ,
" FM_BITCOIND_PASSWORD=archipelago123 " . to_string ( ) ,
2026-02-17 15:03:34 +00:00
" FM_BITCOIN_NETWORK=bitcoin " . to_string ( ) ,
2026-02-01 18:46:35 +00:00
" FM_BIND_P2P=0.0.0.0:8173 " . to_string ( ) ,
" FM_BIND_API=0.0.0.0:8174 " . to_string ( ) ,
2026-02-17 15:03:34 +00:00
" FM_BIND_UI=0.0.0.0:8175 " . to_string ( ) ,
format! ( " FM_P2P_URL=fedimint:// {} :8173 " , host_ip ) ,
format! ( " FM_API_URL=ws:// {} :8174 " , host_ip ) ,
format! ( " FM_BITCOIND_URL=http:// {} :8332 " , host_ip ) ,
2026-02-01 18:46:35 +00:00
] ,
None ,
2026-02-17 15:03:34 +00:00
None ,
2026-02-01 18:46:35 +00:00
) ,
2026-03-01 17:53:18 +00:00
" indeedhub " = > (
vec! [ " 7777:7777 " . to_string ( ) ] ,
vec! [ ] ,
vec! [ " NGINX_HOST=0.0.0.0 " . to_string ( ) , " NGINX_PORT=7777 " . to_string ( ) ] ,
None ,
None ,
) ,
2026-03-04 05:23:42 +00:00
_ = > ( vec! [ ] , vec! [ ] , vec! [ ] , None , None ) ,
2026-02-01 18:46:35 +00:00
}
}