From cc6a40424093c00280a2b63b3e63eb74e9823acf Mon Sep 17 00:00:00 2001 From: Mammad <108564287+Mammad3861@users.noreply.github.com> Date: Sat, 12 Sep 2026 20:49:46 +0330 Subject: [PATCH] Add automatic sslip HTTPS access mode --- README.md | 42 ++++--- browser.sh | 205 ++++++++++++++++++++++++++----- tests/browser_test.sh | 280 +++++++++++++++++++++++++++++++++++++----- 3 files changed, 453 insertions(+), 74 deletions(-) diff --git a/README.md b/README.md index dc60679..fc77072 100644 --- a/README.md +++ b/README.md @@ -16,7 +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. +- Three HTTPS access modes: server IP, automatic sslip.io hostname, or custom domain. - 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. @@ -59,25 +59,39 @@ 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 +## HTTPS Access Modes -IP/port installation remains the default. To use a real domain, set `ILB_DOMAIN`; `ILB_ACME_EMAIL` is optional: +Set `ILB_ACCESS_MODE=ip|sslip|domain`, or choose a mode from the interactive menu after entering the browser credentials. + +### IP mode + +IP mode is the default. It uses Selkies' self-signed certificate and may show a browser certificate warning. Chromium uses `https://SERVER_IP:3001`; Firefox uses `https://SERVER_IP:4001`. Caddy is not started, and HTTP ports `3000` and `4000` remain reverse-proxy-only. ```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 +curl -fsSL https://raw.githubusercontent.com/Mammad3861/Instant-Linux-Browser/main/browser.sh | sudo ILB_ACTION=install-chromium ILB_ACCESS_MODE=ip 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. +### Automatic sslip.io mode + +This mode performs a bounded external lookup for the server's public IPv4 address and generates a browser-specific hostname such as `chromium.138-124-35-156.sslip.io` or `firefox.138-124-35-156.sslip.io`. Caddy manages a publicly trusted certificate, while the browser ports bind only to loopback. + +```bash +curl -fsSL https://raw.githubusercontent.com/Mammad3861/Instant-Linux-Browser/main/browser.sh | sudo ILB_ACTION=install-chromium ILB_ACCESS_MODE=sslip bash +``` -Before installation: +[sslip.io](https://sslip.io/) is a third-party DNS service, and the generated hostname exposes the server IP. Inbound TCP ports `80` and `443` must be publicly reachable. Public CA or sslip.io rate limits may prevent certificate issuance; use a custom domain for stable long-term production deployments. + +### Custom-domain mode + +Set `ILB_ACCESS_MODE=domain` with `ILB_DOMAIN`; `ILB_ACME_EMAIL` is optional. Supplying a non-empty `ILB_DOMAIN` without `ILB_ACCESS_MODE` still selects domain mode for backward compatibility. + +```bash +curl -fsSL https://raw.githubusercontent.com/Mammad3861/Instant-Linux-Browser/main/browser.sh | sudo ILB_ACTION=install-chromium ILB_ACCESS_MODE=domain ILB_DOMAIN=browser.example.com ILB_ACME_EMAIL=admin@example.com bash +``` -- 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. +Point the domain's DNS records to the server, remove incorrect AAAA records, and ensure inbound TCP ports `80` and `443` are available. Handle any existing nginx, Apache, Caddy, or other service using those ports manually. For Cloudflare, use DNS-only mode during initial certificate issuance. -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: +Both sslip.io and custom-domain modes use [Caddy automatic HTTPS](https://caddyserver.com/docs/automatic-https), bind browser ports to loopback, and share the project-managed Caddy container. Browser authentication remains enabled. View proxy logs with: ```bash docker logs instant-linux-browser-caddy @@ -161,14 +175,14 @@ Uninstall removes the container but keeps persistent config: /opt/instant-linux-browser/proxy ``` -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. +Caddy 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 If exposing the browser to the public internet: - use a strong password; -- do not expose HTTP port `3000` directly; use it behind a reverse proxy; +- do not expose HTTP ports `3000` or `4000` directly; use them behind a reverse proxy; - treat `CUSTOM_USER` and `PASSWORD` as basic protection for a trusted local network, not sufficient public Internet protection; - use HTTPS and a reverse proxy with robust authentication for public exposure; - restrict access with a firewall, VPN, or IP allow-list. diff --git a/browser.sh b/browser.sh index c47a7ab..35d8095 100644 --- a/browser.sh +++ b/browser.sh @@ -86,13 +86,60 @@ prompt_secret() { printf -v "$result_var" '%s' "$value" } -prompt_optional_domain() { +prompt_access_mode() { + local result_var="$1" + local choice="" + + if [[ -n "$TTY_FD" ]]; then + printf '%s\n' \ + "1) Server IP - HTTPS with a browser certificate warning" \ + "2) Automatic sslip.io hostname - trusted HTTPS without owning a domain" \ + "3) Custom domain - trusted HTTPS using your own DNS" >&"$TTY_FD" + printf "Select access mode [1]: " >&"$TTY_FD" + if ! IFS= read -r choice <&"$TTY_FD"; then + die "Access mode input ended or was interrupted." + fi + else + printf '%s\n' \ + "1) Server IP - HTTPS with a browser certificate warning" \ + "2) Automatic sslip.io hostname - trusted HTTPS without owning a domain" \ + "3) Custom domain - trusted HTTPS using your own DNS" + printf "Select access mode [1]: " + if ! IFS= read -r choice; then + die "Access mode input ended or was interrupted." + fi + fi + + case "$choice" in + ""|1) + printf -v "$result_var" '%s' "ip" + ;; + 2) + printf -v "$result_var" '%s' "sslip" + ;; + 3) + printf -v "$result_var" '%s' "domain" + ;; + *) + die "Invalid access mode selection: $choice. Use 1, 2, or 3." + ;; + esac +} + +prompt_custom_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." + if [[ -n "$TTY_FD" ]]; then + printf "Custom domain for automatic HTTPS: " >&"$TTY_FD" + if ! IFS= read -r value <&"$TTY_FD"; then + die "Domain input ended or was interrupted." + fi + else + printf "Custom domain for automatic HTTPS: " + if ! IFS= read -r value; then + die "Domain input ended or was interrupted." + fi fi printf -v "$result_var" '%s' "$value" @@ -122,22 +169,105 @@ validate_domain() { done } -resolve_install_domain() { +validate_public_ipv4() { + local ip="$1" + local first second third fourth octet + + [[ "$ip" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]] || return 1 + IFS='.' read -r first second third fourth <<< "$ip" + + for octet in "$first" "$second" "$third" "$fourth"; do + [[ "$octet" == "0" || "$octet" != 0* ]] || return 1 + ((10#$octet <= 255)) || return 1 + done + + ((first != 0 && first != 10 && first != 127)) || return 1 + ! ((first == 100 && second >= 64 && second <= 127)) || return 1 + ! ((first == 169 && second == 254)) || return 1 + ! ((first == 172 && second >= 16 && second <= 31)) || return 1 + ! ((first == 192 && second == 0 && third == 0)) || return 1 + ! ((first == 192 && second == 0 && third == 2)) || return 1 + ! ((first == 192 && second == 88 && third == 99)) || return 1 + ! ((first == 192 && second == 168)) || return 1 + ! ((first == 198 && (second == 18 || second == 19))) || return 1 + ! ((first == 198 && second == 51 && third == 100)) || return 1 + ! ((first == 203 && second == 0 && third == 113)) || return 1 + ((first < 224)) || return 1 +} + +detect_public_ipv4() { local result_var="$1" - local resolved_domain="" + local detected_ipv4="" + + command -v curl >/dev/null 2>&1 || die "Automatic sslip.io mode requires curl to detect the server's public IPv4 address." + if ! detected_ipv4="$(curl -4 -fsS --connect-timeout 3 --max-time 5 https://ifconfig.me/ip 2>/dev/null)"; then + die "Automatic sslip.io mode could not detect the server's public IPv4 address within 5 seconds." + fi + validate_public_ipv4 "$detected_ipv4" || die "Automatic sslip.io mode requires a publicly routable IPv4 address, but detection returned a missing, malformed, or non-public value." + + printf -v "$result_var" '%s' "$detected_ipv4" +} - if [[ -n "${ILB_DOMAIN+x}" ]]; then - resolved_domain="$ILB_DOMAIN" +resolve_access_mode() { + local result_var="$1" + local resolved_mode="" + + if [[ -n "${ILB_ACCESS_MODE+x}" ]]; then + resolved_mode="$ILB_ACCESS_MODE" + elif [[ -n "${ILB_DOMAIN:-}" ]]; then + resolved_mode="domain" + elif [[ -n "${ILB_DOMAIN+x}" ]]; then + resolved_mode="ip" elif [[ -n "$TTY_FD" ]]; then - prompt_optional_domain resolved_domain + prompt_access_mode resolved_mode + else + resolved_mode="ip" 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." + case "$resolved_mode" in + ip|sslip|domain) + ;; + *) + die "Invalid ILB_ACCESS_MODE value: ${resolved_mode:-empty}. Use ip, sslip, or domain exactly as shown." + ;; + esac + + if [[ "$resolved_mode" != "domain" && -n "${ILB_DOMAIN:-}" ]]; then + die "ILB_DOMAIN cannot be used with ILB_ACCESS_MODE=$resolved_mode. Use ILB_ACCESS_MODE=domain or remove ILB_DOMAIN." fi - printf -v "$result_var" '%s' "$resolved_domain" + printf -v "$result_var" '%s' "$resolved_mode" +} + +resolve_access_hostname() { + local browser="$1" + local access_mode="$2" + local result_var="$3" + local resolved_hostname="" + local public_ipv4="" + + case "$access_mode" in + ip) + ;; + sslip) + detect_public_ipv4 public_ipv4 + resolved_hostname="${browser}.${public_ipv4//./-}.sslip.io" + ;; + domain) + if [[ -n "${ILB_DOMAIN:-}" ]]; then + resolved_hostname="$ILB_DOMAIN" + elif [[ -n "$TTY_FD" ]]; then + prompt_custom_domain resolved_hostname + else + die "ILB_ACCESS_MODE=domain requires a non-empty ILB_DOMAIN when no controlling terminal is available." + fi + + resolved_hostname="${resolved_hostname,,}" + validate_domain "$resolved_hostname" || die "Invalid ILB_DOMAIN value. Use a fully qualified hostname without a scheme, path, port, wildcard, or whitespace." + ;; + esac + + printf -v "$result_var" '%s' "$resolved_hostname" } validate_acme_email() { @@ -638,20 +768,13 @@ install_browser() { local image="$2" local port="$3" local ssl_port=$((port + 1)) - local domain="" + local access_mode="" + local access_hostname="" local acme_email="${ILB_ACME_EMAIL:-}" require_root - ensure_docker_ready show_arch_info - if container_exists "$browser"; then - die "$browser container already exists. Use the uninstall option first if you want to recreate it." - fi - - check_port_available "$port" - check_port_available "$ssl_port" - echo -e "${CYAN}--- Configuration for $browser ---${NC}" local username="" local password="" @@ -669,15 +792,27 @@ install_browser() { prompt_secret password "Enter UI Password: " fi - resolve_install_domain domain + resolve_access_mode access_mode + resolve_access_hostname "$browser" "$access_mode" access_hostname 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 + if [[ "$access_mode" != "ip" ]]; then validate_acme_email "$acme_email" || die "Invalid ILB_ACME_EMAIL value." - assert_domain_available "$browser" "$domain" + assert_domain_available "$browser" "$access_hostname" + fi + + ensure_docker_ready + if container_exists "$browser"; then + die "$browser container already exists. Use the uninstall option first if you want to recreate it." + fi + + check_port_available "$port" + check_port_available "$ssl_port" + + if [[ "$access_mode" != "ip" ]]; then prepare_domain_proxy fi @@ -702,7 +837,7 @@ install_browser() { info "Applying Chromium flags through CHROME_CLI and CHROME_FLAGS: $CHROMIUM_FLAGS" fi - if [[ -n "$domain" ]]; then + if [[ "$access_mode" != "ip" ]]; then browser_network=(--network="$CADDY_NETWORK") browser_ports=(-p "127.0.0.1:${port}:3000" -p "127.0.0.1:${ssl_port}:3001") fi @@ -741,30 +876,36 @@ 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 + if [[ "$access_mode" != "ip" ]] && ! configure_caddy_route "$browser" "$access_hostname" "$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." + die "Trusted HTTPS proxy setup failed. The new $browser container was removed; persistent browser and Caddy data were kept." fi local ip success "================================================" success "Deployment Successful!" - if [[ -n "$domain" ]]; then - echo -e "Browser URL (HTTPS): ${CYAN}https://${domain}${NC}" + if [[ "$access_mode" != "ip" ]]; then + echo -e "Browser URL (HTTPS): ${CYAN}https://${access_hostname}${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}" - if [[ -n "$domain" ]]; then + if [[ "$access_mode" == "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." + elif [[ "$access_mode" == "sslip" ]]; then + echo "Caddy manages certificate issuance and renewal for this sslip.io hostname." + warn "sslip.io is a third-party DNS service, and this hostname exposes the server IP." + warn "Inbound ports 80 and 443 must be publicly reachable." + warn "Public CA or sslip.io rate limits may prevent certificate issuance." + warn "Use a custom domain for stable long-term production deployments." else - echo -e "${YELLOW}Note: Accept the SSL warning in your browser.${NC}" + echo -e "${YELLOW}Note: This mode uses a self-signed certificate, so your browser may show a certificate warning.${NC}" fi success "================================================" } diff --git a/tests/browser_test.sh b/tests/browser_test.sh index f9d30df..dcaac9c 100644 --- a/tests/browser_test.sh +++ b/tests/browser_test.sh @@ -8,7 +8,8 @@ SCRIPT="$PROJECT_DIR/browser.sh" TEMP_DIR="$(mktemp -d)" MOCK_BIN="$TEMP_DIR/bin" MOCK_LOG="$TEMP_DIR/docker.log" -export MOCK_LOG +MOCK_CURL_LOG="$TEMP_DIR/curl.log" +export MOCK_LOG MOCK_CURL_LOG cleanup() { rm -rf "$TEMP_DIR" @@ -66,7 +67,11 @@ set -eu { printf 'docker' for argument in "$@"; do - printf '\t%s' "$argument" + if [[ "$argument" == PASSWORD=* ]]; then + printf '\tPASSWORD=[REDACTED]' + else + printf '\t%s' "$argument" + fi done printf '\n' } >> "$MOCK_LOG" @@ -161,6 +166,21 @@ exec "$@" MOCK_TIMEOUT chmod +x "$MOCK_BIN/timeout" +cat > "$MOCK_BIN/curl" <<'MOCK_CURL' +#!/usr/bin/env bash +set -eu + +{ + printf 'curl' + printf '\t%s' "$@" + printf '\n' +} >> "$MOCK_CURL_LOG" + +[[ "${MOCK_CURL_FAIL:-0}" != "1" ]] || exit 1 +printf '%s\n' "${MOCK_PUBLIC_IPV4-138.124.35.156}" +MOCK_CURL +chmod +x "$MOCK_BIN/curl" + bash -n "$SCRIPT" NO_ACTION_OUTPUT="$TEMP_DIR/no-action.out" @@ -217,11 +237,13 @@ if command -v script >/dev/null 2>&1; then 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" + assert_contains "Select access mode [1]:" "$SEQUENTIAL_CREDENTIALS_OUTPUT" + assert_contains "self-signed certificate" "$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" assert_contains $'CUSTOM_USER=streamed-user' "$MOCK_LOG" + assert_not_contains $'docker\tnetwork\tcreate' "$MOCK_LOG" : > "$MOCK_LOG" USERNAME_ONLY_OUTPUT="$TEMP_DIR/username-only.out" @@ -235,8 +257,22 @@ if command -v script >/dev/null 2>&1; then 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_not_contains "Select access mode" "$ENV_CREDENTIALS_OUTPUT" assert_contains $'CUSTOM_USER=environment-user' "$MOCK_LOG" + + : > "$MOCK_LOG" + STREAMED_SSLIP_OUTPUT="$TEMP_DIR/streamed-sslip.out" + run_streamed_install $'1\nsslip-user\nsslip-password\n2\n' "$STREAMED_SSLIP_OUTPUT" MOCK_PUBLIC_IPV4=138.124.35.156 || fail "Streamed sslip.io selection failed" + assert_contains "Select access mode [1]:" "$STREAMED_SSLIP_OUTPUT" + assert_contains "https://chromium.138-124-35-156.sslip.io" "$STREAMED_SSLIP_OUTPUT" + assert_contains "sslip.io is a third-party DNS service" "$STREAMED_SSLIP_OUTPUT" + assert_contains $'docker\tnetwork\tcreate\t--label\tcom.instant-linux-browser.managed=true\tinstant-linux-browser' "$MOCK_LOG" + + : > "$MOCK_LOG" + STREAMED_DOMAIN_OUTPUT="$TEMP_DIR/streamed-domain.out" + run_streamed_install $'1\ndomain-user\ndomain-password\n3\nstreamed.example.com\n' "$STREAMED_DOMAIN_OUTPUT" || fail "Streamed custom-domain selection failed" + assert_contains "Custom domain for automatic HTTPS:" "$STREAMED_DOMAIN_OUTPUT" + assert_contains "https://streamed.example.com" "$STREAMED_DOMAIN_OUTPUT" else echo "SKIP: streamed TTY tests require the util-linux script command" fi @@ -254,23 +290,152 @@ fi require_root() { :; } detect_arch() { echo x86_64; } +detect_ip() { echo 203.0.113.10; } 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="" +if [[ -n "$TTY_FD" ]]; then + exec {TTY_FD}>&- +fi +TTY_FD="" + +assert_prompt_mode() { + local input="$1" + local expected="$2" + local selected_mode="" + + prompt_access_mode selected_mode <<< "$input" > /dev/null + [[ "$selected_mode" == "$expected" ]] || fail "Interactive selection '$input' did not resolve to $expected" +} + +assert_prompt_mode "" ip +assert_prompt_mode 1 ip +assert_prompt_mode 2 sslip +assert_prompt_mode 3 domain + +PROMPTED_DOMAIN="" +prompt_custom_domain PROMPTED_DOMAIN <<< "Prompted.Example.COM" > /dev/null +[[ "$PROMPTED_DOMAIN" == "Prompted.Example.COM" ]] || fail "Interactive custom-domain input was not captured" + +unset ILB_ACCESS_MODE ILB_DOMAIN +RESOLVED_MODE="" +resolve_access_mode RESOLVED_MODE +[[ "$RESOLVED_MODE" == "ip" ]] || fail "Non-interactive installation did not default to IP mode" + +ILB_DOMAIN= +resolve_access_mode RESOLVED_MODE +[[ "$RESOLVED_MODE" == "ip" ]] || fail "Explicit empty ILB_DOMAIN did not select IP mode" +unset ILB_DOMAIN + ILB_DOMAIN=Browser.Example.COM -resolve_install_domain NORMALIZED_DOMAIN +resolve_access_mode RESOLVED_MODE +[[ "$RESOLVED_MODE" == "domain" ]] || fail "Legacy ILB_DOMAIN did not select domain mode" +LEGACY_DOMAIN="" +resolve_access_hostname chromium "$RESOLVED_MODE" LEGACY_DOMAIN +[[ "$LEGACY_DOMAIN" == "browser.example.com" ]] || fail "Legacy domain was not normalized to lowercase" unset ILB_DOMAIN -[[ "$NORMALIZED_DOMAIN" == "browser.example.com" ]] || fail "Domain was not normalized to lowercase" + +for EXPLICIT_MODE in ip sslip; do + ILB_ACCESS_MODE="$EXPLICIT_MODE" + resolve_access_mode RESOLVED_MODE + [[ "$RESOLVED_MODE" == "$EXPLICIT_MODE" ]] || fail "ILB_ACCESS_MODE=$EXPLICIT_MODE was not preserved" +done + +ILB_ACCESS_MODE=domain +ILB_DOMAIN=Explicit.Example.COM +resolve_access_mode RESOLVED_MODE +EXPLICIT_DOMAIN="" +resolve_access_hostname firefox "$RESOLVED_MODE" EXPLICIT_DOMAIN +[[ "$EXPLICIT_DOMAIN" == "explicit.example.com" ]] || fail "Explicit domain mode did not resolve its hostname" +unset ILB_ACCESS_MODE ILB_DOMAIN + +for INVALID_MODE in "" IP SSLIP custom; do + if (ILB_ACCESS_MODE="$INVALID_MODE" resolve_access_mode INVALID_RESULT) > /dev/null 2>&1; then + fail "Invalid access mode was accepted: ${INVALID_MODE:-empty}" + fi +done + +for CONFLICT_MODE in ip sslip; do + if (ILB_ACCESS_MODE="$CONFLICT_MODE" ILB_DOMAIN=conflict.example.com resolve_access_mode CONFLICT_RESULT) > /dev/null 2>&1; then + fail "ILB_ACCESS_MODE=$CONFLICT_MODE accepted a conflicting ILB_DOMAIN" + fi +done + +: > "$MOCK_LOG" +if (ILB_ACCESS_MODE=IP ILB_USERNAME=admin ILB_PASSWORD=preflight-secret install_browser chromium lscr.io/linuxserver/chromium:latest 3000) > /dev/null 2>&1; then + fail "Invalid access mode reached installation" +fi +[[ ! -s "$MOCK_LOG" ]] || fail "Invalid access mode contacted Docker before failing" + +: > "$MOCK_LOG" +if (ILB_ACCESS_MODE=sslip ILB_DOMAIN=conflict.example.com ILB_USERNAME=admin ILB_PASSWORD=preflight-secret install_browser chromium lscr.io/linuxserver/chromium:latest 3000) > /dev/null 2>&1; then + fail "Conflicting access mode reached installation" +fi +[[ ! -s "$MOCK_LOG" ]] || fail "Conflicting access mode contacted Docker before failing" + +MISSING_DOMAIN_OUTPUT="$TEMP_DIR/missing-domain.out" +if ( + TTY_FD="" + ILB_ACCESS_MODE=domain + unset ILB_DOMAIN + resolve_access_mode MISSING_MODE + resolve_access_hostname chromium "$MISSING_MODE" MISSING_HOSTNAME +) > "$MISSING_DOMAIN_OUTPUT" 2>&1; then + fail "Non-interactive domain mode accepted a missing ILB_DOMAIN" +fi +assert_contains "requires a non-empty ILB_DOMAIN" "$MISSING_DOMAIN_OUTPUT" + +for PUBLIC_IPV4 in 1.1.1.1 8.8.8.8 138.124.35.156 223.255.255.254; do + validate_public_ipv4 "$PUBLIC_IPV4" || fail "Public IPv4 was rejected: $PUBLIC_IPV4" +done + +for NON_PUBLIC_IPV4 in \ + '' '1.2.3' '1.2.3.4.5' '1.2.3.a' '01.2.3.4' '256.1.1.1' \ + '0.1.2.3' '10.1.2.3' '100.64.0.1' '100.127.255.254' '127.0.0.1' \ + '169.254.1.1' '172.16.0.1' '172.31.255.254' '192.0.0.1' '192.0.2.1' \ + '192.88.99.1' '192.168.1.1' '198.18.0.1' '198.19.255.254' \ + '198.51.100.1' '203.0.113.1' '224.0.0.1' '239.255.255.255' '240.0.0.1' '255.255.255.255'; do + if validate_public_ipv4 "$NON_PUBLIC_IPV4"; then + fail "Malformed or non-public IPv4 was accepted: ${NON_PUBLIC_IPV4:-empty}" + fi +done + +: > "$MOCK_CURL_LOG" +export MOCK_PUBLIC_IPV4=138.124.35.156 +CHROMIUM_SSLIP_HOSTNAME="" +FIREFOX_SSLIP_HOSTNAME="" +resolve_access_hostname chromium sslip CHROMIUM_SSLIP_HOSTNAME +resolve_access_hostname firefox sslip FIREFOX_SSLIP_HOSTNAME +[[ "$CHROMIUM_SSLIP_HOSTNAME" == "chromium.138-124-35-156.sslip.io" ]] || fail "Chromium sslip.io hostname was generated incorrectly" +[[ "$FIREFOX_SSLIP_HOSTNAME" == "firefox.138-124-35-156.sslip.io" ]] || fail "Firefox sslip.io hostname was generated incorrectly" +validate_domain "$CHROMIUM_SSLIP_HOSTNAME" || fail "Generated Chromium sslip.io hostname failed domain validation" +validate_domain "$FIREFOX_SSLIP_HOSTNAME" || fail "Generated Firefox sslip.io hostname failed domain validation" +assert_contains $'curl\t-4\t-fsS\t--connect-timeout\t3\t--max-time\t5\thttps://ifconfig.me/ip' "$MOCK_CURL_LOG" + +NON_PUBLIC_DETECTION_OUTPUT="$TEMP_DIR/non-public-detection.out" +if (MOCK_PUBLIC_IPV4=10.0.0.1 detect_public_ipv4 REJECTED_IPV4) > "$NON_PUBLIC_DETECTION_OUTPUT" 2>&1; then + fail "Public IPv4 detection accepted a private address" +fi +assert_contains "requires a publicly routable IPv4 address" "$NON_PUBLIC_DETECTION_OUTPUT" + +EMPTY_DETECTION_OUTPUT="$TEMP_DIR/empty-detection.out" +if (MOCK_PUBLIC_IPV4= detect_public_ipv4 REJECTED_IPV4) > "$EMPTY_DETECTION_OUTPUT" 2>&1; then + fail "Public IPv4 detection accepted an empty response" +fi +assert_contains "requires a publicly routable IPv4 address" "$EMPTY_DETECTION_OUTPUT" + +FAILED_LOOKUP_OUTPUT="$TEMP_DIR/failed-lookup.out" +if (MOCK_CURL_FAIL=1 detect_public_ipv4 REJECTED_IPV4) > "$FAILED_LOOKUP_OUTPUT" 2>&1; then + fail "Public IPv4 detection accepted a failed lookup" +fi +assert_contains "could not detect the server's public IPv4 address within 5 seconds" "$FAILED_LOOKUP_OUTPUT" +unset MOCK_PUBLIC_IPV4 + +: > "$MOCK_LOG" +if (ILB_ACCESS_MODE=sslip MOCK_PUBLIC_IPV4=10.0.0.1 ILB_USERNAME=admin ILB_PASSWORD=preflight-secret install_browser chromium lscr.io/linuxserver/chromium:latest 3000) > /dev/null 2>&1; then + fail "Non-public IPv4 reached installation" +fi +[[ ! -s "$MOCK_LOG" ]] || fail "Non-public IPv4 contacted Docker before failing" for INVALID_DOMAIN in \ 'https://browser.example.com' \ @@ -286,17 +451,15 @@ for INVALID_DOMAIN in \ '-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 + if ( + ILB_DOMAIN="$INVALID_DOMAIN" + resolve_access_mode INVALID_DOMAIN_MODE + resolve_access_hostname chromium "$INVALID_DOMAIN_MODE" 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 @@ -304,6 +467,7 @@ if (ILB_USERNAME=admin ILB_PASSWORD=secret ILB_DOMAIN='browser.example.com;impor fi assert_not_contains $'docker\tpull' "$MOCK_LOG" assert_not_contains $'docker\trun' "$MOCK_LOG" +[[ ! -s "$MOCK_LOG" ]] || fail "Invalid domain contacted Docker before failing" INVALID_EMAIL_OUTPUT="$TEMP_DIR/invalid-email.out" : > "$MOCK_LOG" @@ -312,6 +476,7 @@ if (ILB_USERNAME=admin ILB_PASSWORD=secret ILB_DOMAIN=browser.example.com ILB_AC fi assert_not_contains $'docker\tpull' "$MOCK_LOG" assert_not_contains $'docker\trun' "$MOCK_LOG" +[[ ! -s "$MOCK_LOG" ]] || fail "Invalid ACME email contacted Docker before failing" DUPLICATE_CONFIG_BASE="$TEMP_DIR/duplicate" ( @@ -368,12 +533,17 @@ 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 +unset ILB_ACCESS_MODE 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 "do-not-print-this" "$MOCK_LOG" +assert_contains $'PASSWORD=[REDACTED]' "$MOCK_LOG" 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" +assert_not_contains "Select access mode" "$INSTALL_OUTPUT" +assert_contains "https://203.0.113.10:3001" "$INSTALL_OUTPUT" +assert_contains "HTTP (proxy-only)" "$INSTALL_OUTPUT" +assert_contains "self-signed certificate" "$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" @@ -384,6 +554,39 @@ assert_environment_before_image "CHROME_CLI=--no-sandbox --disable-gpu --disable 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" +assert_not_contains "instant-linux-browser-caddy" "$MOCK_LOG" +assert_not_contains $'docker\tnetwork\t' "$MOCK_LOG" + +CONFIG_BASE="$TEMP_DIR/sslip-chromium" +: > "$MOCK_LOG" +export MOCK_PUBLIC_IPV4=138.124.35.156 +SSLIP_CHROMIUM_OUTPUT="$TEMP_DIR/sslip-chromium.out" +ILB_ACCESS_MODE=sslip ILB_USERNAME=sslip-user ILB_PASSWORD=sslip-password \ + install_browser chromium lscr.io/linuxserver/chromium:latest 3000 > "$SSLIP_CHROMIUM_OUTPUT" 2>&1 +unset ILB_ACCESS_MODE MOCK_PUBLIC_IPV4 +assert_contains "https://chromium.138-124-35-156.sslip.io" "$SSLIP_CHROMIUM_OUTPUT" +assert_not_contains "HTTP (proxy-only)" "$SSLIP_CHROMIUM_OUTPUT" +assert_contains "sslip.io is a third-party DNS service" "$SSLIP_CHROMIUM_OUTPUT" +assert_contains "hostname exposes the server IP" "$SSLIP_CHROMIUM_OUTPUT" +assert_contains "ports 80 and 443 must be publicly reachable" "$SSLIP_CHROMIUM_OUTPUT" +assert_contains "rate limits may prevent certificate issuance" "$SSLIP_CHROMIUM_OUTPUT" +assert_contains "custom domain for stable long-term production" "$SSLIP_CHROMIUM_OUTPUT" +assert_not_contains "sslip-password" "$SSLIP_CHROMIUM_OUTPUT" +assert_not_contains "Enter UI Username" "$SSLIP_CHROMIUM_OUTPUT" +assert_not_contains "Enter UI Password" "$SSLIP_CHROMIUM_OUTPUT" +assert_not_contains "Select access mode" "$SSLIP_CHROMIUM_OUTPUT" +assert_not_contains "Custom domain for automatic HTTPS" "$SSLIP_CHROMIUM_OUTPUT" +assert_contains "chromium.138-124-35-156.sslip.io {" "$CONFIG_BASE/proxy/sites/chromium.caddy" +assert_contains "reverse_proxy chromium:3000" "$CONFIG_BASE/proxy/sites/chromium.caddy" + +SSLIP_CHROMIUM_RUN="$TEMP_DIR/sslip-chromium-run.log" +record_run_with_argument "lscr.io/linuxserver/chromium:latest" "$SSLIP_CHROMIUM_RUN" || fail "sslip.io Chromium run was not recorded" +assert_contains $'\t--network=instant-linux-browser\t' "$SSLIP_CHROMIUM_RUN" +assert_contains $'\t-p\t127.0.0.1:3000:3000\t-p\t127.0.0.1:3001:3001' "$SSLIP_CHROMIUM_RUN" + +SSLIP_CADDY_RUN="$TEMP_DIR/sslip-caddy-run.log" +record_run_with_argument "--name=instant-linux-browser-caddy" "$SSLIP_CADDY_RUN" || fail "sslip.io Caddy run was not recorded" +assert_contains $'\t-p\t80:80\t-p\t443:443' "$SSLIP_CADDY_RUN" CONFIG_BASE="$TEMP_DIR/domain-chromium" : > "$MOCK_LOG" @@ -395,6 +598,10 @@ 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_not_contains "Enter UI Username" "$DOMAIN_CHROMIUM_OUTPUT" +assert_not_contains "Enter UI Password" "$DOMAIN_CHROMIUM_OUTPUT" +assert_not_contains "Select access mode" "$DOMAIN_CHROMIUM_OUTPUT" +assert_not_contains "Custom domain for automatic HTTPS" "$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" @@ -441,7 +648,7 @@ 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 \ +ILB_ACCESS_MODE=domain 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" @@ -451,6 +658,23 @@ record_run_with_argument "lscr.io/linuxserver/firefox:latest" "$DOMAIN_FIREFOX_R 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/sslip-firefox" +: > "$MOCK_LOG" +unset MOCK_EXISTING_CONTAINERS MOCK_NETWORK_EXISTS ILB_DOMAIN +export MOCK_PUBLIC_IPV4=138.124.35.156 +SSLIP_FIREFOX_OUTPUT="$TEMP_DIR/sslip-firefox.out" +ILB_ACCESS_MODE=sslip ILB_USERNAME=firefox-user ILB_PASSWORD=firefox-password \ + install_browser firefox lscr.io/linuxserver/firefox:latest 4000 > "$SSLIP_FIREFOX_OUTPUT" 2>&1 +unset ILB_ACCESS_MODE MOCK_PUBLIC_IPV4 +assert_contains "https://firefox.138-124-35-156.sslip.io" "$SSLIP_FIREFOX_OUTPUT" +assert_contains "firefox.138-124-35-156.sslip.io {" "$CONFIG_BASE/proxy/sites/firefox.caddy" +assert_contains "reverse_proxy firefox:3000" "$CONFIG_BASE/proxy/sites/firefox.caddy" + +SSLIP_FIREFOX_RUN="$TEMP_DIR/sslip-firefox-run.log" +record_run_with_argument "lscr.io/linuxserver/firefox:latest" "$SSLIP_FIREFOX_RUN" || fail "sslip.io Firefox run was not recorded" +assert_contains $'\t--network=instant-linux-browser\t' "$SSLIP_FIREFOX_RUN" +assert_contains $'\t-p\t127.0.0.1:4000:3000\t-p\t127.0.0.1:4001:3001' "$SSLIP_FIREFOX_RUN" + CONFIG_BASE="$TEMP_DIR/reused-caddy" : > "$MOCK_LOG" export MOCK_EXISTING_CONTAINERS=instant-linux-browser-caddy @@ -467,7 +691,7 @@ assert_contains $'docker\texec\t-w\t/etc/caddy\tinstant-linux-browser-caddy\tcad CONFIG_BASE="$TEMP_DIR/uninstall-one-route" ensure_proxy_directories write_caddy_main_config "" -write_caddy_route chromium chromium.example.com +write_caddy_route chromium chromium.138-124-35-156.sslip.io write_caddy_route firefox firefox.example.com : > "$MOCK_LOG" export MOCK_EXISTING_CONTAINERS=chromium,instant-linux-browser-caddy @@ -508,7 +732,7 @@ 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 +unset MOCK_EXISTING_CONTAINERS MOCK_NETWORK_EXISTS MOCK_PUBLIC_IPV4 ILB_ACCESS_MODE ILB_DOMAIN ILB_ACME_EMAIL LOG_OUTPUT="$TEMP_DIR/log.out" export MOCK_LOG_LINE='do-not-print-this'