From 301a7f9cca9afdbe78273b964919577151361cc1 Mon Sep 17 00:00:00 2001 From: Mammad <108564287+Mammad3861@users.noreply.github.com> Date: Thu, 10 Sep 2026 02:17:44 +0330 Subject: [PATCH] Add optional domain TLS with Caddy --- README.md | 30 ++- browser.sh | 432 +++++++++++++++++++++++++++++++++++++++++- tests/browser_test.sh | 280 ++++++++++++++++++++++++++- 3 files changed, 727 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 6a18813..dc60679 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ It keeps an interactive menu for installing, uninstalling, checking status, and - Firefox HTTPS on `4001`; HTTP on `4000` is for a reverse proxy only. - Persistent config in `/opt/instant-linux-browser//config`. - Username/password prompts, with `ILB_USERNAME` and `ILB_PASSWORD` overrides. +- Optional trusted HTTPS for a custom domain through the official Caddy image. - Supports amd64/x86_64 and arm64/aarch64 Linux servers when the upstream image supports them. - Keeps the one-command interactive installer and direct actions for automation. @@ -58,6 +59,30 @@ You can also pass credentials: curl -fsSL https://raw.githubusercontent.com/Mammad3861/Instant-Linux-Browser/main/browser.sh | sudo ILB_ACTION=install-chromium ILB_USERNAME=admin ILB_PASSWORD='change-this-password' bash ``` +## Custom Domain HTTPS + +IP/port installation remains the default. To use a real domain, set `ILB_DOMAIN`; `ILB_ACME_EMAIL` is optional: + +```bash +curl -fsSL https://raw.githubusercontent.com/Mammad3861/Instant-Linux-Browser/main/browser.sh | sudo ILB_ACTION=install-chromium ILB_USERNAME=admin ILB_PASSWORD='change-this-password' ILB_DOMAIN=browser.example.com ILB_ACME_EMAIL=admin@example.com bash +``` + +When installing interactively, you can also enter a domain after the browser credentials. Leave it blank to keep IP/port mode. Domain mode uses one shared `caddy:2-alpine` container, and Caddy automatically obtains and renews the public certificate. + +Before installation: + +- point the domain's DNS A and, when used, AAAA records to the VPS; +- open inbound TCP ports `80` and `443`; +- handle any existing nginx, Apache, Caddy, or other service using those ports manually; +- remove incorrect AAAA records, because they can break certificate validation; +- for Cloudflare, use DNS-only mode during initial certificate issuance, then enable proxying only after it is configured correctly. + +Domain-enabled browser ports are bound to loopback only; public traffic enters through Caddy. The browser username and password are still required. View proxy logs with: + +```bash +docker logs instant-linux-browser-caddy +``` + ## Direct Actions ```bash @@ -84,6 +109,8 @@ sudo ILB_ACTION=diagnostics bash browser.sh - `5` - Diagnostics - `6` - Exit +If the browser window is closed, right-click the empty desktop and select Chromium or Firefox to launch it again. Keeping the browser closed reduces active CPU and memory usage while the container remains available. + ## Chromium Compatibility Chromium is launched with these flags by default: @@ -131,9 +158,10 @@ Uninstall removes the container but keeps persistent config: ```text /opt/instant-linux-browser/chromium/config /opt/instant-linux-browser/firefox/config +/opt/instant-linux-browser/proxy ``` -Delete those directories manually only if you no longer need the browser profile data. +Domain-route cleanup reuses Caddy while another browser route remains and removes only the project-owned Caddy container after the last route is removed. Browser profiles and persistent Caddy certificate/configuration data are kept. Delete those directories manually only if you no longer need them. ## Security diff --git a/browser.sh b/browser.sh index b4b8d36..c47a7ab 100644 --- a/browser.sh +++ b/browser.sh @@ -18,6 +18,10 @@ NC='\033[0m' CONFIG_BASE="${CONFIG_BASE:-/opt/instant-linux-browser}" CHROMIUM_FLAGS="${CHROMIUM_FLAGS:---no-sandbox --disable-gpu --disable-dev-shm-usage --disable-setuid-sandbox}" +CADDY_CONTAINER="instant-linux-browser-caddy" +CADDY_NETWORK="instant-linux-browser" +CADDY_IMAGE="caddy:2-alpine" +CADDY_MANAGED_LABEL="com.instant-linux-browser.managed=true" info() { echo -e "${CYAN}$*${NC}"; } success() { echo -e "${GREEN}$*${NC}"; } @@ -82,6 +86,65 @@ prompt_secret() { printf -v "$result_var" '%s' "$value" } +prompt_optional_domain() { + local result_var="$1" + local value="" + + printf "Optional domain for automatic HTTPS (leave blank for IP access): " >&"$TTY_FD" + if ! IFS= read -r value <&"$TTY_FD"; then + die "Domain input ended or was interrupted." + fi + + printf -v "$result_var" '%s' "$value" +} + +validate_domain() { + local domain="$1" + local label + local -a labels + + [[ -n "$domain" && ${#domain} -le 253 ]] || return 1 + [[ "$domain" == *.* ]] || return 1 + [[ "$domain" =~ ^[a-z0-9.-]+$ ]] || return 1 + [[ "$domain" != .* && "$domain" != *. && "$domain" != *..* ]] || return 1 + [[ ! "$domain" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]] || return 1 + + case "$domain" in + localhost|*.localhost|*.local|*.internal|*.home.arpa) + return 1 + ;; + esac + + IFS='.' read -r -a labels <<< "$domain" + for label in "${labels[@]}"; do + [[ -n "$label" && ${#label} -le 63 ]] || return 1 + [[ "$label" =~ ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$ ]] || return 1 + done +} + +resolve_install_domain() { + local result_var="$1" + local resolved_domain="" + + if [[ -n "${ILB_DOMAIN+x}" ]]; then + resolved_domain="$ILB_DOMAIN" + elif [[ -n "$TTY_FD" ]]; then + prompt_optional_domain resolved_domain + fi + + if [[ -n "$resolved_domain" ]]; then + resolved_domain="${resolved_domain,,}" + validate_domain "$resolved_domain" || die "Invalid ILB_DOMAIN value. Use a fully qualified hostname without a scheme, path, port, wildcard, or whitespace." + fi + + printf -v "$result_var" '%s' "$resolved_domain" +} + +validate_acme_email() { + local email="$1" + [[ -z "$email" || "$email" =~ ^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+$ ]] +} + is_debian_like() { command -v apt-get >/dev/null 2>&1 } @@ -234,6 +297,311 @@ check_port_available() { fi } +caddy_route_file() { + printf '%s/proxy/sites/%s.caddy' "$CONFIG_BASE" "$1" +} + +container_is_project_managed() { + [[ "$(docker inspect -f '{{ index .Config.Labels "com.instant-linux-browser.managed" }}' "$1" 2>/dev/null || true)" == "true" ]] +} + +network_exists() { + docker network inspect "$CADDY_NETWORK" >/dev/null 2>&1 +} + +network_is_project_managed() { + [[ "$(docker network inspect -f '{{ index .Labels "com.instant-linux-browser.managed" }}' "$CADDY_NETWORK" 2>/dev/null || true)" == "true" ]] +} + +ensure_proxy_directories() { + mkdir -p \ + "${CONFIG_BASE}/proxy/sites" \ + "${CONFIG_BASE}/proxy/data" \ + "${CONFIG_BASE}/proxy/config" || die "Failed to create Caddy configuration directories under ${CONFIG_BASE}/proxy." + chmod 700 \ + "${CONFIG_BASE}/proxy/data" \ + "${CONFIG_BASE}/proxy/config" || die "Failed to protect Caddy data directories under ${CONFIG_BASE}/proxy." +} + +ensure_proxy_network() { + if network_exists; then + network_is_project_managed || die "A Docker network named $CADDY_NETWORK exists but is not managed by Instant Linux Browser." + return 0 + fi + + docker network create --label "$CADDY_MANAGED_LABEL" "$CADDY_NETWORK" >/dev/null || die "Failed to create Docker network $CADDY_NETWORK." +} + +write_atomic_file() { + local path="$1" + local content="$2" + local temporary="${path}.tmp.$$" + + if ! printf '%s' "$content" > "$temporary"; then + rm -f -- "$temporary" + return 1 + fi + + if ! mv -f -- "$temporary" "$path"; then + rm -f -- "$temporary" + return 1 + fi +} + +write_caddy_main_config() { + local email="$1" + local content + + if [[ -n "$email" ]]; then + content=$'{\n\temail '"$email"$'\n}\n\nimport /etc/caddy/sites/*.caddy\n' + else + content=$'import /etc/caddy/sites/*.caddy\n' + fi + + write_atomic_file "${CONFIG_BASE}/proxy/Caddyfile" "$content" +} + +write_caddy_route() { + local browser="$1" + local domain="$2" + local route_file + local content + + route_file="$(caddy_route_file "$browser")" + content="${domain} {"$'\n\treverse_proxy '"${browser}:3000"$'\n}\n' + write_atomic_file "$route_file" "$content" +} + +configured_route_domain() { + local browser="$1" + local route_file + local first_line="" + + route_file="$(caddy_route_file "$browser")" + [[ -f "$route_file" ]] || return 1 + IFS= read -r first_line < "$route_file" || true + printf '%s\n' "${first_line%%[[:space:]]*}" +} + +assert_domain_available() { + local browser="$1" + local domain="$2" + local other_browser other_domain + + for other_browser in chromium firefox; do + [[ "$other_browser" == "$browser" ]] && continue + other_domain="$(configured_route_domain "$other_browser" 2>/dev/null || true)" + if [[ "$other_domain" == "$domain" ]]; then + die "Domain $domain is already assigned to $other_browser." + fi + done +} + +validate_caddy_config() { + if container_exists "$CADDY_CONTAINER" && container_running "$CADDY_CONTAINER"; then + docker exec -w /etc/caddy "$CADDY_CONTAINER" caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile + else + docker run --rm \ + -v "${CONFIG_BASE}/proxy:/etc/caddy:ro" \ + "$CADDY_IMAGE" \ + caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile + fi +} + +reload_caddy() { + docker exec -w /etc/caddy "$CADDY_CONTAINER" caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile +} + +wait_for_caddy_startup() { + local attempt + + for attempt in {1..5}; do + container_running "$CADDY_CONTAINER" || return 1 + [[ "$attempt" -eq 5 ]] && return 0 + sleep 1 + done +} + +print_caddy_failure() { + local reason="$1" + + echo -e "${RED}Caddy setup failed: $reason${NC}" >&2 + if container_exists "$CADDY_CONTAINER"; then + echo "Recent Caddy logs:" >&2 + docker logs --tail 80 "$CADDY_CONTAINER" 2>&1 || true + fi + echo "Debug: sudo docker logs $CADDY_CONTAINER" >&2 + echo "Debug: sudo docker exec -w /etc/caddy $CADDY_CONTAINER caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile" >&2 +} + +start_or_reload_caddy() { + if container_exists "$CADDY_CONTAINER"; then + container_is_project_managed "$CADDY_CONTAINER" || return 1 + if ! container_running "$CADDY_CONTAINER"; then + docker start "$CADDY_CONTAINER" >/dev/null || return 1 + container_running "$CADDY_CONTAINER" || return 1 + fi + reload_caddy + return + fi + + if ! docker run -d \ + --name="$CADDY_CONTAINER" \ + --label "$CADDY_MANAGED_LABEL" \ + --network="$CADDY_NETWORK" \ + -p "80:80" \ + -p "443:443" \ + -v "${CONFIG_BASE}/proxy:/etc/caddy:ro" \ + -v "${CONFIG_BASE}/proxy/data:/data" \ + -v "${CONFIG_BASE}/proxy/config:/config" \ + --restart unless-stopped \ + "$CADDY_IMAGE"; then + return 1 + fi + + wait_for_caddy_startup +} + +prepare_domain_proxy() { + ensure_proxy_directories + + if container_exists "$CADDY_CONTAINER"; then + container_is_project_managed "$CADDY_CONTAINER" || die "A container named $CADDY_CONTAINER exists but is not managed by Instant Linux Browser." + else + check_port_available 80 + check_port_available 443 + info "Pulling $CADDY_IMAGE..." + docker pull "$CADDY_IMAGE" || die "Failed to pull $CADDY_IMAGE." + fi + + ensure_proxy_network +} + +restore_config_file() { + local path="$1" + local backup="$2" + + if [[ -n "$backup" ]]; then + mv -f -- "$backup" "$path" + else + rm -f -- "$path" + fi +} + +configure_caddy_route() { + local browser="$1" + local domain="$2" + local email="$3" + local main_file="${CONFIG_BASE}/proxy/Caddyfile" + local route_file + local main_backup="" + local route_backup="" + local caddy_preexisting=0 + + route_file="$(caddy_route_file "$browser")" + assert_domain_available "$browser" "$domain" + + if [[ -f "$main_file" ]]; then + main_backup="${main_file}.backup.$$" + cp -p -- "$main_file" "$main_backup" || return 1 + fi + if [[ -f "$route_file" ]]; then + route_backup="${route_file}.backup.$$" + cp -p -- "$route_file" "$route_backup" || { + [[ -z "$main_backup" ]] || rm -f -- "$main_backup" + return 1 + } + fi + if container_exists "$CADDY_CONTAINER"; then + caddy_preexisting=1 + fi + + if [[ ! -f "$main_file" || -n "$email" ]]; then + if ! write_caddy_main_config "$email"; then + restore_config_file "$main_file" "$main_backup" + restore_config_file "$route_file" "$route_backup" + return 1 + fi + fi + if ! write_caddy_route "$browser" "$domain"; then + restore_config_file "$main_file" "$main_backup" + restore_config_file "$route_file" "$route_backup" + return 1 + fi + + if ! validate_caddy_config; then + restore_config_file "$main_file" "$main_backup" + restore_config_file "$route_file" "$route_backup" + print_caddy_failure "configuration validation failed" + return 1 + fi + + if ! start_or_reload_caddy; then + restore_config_file "$main_file" "$main_backup" + restore_config_file "$route_file" "$route_backup" + print_caddy_failure "container start or reload failed" + if [[ "$caddy_preexisting" -eq 1 ]] && container_running "$CADDY_CONTAINER"; then + reload_caddy >/dev/null 2>&1 || true + elif container_exists "$CADDY_CONTAINER" && container_is_project_managed "$CADDY_CONTAINER"; then + docker rm -f "$CADDY_CONTAINER" >/dev/null 2>&1 || true + fi + return 1 + fi + + [[ -z "$main_backup" ]] || rm -f -- "$main_backup" + [[ -z "$route_backup" ]] || rm -f -- "$route_backup" +} + +proxy_routes_remain() { + compgen -G "${CONFIG_BASE}/proxy/sites/*.caddy" >/dev/null +} + +cleanup_proxy_network() { + container_exists chromium && return 0 + container_exists firefox && return 0 + container_exists "$CADDY_CONTAINER" && return 0 + network_exists || return 0 + network_is_project_managed || return 0 + docker network rm "$CADDY_NETWORK" >/dev/null 2>&1 || true +} + +remove_caddy_route() { + local browser="$1" + local route_file + local backup + + route_file="$(caddy_route_file "$browser")" + [[ -f "$route_file" ]] || return 0 + backup="${route_file}.removed.$$" + mv -- "$route_file" "$backup" || return 1 + + if proxy_routes_remain; then + if container_exists "$CADDY_CONTAINER"; then + if ! container_is_project_managed "$CADDY_CONTAINER"; then + mv -f -- "$backup" "$route_file" + return 1 + fi + if ! validate_caddy_config || ! start_or_reload_caddy; then + mv -f -- "$backup" "$route_file" + reload_caddy >/dev/null 2>&1 || true + return 1 + fi + fi + elif container_exists "$CADDY_CONTAINER"; then + if ! container_is_project_managed "$CADDY_CONTAINER"; then + mv -f -- "$backup" "$route_file" + return 1 + fi + if ! docker stop "$CADDY_CONTAINER" >/dev/null || ! docker rm "$CADDY_CONTAINER" >/dev/null; then + mv -f -- "$backup" "$route_file" + docker start "$CADDY_CONTAINER" >/dev/null 2>&1 || true + return 1 + fi + fi + + rm -f -- "$backup" +} + normalize_arch() { local arch="${1:-}" case "$arch" in @@ -270,6 +638,8 @@ install_browser() { local image="$2" local port="$3" local ssl_port=$((port + 1)) + local domain="" + local acme_email="${ILB_ACME_EMAIL:-}" require_root ensure_docker_ready @@ -299,10 +669,18 @@ install_browser() { prompt_secret password "Enter UI Password: " fi + resolve_install_domain domain + if [[ -z "$password" ]]; then warn "Empty UI password selected. Use a firewall or reverse proxy allow-list if this server is reachable from the internet." fi + if [[ -n "$domain" ]]; then + validate_acme_email "$acme_email" || die "Invalid ILB_ACME_EMAIL value." + assert_domain_available "$browser" "$domain" + prepare_domain_proxy + fi + info "Pulling $image..." docker pull "$image" || die "Failed to pull $image. Check internet access, DNS, Docker, and whether the image supports this CPU architecture." @@ -316,12 +694,19 @@ install_browser() { chown -R "${puid}:${pgid}" "${CONFIG_BASE}/${browser}" 2>/dev/null || true local browser_env=() + local browser_network=() + local browser_ports=(-p "${port}:3000" -p "${ssl_port}:3001") if [[ "$browser" == "chromium" ]]; then browser_env+=(-e "PIXELFLUX_WAYLAND=false" -e "CHROME_CLI=$CHROMIUM_FLAGS" -e "CHROME_FLAGS=$CHROMIUM_FLAGS") info "Applying Chromium flags through CHROME_CLI and CHROME_FLAGS: $CHROMIUM_FLAGS" fi + if [[ -n "$domain" ]]; then + browser_network=(--network="$CADDY_NETWORK") + browser_ports=(-p "127.0.0.1:${port}:3000" -p "127.0.0.1:${ssl_port}:3001") + fi + info "Deploying $browser... Please wait." if ! docker run -d \ --name="$browser" \ @@ -331,8 +716,8 @@ install_browser() { -e "CUSTOM_USER=$username" \ -e "PASSWORD=$password" \ "${browser_env[@]}" \ - -p "${port}:3000" \ - -p "${ssl_port}:3001" \ + "${browser_network[@]}" \ + "${browser_ports[@]}" \ -v "${config_dir}:/config" \ --shm-size="2gb" \ --restart unless-stopped \ @@ -356,26 +741,55 @@ install_browser() { verify_chromium_startup "$password" || die "Chromium is not ready. Check the logs and debug commands above." fi + if [[ -n "$domain" ]] && ! configure_caddy_route "$browser" "$domain" "$acme_email"; then + docker stop "$browser" >/dev/null 2>&1 || true + docker rm "$browser" >/dev/null 2>&1 || true + cleanup_proxy_network + die "Domain proxy setup failed. The new $browser container was removed; persistent browser and Caddy data were kept." + fi + local ip - ip=$(detect_ip) success "================================================" success "Deployment Successful!" - echo -e "Browser URL (HTTPS): ${CYAN}https://${ip}:${ssl_port}${NC}" - echo -e "HTTP (proxy-only) : ${CYAN}http://${ip}:${port}${NC}" + if [[ -n "$domain" ]]; then + echo -e "Browser URL (HTTPS): ${CYAN}https://${domain}${NC}" + else + ip=$(detect_ip) + echo -e "Browser URL (HTTPS): ${CYAN}https://${ip}:${ssl_port}${NC}" + echo -e "HTTP (proxy-only) : ${CYAN}http://${ip}:${port}${NC}" + fi echo -e "Credentials : ${YELLOW}configured for $username${NC}" - echo -e "${YELLOW}Note: Accept the SSL warning in your browser.${NC}" + if [[ -n "$domain" ]]; then + echo "Caddy manages certificate issuance and renewal for this domain." + warn "DNS must point to this server, and inbound ports 80 and 443 must be reachable." + else + echo -e "${YELLOW}Note: Accept the SSL warning in your browser.${NC}" + fi success "================================================" } uninstall_browser() { local browser="$1" + local had_domain_route=0 + + [[ -f "$(caddy_route_file "$browser")" ]] && had_domain_route=1 info "Removing $browser..." require_root ensure_docker_ready - docker stop "$browser" >/dev/null 2>&1 || true - docker rm "$browser" >/dev/null 2>&1 || true - success "Cleanup complete. Persistent config was kept at ${CONFIG_BASE}/${browser}/config." + remove_caddy_route "$browser" || die "Failed to remove the $browser domain route safely. Check Caddy logs and configuration." + if container_exists "$browser"; then + docker stop "$browser" >/dev/null 2>&1 || true + if ! docker rm "$browser" >/dev/null 2>&1 && container_exists "$browser"; then + die "Failed to remove the $browser container." + fi + fi + cleanup_proxy_network + if [[ "$had_domain_route" -eq 1 ]]; then + success "Cleanup complete. Persistent browser and Caddy data were kept under ${CONFIG_BASE}." + else + success "Cleanup complete. Persistent config was kept at ${CONFIG_BASE}/${browser}/config." + fi } show_diagnostics() { diff --git a/tests/browser_test.sh b/tests/browser_test.sh index 788becf..f9d30df 100644 --- a/tests/browser_test.sh +++ b/tests/browser_test.sh @@ -51,6 +51,13 @@ assert_environment_before_image() { [[ "$(awk -F '\t' -v field="$environment_flag_index" '{ print $field }' "$RUN_RECORD")" == "-e" ]] || fail "$argument must be preceded by -e" } +record_run_with_argument() { + local argument="$1" + local output="$2" + + awk -F '\t' -v argument="$argument" '$2 == "run" { for (field_index = 3; field_index <= NF; field_index++) if ($field_index == argument) { print; found = 1; exit } } END { exit !found }' "$MOCK_LOG" > "$output" +} + mkdir -p "$MOCK_BIN" cat > "$MOCK_BIN/docker" <<'MOCK_DOCKER' #!/usr/bin/env bash @@ -65,16 +72,70 @@ set -eu } >> "$MOCK_LOG" case "${1:-}" in - info|pull|run|stop|rm|ps) + info|pull|stop|start) + exit 0 + ;; + run) + if [[ "${MOCK_TRACK_CADDY:-0}" == "1" && "$*" == *'--name=instant-linux-browser-caddy'* ]]; then + : > "${MOCK_CADDY_STATE:?}" + fi + exit 0 + ;; + rm) + if [[ "${MOCK_TRACK_CADDY:-0}" == "1" && "$*" == *'instant-linux-browser-caddy'* ]]; then + rm -f -- "${MOCK_CADDY_STATE:?}" + fi + exit 0 + ;; + ps) + if [[ "$*" == *'{{.Names}}'* && -n "${MOCK_EXISTING_CONTAINERS:-}" ]]; then + printf '%s\n' "${MOCK_EXISTING_CONTAINERS//,/$'\n'}" + fi + if [[ "$*" == *'{{.Names}}'* && "${MOCK_TRACK_CADDY:-0}" == "1" && -f "${MOCK_CADDY_STATE:-}" ]]; then + printf '%s\n' 'instant-linux-browser-caddy' + fi exit 0 ;; inspect) - printf 'true\n' + if [[ "$*" == *'Config.Labels'* ]]; then + printf '%s\n' "${MOCK_CADDY_MANAGED:-true}" + elif [[ "${MOCK_CADDY_EXIT_AFTER_FIRST_CHECK:-0}" == "1" && "$*" == *'State.Running'* && "$*" == *'instant-linux-browser-caddy'* ]]; then + check_count=0 + [[ ! -f "${MOCK_CADDY_INSPECT_COUNT_FILE:-}" ]] || read -r check_count < "$MOCK_CADDY_INSPECT_COUNT_FILE" + check_count=$((check_count + 1)) + printf '%s\n' "$check_count" > "${MOCK_CADDY_INSPECT_COUNT_FILE:?}" + if [[ "$check_count" -eq 1 ]]; then + printf 'true\n' + else + printf 'false\n' + fi + else + printf 'true\n' + fi exit 0 ;; exec) + if [[ "$*" == *' caddy '* ]]; then + if [[ "${MOCK_CADDY_RELOAD_FAIL:-0}" == "1" && "$*" == *' caddy reload '* ]]; then + exit 1 + fi + exit 0 + fi [[ "${MOCK_CHROMIUM_PROCESS:-1}" == "1" ]] ;; + network) + case "${2:-}" in + inspect) + [[ "${MOCK_NETWORK_EXISTS:-0}" == "1" ]] || exit 1 + if [[ "$*" == *'Labels'* ]]; then + printf '%s\n' "${MOCK_NETWORK_MANAGED:-true}" + fi + ;; + create|rm) + exit 0 + ;; + esac + ;; logs) printf '%s\n' "${MOCK_LOG_LINE:-mock container log}" ;; @@ -153,9 +214,10 @@ if command -v script >/dev/null 2>&1; then : > "$MOCK_LOG" SEQUENTIAL_CREDENTIALS_OUTPUT="$TEMP_DIR/sequential-credentials.out" - run_streamed_install $'1\nstreamed-user\nstreamed-password\n' "$SEQUENTIAL_CREDENTIALS_OUTPUT" || fail "Streamed interactive credential install failed" + run_streamed_install $'1\nstreamed-user\nstreamed-password\n\n' "$SEQUENTIAL_CREDENTIALS_OUTPUT" || fail "Streamed interactive credential install failed" assert_contains "Enter UI Username (default: admin):" "$SEQUENTIAL_CREDENTIALS_OUTPUT" assert_contains "Enter UI Password:" "$SEQUENTIAL_CREDENTIALS_OUTPUT" + assert_contains "Optional domain for automatic HTTPS" "$SEQUENTIAL_CREDENTIALS_OUTPUT" # script may record pre-fed PTY input before read -s disables terminal echo. assert_contains $'docker\tpull\tlscr.io/linuxserver/chromium:latest' "$MOCK_LOG" assert_contains $'docker\trun' "$MOCK_LOG" @@ -163,16 +225,17 @@ if command -v script >/dev/null 2>&1; then : > "$MOCK_LOG" USERNAME_ONLY_OUTPUT="$TEMP_DIR/username-only.out" - run_streamed_install $'1\npassword-only\n' "$USERNAME_ONLY_OUTPUT" ILB_USERNAME=provided-user || fail "Streamed password prompt with ILB_USERNAME failed" + run_streamed_install $'1\npassword-only\n\n' "$USERNAME_ONLY_OUTPUT" ILB_USERNAME=provided-user || fail "Streamed password prompt with ILB_USERNAME failed" assert_not_contains "Enter UI Username" "$USERNAME_ONLY_OUTPUT" assert_contains "Enter UI Password:" "$USERNAME_ONLY_OUTPUT" assert_contains $'CUSTOM_USER=provided-user' "$MOCK_LOG" : > "$MOCK_LOG" ENV_CREDENTIALS_OUTPUT="$TEMP_DIR/env-credentials.out" - run_streamed_install $'1\n' "$ENV_CREDENTIALS_OUTPUT" ILB_USERNAME=environment-user ILB_PASSWORD=environment-password || fail "Environment credential install failed" + run_streamed_install $'1\n' "$ENV_CREDENTIALS_OUTPUT" ILB_USERNAME=environment-user ILB_PASSWORD=environment-password ILB_DOMAIN= || fail "Environment credential install failed" assert_not_contains "Enter UI Username" "$ENV_CREDENTIALS_OUTPUT" assert_not_contains "Enter UI Password" "$ENV_CREDENTIALS_OUTPUT" + assert_not_contains "Optional domain for automatic HTTPS" "$ENV_CREDENTIALS_OUTPUT" assert_contains $'CUSTOM_USER=environment-user' "$MOCK_LOG" else echo "SKIP: streamed TTY tests require the util-linux script command" @@ -192,6 +255,84 @@ fi require_root() { :; } detect_arch() { echo x86_64; } sleep() { :; } +check_port_available() { :; } + +EXPLICIT_EMPTY_DOMAIN="not-empty" +( + prompt_optional_domain() { fail "Explicit empty ILB_DOMAIN prompted unexpectedly"; } + TTY_FD=9 + ILB_DOMAIN= + resolve_install_domain EXPLICIT_EMPTY_DOMAIN + [[ -z "$EXPLICIT_EMPTY_DOMAIN" ]] +) || fail "Explicit empty ILB_DOMAIN did not disable domain mode" + +NORMALIZED_DOMAIN="" +ILB_DOMAIN=Browser.Example.COM +resolve_install_domain NORMALIZED_DOMAIN +unset ILB_DOMAIN +[[ "$NORMALIZED_DOMAIN" == "browser.example.com" ]] || fail "Domain was not normalized to lowercase" + +for INVALID_DOMAIN in \ + 'https://browser.example.com' \ + 'browser.example.com/path' \ + 'browser.example.com:443' \ + 'browser example.com' \ + '*.example.com' \ + '127.0.0.1' \ + '2001:db8::1' \ + 'localhost' \ + '.example.com' \ + 'browser..example.com' \ + '-browser.example.com' \ + 'browser-.example.com' \ + 'browser.example.com;import'; do + if (ILB_DOMAIN="$INVALID_DOMAIN" resolve_install_domain REJECTED_DOMAIN) > /dev/null 2>&1; then + fail "Invalid domain was accepted: $INVALID_DOMAIN" + fi +done + +NON_INTERACTIVE_DOMAIN="unexpected" +TTY_FD="" +unset ILB_DOMAIN +resolve_install_domain NON_INTERACTIVE_DOMAIN +[[ -z "$NON_INTERACTIVE_DOMAIN" ]] || fail "No-terminal domain selection did not default to IP mode" + +INJECTION_OUTPUT="$TEMP_DIR/injection.out" +: > "$MOCK_LOG" +if (ILB_USERNAME=admin ILB_PASSWORD=secret ILB_DOMAIN='browser.example.com;import' install_browser chromium lscr.io/linuxserver/chromium:latest 3000) > "$INJECTION_OUTPUT" 2>&1; then + fail "Injection-shaped domain reached deployment" +fi +assert_not_contains $'docker\tpull' "$MOCK_LOG" +assert_not_contains $'docker\trun' "$MOCK_LOG" + +INVALID_EMAIL_OUTPUT="$TEMP_DIR/invalid-email.out" +: > "$MOCK_LOG" +if (ILB_USERNAME=admin ILB_PASSWORD=secret ILB_DOMAIN=browser.example.com ILB_ACME_EMAIL=$'admin@example.com\n}' install_browser chromium lscr.io/linuxserver/chromium:latest 3000) > "$INVALID_EMAIL_OUTPUT" 2>&1; then + fail "Injection-shaped ACME email reached deployment" +fi +assert_not_contains $'docker\tpull' "$MOCK_LOG" +assert_not_contains $'docker\trun' "$MOCK_LOG" + +DUPLICATE_CONFIG_BASE="$TEMP_DIR/duplicate" +( + CONFIG_BASE="$DUPLICATE_CONFIG_BASE" + ensure_proxy_directories + write_caddy_route firefox shared.example.com + assert_domain_available chromium shared.example.com +) > /dev/null 2>&1 && fail "The same domain was assigned to both browsers" + +PERMISSIONS_CONFIG_BASE="$TEMP_DIR/permissions" +PERMISSIONS_LOG="$TEMP_DIR/permissions.log" +( + chmod() { + printf 'chmod' + printf '\t%s' "$@" + printf '\n' + } > "$PERMISSIONS_LOG" + CONFIG_BASE="$PERMISSIONS_CONFIG_BASE" + ensure_proxy_directories +) +assert_contains $'chmod\t700\t'"$PERMISSIONS_CONFIG_BASE"$'/proxy/data\t'"$PERMISSIONS_CONFIG_BASE"'/proxy/config' "$PERMISSIONS_LOG" EOF_OUTPUT="$TEMP_DIR/eof.out" if ( @@ -227,10 +368,12 @@ RESOLVED_IDS="$(SUDO_USER=root resolve_puid_pgid)" : > "$MOCK_LOG" INSTALL_OUTPUT="$TEMP_DIR/install.out" +unset ILB_DOMAIN ILB_ACME_EMAIL MOCK_EXISTING_CONTAINERS MOCK_NETWORK_EXISTS ILB_USERNAME=admin ILB_PASSWORD=do-not-print-this install_browser chromium lscr.io/linuxserver/chromium:latest 3000 > "$INSTALL_OUTPUT" 2>&1 assert_not_contains "do-not-print-this" "$INSTALL_OUTPUT" assert_not_contains "Enter UI Username" "$INSTALL_OUTPUT" assert_not_contains "Enter UI Password" "$INSTALL_OUTPUT" +assert_not_contains "Optional domain for automatic HTTPS" "$INSTALL_OUTPUT" RUN_RECORD="$TEMP_DIR/docker-run.log" awk -F '\t' '$2 == "run" { print; found = 1; exit } END { exit !found }' "$MOCK_LOG" > "$RUN_RECORD" || fail "Docker run invocation was not recorded" @@ -238,8 +381,135 @@ IMAGE_INDEX="$(argument_index "lscr.io/linuxserver/chromium:latest")" [[ -n "$IMAGE_INDEX" ]] || fail "Chromium image was not passed to docker run" assert_environment_before_image "PIXELFLUX_WAYLAND=false" assert_environment_before_image "CHROME_CLI=--no-sandbox --disable-gpu --disable-dev-shm-usage --disable-setuid-sandbox" +assert_contains $'\t-p\t3000:3000\t-p\t3001:3001' "$RUN_RECORD" +assert_not_contains "--network=$CADDY_NETWORK" "$RUN_RECORD" assert_not_contains "--disable-software-rasterizer" "$RUN_RECORD" +CONFIG_BASE="$TEMP_DIR/domain-chromium" +: > "$MOCK_LOG" +unset MOCK_EXISTING_CONTAINERS MOCK_NETWORK_EXISTS +DOMAIN_CHROMIUM_OUTPUT="$TEMP_DIR/domain-chromium.out" +ILB_USERNAME=domain-user ILB_PASSWORD=domain-password ILB_DOMAIN=Chrome.Example.COM ILB_ACME_EMAIL=admin@example.com \ + install_browser chromium lscr.io/linuxserver/chromium:latest 3000 > "$DOMAIN_CHROMIUM_OUTPUT" 2>&1 +assert_contains "Browser URL (HTTPS):" "$DOMAIN_CHROMIUM_OUTPUT" +assert_contains "https://chrome.example.com" "$DOMAIN_CHROMIUM_OUTPUT" +assert_contains "Caddy manages certificate issuance and renewal" "$DOMAIN_CHROMIUM_OUTPUT" +assert_not_contains "domain-password" "$DOMAIN_CHROMIUM_OUTPUT" +assert_contains "chrome.example.com {" "$CONFIG_BASE/proxy/sites/chromium.caddy" +assert_contains "reverse_proxy chromium:3000" "$CONFIG_BASE/proxy/sites/chromium.caddy" +assert_contains "email admin@example.com" "$CONFIG_BASE/proxy/Caddyfile" +assert_contains "import /etc/caddy/sites/*.caddy" "$CONFIG_BASE/proxy/Caddyfile" +assert_not_contains "domain-password" "$CONFIG_BASE/proxy/Caddyfile" +assert_not_contains "domain-password" "$CONFIG_BASE/proxy/sites/chromium.caddy" + +DOMAIN_CHROMIUM_RUN="$TEMP_DIR/domain-chromium-run.log" +record_run_with_argument "lscr.io/linuxserver/chromium:latest" "$DOMAIN_CHROMIUM_RUN" || fail "Domain Chromium run was not recorded" +assert_contains $'\t--network=instant-linux-browser\t' "$DOMAIN_CHROMIUM_RUN" +assert_contains $'\t-p\t127.0.0.1:3000:3000\t-p\t127.0.0.1:3001:3001' "$DOMAIN_CHROMIUM_RUN" + +CADDY_RUN="$TEMP_DIR/caddy-run.log" +record_run_with_argument "--name=instant-linux-browser-caddy" "$CADDY_RUN" || fail "Caddy run was not recorded" +assert_contains $'\t--network=instant-linux-browser\t' "$CADDY_RUN" +assert_contains $'\t-p\t80:80\t-p\t443:443' "$CADDY_RUN" +assert_contains $'\tcaddy:2-alpine' "$CADDY_RUN" +assert_contains $'\t-v\t'"$CONFIG_BASE"$'/proxy:/etc/caddy:ro\t' "$CADDY_RUN" +assert_contains $'\t-v\t'"$CONFIG_BASE"$'/proxy/data:/data\t' "$CADDY_RUN" +assert_contains $'\t-v\t'"$CONFIG_BASE"$'/proxy/config:/config\t' "$CADDY_RUN" +assert_not_contains "$CONFIG_BASE/proxy/data:/data:ro" "$CADDY_RUN" +assert_not_contains "$CONFIG_BASE/proxy/config:/config:ro" "$CADDY_RUN" +assert_contains $'docker\tnetwork\tcreate\t--label\tcom.instant-linux-browser.managed=true\tinstant-linux-browser' "$MOCK_LOG" + +CONFIG_BASE="$TEMP_DIR/caddy-immediate-exit" +ensure_proxy_directories +: > "$MOCK_LOG" +export MOCK_TRACK_CADDY=1 +export MOCK_CADDY_STATE="$TEMP_DIR/caddy.state" +export MOCK_CADDY_INSPECT_COUNT_FILE="$TEMP_DIR/caddy-inspect-count" +export MOCK_CADDY_EXIT_AFTER_FIRST_CHECK=1 +rm -f -- "$MOCK_CADDY_STATE" "$MOCK_CADDY_INSPECT_COUNT_FILE" +CADDY_EXIT_OUTPUT="$TEMP_DIR/caddy-immediate-exit.out" +if configure_caddy_route chromium immediate-exit.example.com "" > "$CADDY_EXIT_OUTPUT" 2>&1; then + fail "Caddy startup succeeded after the container exited immediately" +fi +assert_contains "Caddy setup failed: container start or reload failed" "$CADDY_EXIT_OUTPUT" +assert_contains "Recent Caddy logs:" "$CADDY_EXIT_OUTPUT" +assert_contains $'docker\tlogs\t--tail\t80\tinstant-linux-browser-caddy' "$MOCK_LOG" +[[ ! -f "$CONFIG_BASE/proxy/sites/chromium.caddy" ]] || fail "Failed Caddy startup left the new route configured" +unset MOCK_TRACK_CADDY MOCK_CADDY_STATE MOCK_CADDY_INSPECT_COUNT_FILE MOCK_CADDY_EXIT_AFTER_FIRST_CHECK + +CONFIG_BASE="$TEMP_DIR/domain-firefox" +: > "$MOCK_LOG" +unset MOCK_EXISTING_CONTAINERS MOCK_NETWORK_EXISTS +DOMAIN_FIREFOX_OUTPUT="$TEMP_DIR/domain-firefox.out" +ILB_USERNAME=firefox-user ILB_PASSWORD=firefox-password ILB_DOMAIN=firefox.example.com \ + install_browser firefox lscr.io/linuxserver/firefox:latest 4000 > "$DOMAIN_FIREFOX_OUTPUT" 2>&1 +assert_contains "https://firefox.example.com" "$DOMAIN_FIREFOX_OUTPUT" +assert_contains "reverse_proxy firefox:3000" "$CONFIG_BASE/proxy/sites/firefox.caddy" + +DOMAIN_FIREFOX_RUN="$TEMP_DIR/domain-firefox-run.log" +record_run_with_argument "lscr.io/linuxserver/firefox:latest" "$DOMAIN_FIREFOX_RUN" || fail "Domain Firefox run was not recorded" +assert_contains $'\t--network=instant-linux-browser\t' "$DOMAIN_FIREFOX_RUN" +assert_contains $'\t-p\t127.0.0.1:4000:3000\t-p\t127.0.0.1:4001:3001' "$DOMAIN_FIREFOX_RUN" + +CONFIG_BASE="$TEMP_DIR/reused-caddy" +: > "$MOCK_LOG" +export MOCK_EXISTING_CONTAINERS=instant-linux-browser-caddy +export MOCK_NETWORK_EXISTS=1 +REUSED_CADDY_OUTPUT="$TEMP_DIR/reused-caddy.out" +ILB_USERNAME=reuse-user ILB_PASSWORD=reuse-password ILB_DOMAIN=reuse.example.com \ + install_browser firefox lscr.io/linuxserver/firefox:latest 4000 > "$REUSED_CADDY_OUTPUT" 2>&1 +assert_not_contains $'docker\tpull\tcaddy:2-alpine' "$MOCK_LOG" +if record_run_with_argument "--name=instant-linux-browser-caddy" "$TEMP_DIR/unexpected-caddy-run.log"; then + fail "Existing project Caddy container was recreated" +fi +assert_contains $'docker\texec\t-w\t/etc/caddy\tinstant-linux-browser-caddy\tcaddy\treload' "$MOCK_LOG" + +CONFIG_BASE="$TEMP_DIR/uninstall-one-route" +ensure_proxy_directories +write_caddy_main_config "" +write_caddy_route chromium chromium.example.com +write_caddy_route firefox firefox.example.com +: > "$MOCK_LOG" +export MOCK_EXISTING_CONTAINERS=chromium,instant-linux-browser-caddy +export MOCK_NETWORK_EXISTS=1 +uninstall_browser chromium > "$TEMP_DIR/uninstall-one-route.out" 2>&1 +[[ ! -f "$CONFIG_BASE/proxy/sites/chromium.caddy" ]] || fail "Chromium route was not removed" +[[ -f "$CONFIG_BASE/proxy/sites/firefox.caddy" ]] || fail "Firefox route was removed unexpectedly" +assert_not_contains $'docker\tstop\tinstant-linux-browser-caddy' "$MOCK_LOG" +assert_contains $'docker\texec\t-w\t/etc/caddy\tinstant-linux-browser-caddy\tcaddy\treload' "$MOCK_LOG" + +CONFIG_BASE="$TEMP_DIR/uninstall-last-route" +ensure_proxy_directories +write_caddy_main_config "" +write_caddy_route firefox last.example.com +: > "$MOCK_LOG" +export MOCK_EXISTING_CONTAINERS=firefox,instant-linux-browser-caddy +uninstall_browser firefox > "$TEMP_DIR/uninstall-last-route.out" 2>&1 +[[ ! -f "$CONFIG_BASE/proxy/sites/firefox.caddy" ]] || fail "Last Firefox route was not removed" +assert_contains $'docker\tstop\tinstant-linux-browser-caddy' "$MOCK_LOG" +assert_contains $'docker\trm\tinstant-linux-browser-caddy' "$MOCK_LOG" + +CONFIG_BASE="$TEMP_DIR/uninstall-reload-failure" +ensure_proxy_directories +write_caddy_main_config "" +write_caddy_route chromium stale.example.com +write_caddy_route firefox retained.example.com +: > "$MOCK_LOG" +export MOCK_EXISTING_CONTAINERS=chromium,instant-linux-browser-caddy +export MOCK_CADDY_RELOAD_FAIL=1 +UNINSTALL_FAILURE_OUTPUT="$TEMP_DIR/uninstall-reload-failure.out" +if (uninstall_browser chromium) > "$UNINSTALL_FAILURE_OUTPUT" 2>&1; then + fail "Uninstall succeeded after Caddy route removal failed" +fi +[[ -f "$CONFIG_BASE/proxy/sites/chromium.caddy" ]] || fail "Failed route removal did not restore the Chromium route" +assert_not_contains $'docker\tstop\tchromium' "$MOCK_LOG" +assert_not_contains $'docker\trm\tchromium' "$MOCK_LOG" +assert_not_contains "Cleanup complete." "$UNINSTALL_FAILURE_OUTPUT" +assert_contains "Failed to remove the chromium domain route safely" "$UNINSTALL_FAILURE_OUTPUT" +unset MOCK_CADDY_RELOAD_FAIL + +unset MOCK_EXISTING_CONTAINERS MOCK_NETWORK_EXISTS ILB_DOMAIN ILB_ACME_EMAIL + LOG_OUTPUT="$TEMP_DIR/log.out" export MOCK_LOG_LINE='do-not-print-this' print_recent_logs chromium do-not-print-this > "$LOG_OUTPUT"