diff --git a/.env.example b/.env.example index 5834faa3d..6fadc7017 100644 --- a/.env.example +++ b/.env.example @@ -111,3 +111,11 @@ SUPPORT_EMAIL= # API uploads, etc.). Increase if you run into "413 Request Entity Too Large" # errors when syncing large vaults. Defaults to 100MB. MAX_UPLOAD_SIZE_MB=100 + +# Restrict access to the /admin endpoint by client IP at the reverse-proxy layer. Options: +# - Empty = no restriction (default, preserves existing behavior). +# - "private" = allow only loopback + RFC1918 (127/8, 10/8, 172.16/12, 192.168/16). +# - A comma-separated list of CIDRs/IPs (e.g. "192.168.1.0/24,10.0.0.0/8"). +# Requests from non-allowlisted IPs are silently routed to the client app and will +# result in a 404 error. +ADMIN_IP_ALLOWLIST= diff --git a/apps/server/entrypoint.sh b/apps/server/entrypoint.sh index 34c54806e..9d5d56214 100644 --- a/apps/server/entrypoint.sh +++ b/apps/server/entrypoint.sh @@ -19,6 +19,60 @@ fi MAX_UPLOAD_SIZE_MB_VALUE="${MAX_UPLOAD_SIZE_MB:-100}" sed -i "s|__MAX_UPLOAD_SIZE__|${MAX_UPLOAD_SIZE_MB_VALUE}|g" /etc/nginx/nginx.conf +# Build the geo-block rules for the /admin IP allowlist from ADMIN_IP_ALLOWLIST. +# Empty -> "default 0;" (everyone allowed). "private" -> default-deny with +# loopback + RFC1918 carved out. Anything else is treated as a comma-separated +# list of CIDRs/IPs (loopback is always allowed). Tokens that don't look like +# an IPv4/IPv6 address or CIDR are skipped with a warning so a typo can't +# crash-loop nginx; if no valid tokens remain we fail closed (deny everything +# except loopback). When the resulting $admin_block is 1, the /admin location +# quietly routes the request to the client app via @admin_fallback rather than +# emitting a 403/404. +is_valid_cidr() { + case "$1" in + *:*) + printf '%s' "$1" | grep -Eq '^[0-9a-fA-F:]+(/[0-9]{1,3})?$' + ;; + *) + printf '%s' "$1" | grep -Eq '^([0-9]{1,3}\.){3}[0-9]{1,3}(/[0-9]{1,2})?$' + ;; + esac +} + +build_admin_ip_geo_rules() { + local raw="${ADMIN_IP_ALLOWLIST:-}" + case "$(printf '%s' "$raw" | tr '[:upper:]' '[:lower:]')" in + "") + printf '%s' "default 0;" + ;; + private) + printf '%s' "default 1; 127.0.0.0/8 0; 10.0.0.0/8 0; 172.16.0.0/12 0; 192.168.0.0/16 0;" + ;; + *) + local out="default 1; 127.0.0.0/8 0;" + local valid=0 + local IFS=',' + for cidr in $raw; do + cidr=$(printf '%s' "$cidr" | tr -d '[:space:]') + [ -z "$cidr" ] && continue + if is_valid_cidr "$cidr"; then + out="$out $cidr 0;" + valid=$((valid + 1)) + else + printf 'warning: ADMIN_IP_ALLOWLIST: ignoring invalid CIDR/IP %s\n' "$cidr" >&2 + fi + done + if [ "$valid" -eq 0 ]; then + printf 'warning: ADMIN_IP_ALLOWLIST has no valid entries; /admin will fall through to the client app for all non-loopback traffic\n' >&2 + fi + printf '%s' "$out" + ;; + esac +} + +ADMIN_IP_GEO_RULES_VALUE=$(build_admin_ip_geo_rules) +sed -i "s|__ADMIN_IP_GEO_RULES__|${ADMIN_IP_GEO_RULES_VALUE}|g" /etc/nginx/nginx.conf + # Function to check if certificate needs regeneration needs_cert_regeneration() { # If cert doesn't exist, need to generate diff --git a/apps/server/nginx-443.conf b/apps/server/nginx-443.conf index eb9189af3..7aa4c3d78 100644 --- a/apps/server/nginx-443.conf +++ b/apps/server/nginx-443.conf @@ -29,6 +29,14 @@ http { real_ip_header X-Forwarded-For; real_ip_recursive on; + # /admin IP allowlist. Rules rendered by entrypoint.sh from ADMIN_IP_ALLOWLIST: + # empty -> "default 0;" (everyone allowed); otherwise "default 1;" plus an + # allow rule per CIDR. When $admin_block is 1, the /admin location quietly + # routes the request to the client app via @admin_fallback. + geo $admin_block { + __ADMIN_IP_GEO_RULES__ + } + include /etc/nginx/mime.types; default_type application/octet-stream; @@ -91,6 +99,14 @@ http { # Admin interface location /admin { + # If the IP allowlist is set and this client isn't allowed, fall through + # to the client app instead of revealing that /admin exists. The 418 is + # an internal-only signal caught by the error_page handler below. + error_page 418 = @admin_fallback; + if ($admin_block) { + return 418; + } + proxy_pass http://admin; proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; @@ -140,5 +156,21 @@ http { proxy_intercept_errors on; error_page 502 503 504 =503 /status.html; } + + # Internal-only fallback for /admin when the client IP isn't allowlisted. + # Mirrors `location /` so the response is indistinguishable from any other + # client-app request. + location @admin_fallback { + proxy_pass http://client; + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + + proxy_redirect http:// https://; + + proxy_intercept_errors on; + error_page 502 503 504 =503 /status.html; + } } } diff --git a/apps/server/nginx-80-443.conf b/apps/server/nginx-80-443.conf index c5023fc8e..3a963637b 100644 --- a/apps/server/nginx-80-443.conf +++ b/apps/server/nginx-80-443.conf @@ -29,6 +29,14 @@ http { real_ip_header X-Forwarded-For; real_ip_recursive on; + # /admin IP allowlist. Rules rendered by entrypoint.sh from ADMIN_IP_ALLOWLIST: + # empty -> "default 0;" (everyone allowed); otherwise "default 1;" plus an + # allow rule per CIDR. When $admin_block is 1, the /admin location quietly + # routes the request to the client app via @admin_fallback. + geo $admin_block { + __ADMIN_IP_GEO_RULES__ + } + include /etc/nginx/mime.types; default_type application/octet-stream; @@ -82,6 +90,14 @@ http { # Admin interface location /admin { + # If the IP allowlist is set and this client isn't allowed, fall through + # to the client app instead of revealing that /admin exists. The 418 is + # an internal-only signal caught by the error_page handler below. + error_page 418 = @admin_fallback; + if ($admin_block) { + return 418; + } + proxy_pass http://admin; proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; @@ -125,5 +141,19 @@ http { proxy_intercept_errors on; error_page 502 503 504 =503 /status.html; } + + # Internal-only fallback for /admin when the client IP isn't allowlisted. + # Mirrors `location /` so the response is indistinguishable from any other + # client-app request. + location @admin_fallback { + proxy_pass http://client; + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + + proxy_intercept_errors on; + error_page 502 503 504 =503 /status.html; + } } } diff --git a/dockerfiles/all-in-one/config/nginx-443.conf b/dockerfiles/all-in-one/config/nginx-443.conf index d7dfff076..876b7e53f 100644 --- a/dockerfiles/all-in-one/config/nginx-443.conf +++ b/dockerfiles/all-in-one/config/nginx-443.conf @@ -27,6 +27,14 @@ http { real_ip_header X-Forwarded-For; real_ip_recursive on; + # /admin IP allowlist. Rules rendered by the nginx s6 script from ADMIN_IP_ALLOWLIST: + # empty -> "default 0;" (everyone allowed); otherwise "default 1;" plus an + # allow rule per CIDR. When $admin_block is 1, the /admin location quietly + # routes the request to the client app via @admin_fallback. + geo $admin_block { + __ADMIN_IP_GEO_RULES__ + } + include /etc/nginx/mime.types; default_type application/octet-stream; @@ -79,6 +87,14 @@ http { # Admin interface location /admin { + # If the IP allowlist is set and this client isn't allowed, fall through + # to the client app instead of revealing that /admin exists. The 418 is + # an internal-only signal caught by the error_page handler below. + error_page 418 = @admin_fallback; + if ($admin_block) { + return 418; + } + proxy_pass http://admin; proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; @@ -128,5 +144,24 @@ http { proxy_intercept_errors on; error_page 502 503 504 =503 /status.html; } + + # Internal-only fallback for /admin when the client IP isn't allowlisted. + # Mirrors `location /` so the response is indistinguishable from any other + # client-app request. + location @admin_fallback { + proxy_pass http://client; + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + + proxy_hide_header Cache-Control; + proxy_hide_header Pragma; + proxy_hide_header Expires; + add_header Cache-Control $upstream_http_cache_control; + + proxy_intercept_errors on; + error_page 502 503 504 =503 /status.html; + } } } \ No newline at end of file diff --git a/dockerfiles/all-in-one/config/nginx-80-443.conf b/dockerfiles/all-in-one/config/nginx-80-443.conf index ef5e37efd..9dc27acaf 100644 --- a/dockerfiles/all-in-one/config/nginx-80-443.conf +++ b/dockerfiles/all-in-one/config/nginx-80-443.conf @@ -28,6 +28,14 @@ http { real_ip_header X-Forwarded-For; real_ip_recursive on; + # /admin IP allowlist. Rules rendered by the nginx s6 script from ADMIN_IP_ALLOWLIST: + # empty -> "default 0;" (everyone allowed); otherwise "default 1;" plus an + # allow rule per CIDR. When $admin_block is 1, the /admin location quietly + # routes the request to the client app via @admin_fallback. + geo $admin_block { + __ADMIN_IP_GEO_RULES__ + } + include /etc/nginx/mime.types; default_type application/octet-stream; @@ -66,6 +74,14 @@ http { # Admin interface location /admin { + # If the IP allowlist is set and this client isn't allowed, fall through + # to the client app instead of revealing that /admin exists. The 418 is + # an internal-only signal caught by the error_page handler below. + error_page 418 = @admin_fallback; + if ($admin_block) { + return 418; + } + proxy_pass http://admin; proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; @@ -115,6 +131,25 @@ http { proxy_intercept_errors on; error_page 502 503 504 =503 /status.html; } + + # Internal-only fallback for /admin when the client IP isn't allowlisted. + # Mirrors `location /` so the response is indistinguishable from any other + # client-app request. + location @admin_fallback { + proxy_pass http://client; + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + + proxy_hide_header Cache-Control; + proxy_hide_header Pragma; + proxy_hide_header Expires; + add_header Cache-Control $upstream_http_cache_control; + + proxy_intercept_errors on; + error_page 502 503 504 =503 /status.html; + } } # HTTPS server @@ -147,6 +182,14 @@ http { # Admin interface location /admin { + # If the IP allowlist is set and this client isn't allowed, fall through + # to the client app instead of revealing that /admin exists. The 418 is + # an internal-only signal caught by the error_page handler below. + error_page 418 = @admin_fallback; + if ($admin_block) { + return 418; + } + proxy_pass http://admin; proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; @@ -196,5 +239,24 @@ http { proxy_intercept_errors on; error_page 502 503 504 =503 /status.html; } + + # Internal-only fallback for /admin when the client IP isn't allowlisted. + # Mirrors `location /` so the response is indistinguishable from any other + # client-app request. + location @admin_fallback { + proxy_pass http://client; + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + + proxy_hide_header Cache-Control; + proxy_hide_header Pragma; + proxy_hide_header Expires; + add_header Cache-Control $upstream_http_cache_control; + + proxy_intercept_errors on; + error_page 502 503 504 =503 /status.html; + } } } \ No newline at end of file diff --git a/dockerfiles/all-in-one/s6-scripts/nginx/run b/dockerfiles/all-in-one/s6-scripts/nginx/run index 2f06ccd78..616b09cfa 100644 --- a/dockerfiles/all-in-one/s6-scripts/nginx/run +++ b/dockerfiles/all-in-one/s6-scripts/nginx/run @@ -29,6 +29,60 @@ else cp /etc/nginx/nginx-80-443.conf /etc/nginx/nginx.conf fi +# Build the geo-block rules for the /admin IP allowlist from ADMIN_IP_ALLOWLIST. +# Empty -> "default 0;" (everyone allowed). "private" -> default-deny with +# loopback + RFC1918 carved out. Anything else is treated as a comma-separated +# list of CIDRs/IPs (loopback is always allowed). Tokens that don't look like +# an IPv4/IPv6 address or CIDR are skipped with a warning so a typo can't +# crash-loop nginx; if no valid tokens remain we fail closed (deny everything +# except loopback). When the resulting $admin_block is 1, the /admin location +# quietly routes the request to the client app via @admin_fallback rather than +# emitting a 403/404. +is_valid_cidr() { + case "$1" in + *:*) + printf '%s' "$1" | grep -Eq '^[0-9a-fA-F:]+(/[0-9]{1,3})?$' + ;; + *) + printf '%s' "$1" | grep -Eq '^([0-9]{1,3}\.){3}[0-9]{1,3}(/[0-9]{1,2})?$' + ;; + esac +} + +build_admin_ip_geo_rules() { + local raw="${ADMIN_IP_ALLOWLIST:-}" + case "$(printf '%s' "$raw" | tr '[:upper:]' '[:lower:]')" in + "") + printf '%s' "default 0;" + ;; + private) + printf '%s' "default 1; 127.0.0.0/8 0; 10.0.0.0/8 0; 172.16.0.0/12 0; 192.168.0.0/16 0;" + ;; + *) + local out="default 1; 127.0.0.0/8 0;" + local valid=0 + local IFS=',' + for cidr in $raw; do + cidr=$(printf '%s' "$cidr" | tr -d '[:space:]') + [ -z "$cidr" ] && continue + if is_valid_cidr "$cidr"; then + out="$out $cidr 0;" + valid=$((valid + 1)) + else + printf 'warning: ADMIN_IP_ALLOWLIST: ignoring invalid CIDR/IP %s\n' "$cidr" >&2 + fi + done + if [ "$valid" -eq 0 ]; then + printf 'warning: ADMIN_IP_ALLOWLIST has no valid entries; /admin will fall through to the client app for all non-loopback traffic\n' >&2 + fi + printf '%s' "$out" + ;; + esac +} + +ADMIN_IP_GEO_RULES_VALUE=$(build_admin_ip_geo_rules) +sed -i "s|__ADMIN_IP_GEO_RULES__|${ADMIN_IP_GEO_RULES_VALUE}|g" /etc/nginx/nginx.conf + echo "Starting Nginx reverse proxy..." # Set nginx error log level based on verbosity diff --git a/docs/installation/docker-compose/advanced/admin-access.md b/docs/installation/docker-compose/advanced/admin-access.md new file mode 100644 index 000000000..eb7822457 --- /dev/null +++ b/docs/installation/docker-compose/advanced/admin-access.md @@ -0,0 +1,63 @@ +--- +layout: default +title: Admin access +parent: Advanced +grand_parent: Docker Compose +nav_order: 5 +--- + +# Admin access + +By default the admin panel at `/admin` is reachable from the public internet, alongside the regular client app. This is intentional and safe to leave as-is for most installations: + +- Sign-in requires the admin password you set during installation (and optional 2FA). +- The admin account is protected against brute force: after 10 failed sign-in attempts the account is locked for 30 minutes. + +If you'd still rather not expose `/admin` to the open internet, for example if your AliasVault server is only meant to be reached from a home network or VPN, you can restrict it by client IP at the reverse-proxy layer using the `ADMIN_IP_ALLOWLIST` environment variable. + +## How it works + +When a request to `/admin` comes from an IP that is **not** on the allowlist, the reverse proxy quietly forwards it to the regular client app instead of returning a 403/404. From the outside, `/admin` looks identical to any other path on the public surface. There's no signal that an admin panel exists at that URL. + +Requests from allowlisted IPs reach the admin panel as normal. + +## Options + +Set `ADMIN_IP_ALLOWLIST` in the `environment:` section of your `docker-compose.yml` to one of: + +| Value | Effect | +|---|---| +| _empty_ (default) | No restriction. `/admin` is reachable from anywhere. | +| `private` | Only loopback and RFC1918 addresses are allowed (`127.0.0.0/8`, `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`). | +| Comma-separated list of CIDRs/IPs | Only the listed ranges are allowed (loopback is always allowed). | + +### Examples + +```yaml +# ... + environment: + # Only allow access from a specific home IP and a corporate /24: + ADMIN_IP_ALLOWLIST: "203.0.113.42,198.51.100.0/24" +# ... +``` + +```yaml +# ... + environment: + # Only allow access from machines on the local network: + ADMIN_IP_ALLOWLIST: "private" +# ... +``` + +## Apply the change + +After updating `docker-compose.yml`, the container must be recreated for the new environment value to take effect: + +```bash +docker compose down +docker compose up -d +``` + +## Behind another reverse proxy + +If AliasVault is itself running behind another reverse proxy (Cloudflare, Traefik, an upstream nginx, etc.), the allowlist is matched against the client IP forwarded via `X-Forwarded-For`. Make sure your upstream proxy is setting that header correctly, otherwise every request will appear to come from the proxy's own address. diff --git a/docs/installation/script/advanced/admin-access.md b/docs/installation/script/advanced/admin-access.md new file mode 100644 index 000000000..86662dade --- /dev/null +++ b/docs/installation/script/advanced/admin-access.md @@ -0,0 +1,42 @@ +--- +layout: default +title: Admin access +parent: Advanced +grand_parent: Install Script +nav_order: 5 +--- + +# Admin access + +By default the admin panel at `/admin` is reachable from the public internet, alongside the regular client app. This is intentional and safe to leave as-is for most installations: + +- Sign-in requires the admin password you set during installation (and optional 2FA). +- The admin account is protected against brute force: after 10 failed sign-in attempts the account is locked for 30 minutes. + +If you'd still rather not expose `/admin` to the open internet — for example if your AliasVault server is only meant to be reached from a home network or VPN — you can restrict it by client IP at the reverse-proxy layer. + +## How it works + +When a request to `/admin` comes from an IP that is **not** on the allowlist, the reverse proxy quietly forwards it to the regular client app instead of returning a 403/404. From the outside, `/admin` looks identical to any other path on the public surface. There's no signal that an admin panel exists at that URL. + +Requests from allowlisted IPs reach the admin panel as normal. + +## Configure + +Run the install script — it walks you through the options, validates your input, and offers to restart the containers for you: + +```bash +$ ./install.sh configure-admin-access +``` + +You'll be prompted to choose one of: + +| Option | Effect | +|---|---| +| No restriction (default) | `/admin` is reachable from anywhere. | +| Private networks only | Only loopback and RFC1918 addresses are allowed (`127.0.0.0/8`, `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`). | +| Custom CIDRs/IPs | Only the listed ranges are allowed (loopback is always allowed). | + +## Behind another reverse proxy + +If AliasVault is itself running behind another reverse proxy (Cloudflare, Traefik, an upstream nginx, etc.), the allowlist is matched against the client IP forwarded via `X-Forwarded-For`. Make sure your upstream proxy is setting that header correctly, otherwise every request will appear to come from the proxy's own address. diff --git a/install.sh b/install.sh index 41f5bd782..b90dd255e 100755 --- a/install.sh +++ b/install.sh @@ -61,6 +61,7 @@ show_usage() { printf " configure-email Configure email domains for receiving emails\n" printf " configure-registration Configure new account registration (enable or disable)\n" printf " configure-ip-logging Configure IP address logging (enable or disable)\n" + printf " configure-admin-access Configure /admin IP allowlist (restrict admin access by client IP)\n" printf " reset-admin-password Reset admin password\n" printf " uninstall Uninstall AliasVault\n" printf "\n" @@ -164,6 +165,10 @@ parse_args() { COMMAND="configure-ip-logging" shift ;; + configure-admin-access|admin-access) + COMMAND="configure-admin-access" + shift + ;; start|s) COMMAND="start" shift @@ -970,6 +975,9 @@ main() { "configure-ip-logging") handle_ip_logging_configuration ;; + "configure-admin-access") + handle_admin_access_configuration + ;; "start") handle_start ;; @@ -3297,6 +3305,135 @@ handle_ip_logging_configuration() { esac } +# Validate that a token looks like an IPv4/IPv6 address with optional CIDR mask. +# IPv4 enforces each octet 0-255 and mask 0-32. IPv6 is checked loosely (hex +# segments + colons) but with a strict 0-128 mask bound; nginx will reject any +# malformed IPv6 at startup. +is_valid_cidr() { + case "$1" in + *:*) + printf '%s' "$1" | grep -Eq '^[0-9a-fA-F:]+(/(12[0-8]|1[01][0-9]|[1-9]?[0-9]))?$' + ;; + *) + printf '%s' "$1" | grep -Eq '^(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])(\.(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])){3}(/(3[0-2]|[12]?[0-9]))?$' + ;; + esac +} + +# Function to handle /admin IP allowlist configuration +handle_admin_access_configuration() { + printf "${YELLOW}+++ Admin Access Configuration +++${NC}\n" + printf "\n" + + # Check if AliasVault is installed + if [ ! -f "docker-compose.yml" ]; then + printf "${RED}Error: AliasVault must be installed first.${NC}\n" + exit 1 + fi + + CURRENT_SETTING=$(grep "^ADMIN_IP_ALLOWLIST=" "$ENV_FILE" | cut -d '=' -f2-) + + printf "${CYAN}About Admin Access:${NC}\n" + printf "By default /admin is reachable from anywhere. You can optionally restrict it by client IP. \n" + printf "Requests from non-allowlisted IPs are silently routed to the client app and will throw a 404.\n" + printf "\n" + printf "${CYAN}Current Configuration:${NC}\n" + if [ -z "$CURRENT_SETTING" ]; then + printf "Admin IP Allowlist: ${GREEN}No restriction${NC} (reachable from anywhere)\n" + else + printf "Admin IP Allowlist: ${CYAN}${CURRENT_SETTING}${NC}\n" + fi + printf "\n" + printf "${CYAN}Options:${NC}\n" + printf "1) No restriction — /admin is reachable from anywhere (default)\n" + printf "2) Private networks only — allow loopback + RFC1918 (127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16)\n" + printf "3) Custom — comma-separated CIDRs/IPs (e.g. 203.0.113.42,198.51.100.0/24)\n" + printf "4) Cancel\n" + printf "\n" + + read -p "Select an option [1-4]: " allowlist_option + + NEW_VALUE="" + case $allowlist_option in + 1) + NEW_VALUE="" + ;; + 2) + NEW_VALUE="private" + ;; + 3) + while true; do + read -p "Enter comma-separated CIDRs/IPs: " CUSTOM_LIST + if [ -z "$CUSTOM_LIST" ]; then + printf "${YELLOW}> List cannot be empty. Use option 1 to remove restrictions.${NC}\n" + continue + fi + + INVALID="" + CLEANED="" + OLD_IFS="$IFS" + IFS=',' + for token in $CUSTOM_LIST; do + token=$(printf '%s' "$token" | tr -d '[:space:]') + [ -z "$token" ] && continue + if is_valid_cidr "$token"; then + if [ -z "$CLEANED" ]; then + CLEANED="$token" + else + CLEANED="$CLEANED,$token" + fi + else + if [ -z "$INVALID" ]; then + INVALID="$token" + else + INVALID="$INVALID, $token" + fi + fi + done + IFS="$OLD_IFS" + + if [ -n "$INVALID" ]; then + printf "${YELLOW}> Invalid entries: ${INVALID}${NC}\n" + printf "${YELLOW}> Each entry must be an IPv4/IPv6 address with an optional /mask. Try again.${NC}\n" + continue + fi + + NEW_VALUE="$CLEANED" + break + done + ;; + 4) + printf "${YELLOW}Admin access configuration cancelled.${NC}\n" + return 0 + ;; + *) + printf "${RED}Invalid option selected.${NC}\n" + return 1 + ;; + esac + + update_env_var "ADMIN_IP_ALLOWLIST" "$NEW_VALUE" + + printf "\n${YELLOW}Warning: Docker containers need to be restarted to apply these changes.${NC}\n" + read -p "Restart now? (y/n): " restart_confirm + + if [ "$restart_confirm" != "y" ] && [ "$restart_confirm" != "Y" ]; then + printf "${YELLOW}Please restart manually to apply the changes.${NC}\n" + exit 0 + fi + + handle_restart + + printf "\n" + if [ -z "$NEW_VALUE" ]; then + print_success_box "Admin access restriction removed — /admin is now reachable from anywhere." + elif [ "$NEW_VALUE" = "private" ]; then + print_success_box "Admin access restricted to loopback + private networks." + else + print_success_box "Admin access restricted to: ${NEW_VALUE}" + fi +} + check_and_populate_env() { printf "${CYAN}ℹ Checking .env values...${NC} ${GREEN}✓${NC}\n" @@ -3374,6 +3511,12 @@ check_and_populate_env() { update_env_var "MAX_UPLOAD_SIZE_MB" "100" printf " Set MAX_UPLOAD_SIZE_MB\n" fi + + # ADMIN_IP_ALLOWLIST + if ! grep -q "^ADMIN_IP_ALLOWLIST=" "$ENV_FILE" 2>/dev/null; then + update_env_var "ADMIN_IP_ALLOWLIST" "" + printf " Set ADMIN_IP_ALLOWLIST\n" + fi } main "$@"