Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 28 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<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.

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
205 changes: 173 additions & 32 deletions browser.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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=""
Expand All @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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 "================================================"
}
Expand Down
Loading