#!/usr/bin/env bash # Hostlet installer (docs/STAGES.md, B12). # curl -fsSL https://get.hostlet.me | sudo bash [-s -- --domain example.com] # sudo ./install.sh --binary ./hostlet-linux-amd64 [--domain example.com] [--dev] # The published copy knows its channel and gives the newest stable release, checked against the # publisher's signature first, and the panel's updates; HOSTLET_RELEASES= names another one. # --binary installs a build brought by hand (scripts/server.sh builds and brings it); this copy # of the script has no channel, so that panel is updated with `hostlet upgrade `, or by # another run of scripts/server.sh. Without --domain the panel opens at https://:9999 and a # site at the domain added to it; --domain puts the panel on panel.example.com and sites on # .example.com from the start. --dev serves sites over plain HTTP (a private test machine # without real certificates). --tools only prepares the server (Docker 28 or newer, # Compose, Buildx, Railpack, restic, Git, time sync) and names the ports it needs: the panel runs # it on a server it adds or moves to. On a server with the panel, a newer build goes in through # `hostlet upgrade`, which copies the database first and puts the version before back if the new # one doesn't start. set -euo pipefail # The channel and the publisher's key; `hostlet-release publish` writes them in when it puts this # script out. CHANNEL='https://get.hostlet.me' [[ $CHANNEL == http* ]] || CHANNEL="" RELEASES="${HOSTLET_RELEASES:-$CHANNEL}" [[ $RELEASES != off ]] || RELEASES="" PUBLISHER_KEY='-----BEGIN PUBLIC KEY----- MCowBQYDK2VwAyEASe3SJy26TudRT+5zXFRZoltN2yHpbh+ClHaMoTcGrXE= -----END PUBLIC KEY-----' # On a Mac the panel runs in a Linux machine of its own, made as the person, not as root. if [[ "$(uname -s)" == Darwin ]]; then [[ -n $RELEASES ]] || { echo "On a Mac, install from the channel: curl -fsSL https://get.hostlet.me | bash" >&2; exit 1; } if [[ $EUID -eq 0 && -n ${SUDO_USER:-} ]]; then exec sudo -u "$SUDO_USER" -H bash -c 'curl -fsSL "$1/mac.sh" | bash' _ "$RELEASES" fi curl -fsSL "$RELEASES/mac.sh" | bash exit fi say() { printf '\033[1;34m==>\033[0m %s\n' "$*"; } die() { printf '\033[1;31mError:\033[0m %s\n' "$*" >&2; exit 1; } warn() { printf '\033[1;33mNote:\033[0m %s\n' "$*"; } value() { [[ -n ${2:-} ]] || die "$1 needs a value."; } BINARY="" DOMAIN="" DEV=0 TOOLS=0 while [[ $# -gt 0 ]]; do case "$1" in --binary) value "$@"; BINARY="$2"; shift 2 ;; --domain) value "$@"; DOMAIN="$2"; shift 2 ;; --dev) DEV=1; shift ;; --tools) TOOLS=1; shift ;; *) echo "Unknown option: $1" >&2; exit 2 ;; esac done [[ $EUID -eq 0 ]] || die "Run as root (sudo)." [[ -z "$BINARY" || -f "$BINARY" ]] || die "No such file: $BINARY" # Only a published copy has a channel of its own: this file names no address that could be taken # over later. [[ $TOOLS -eq 1 || -n $BINARY || -n $RELEASES ]] || die "Give the panel's build with --binary (scripts/server.sh builds and brings it), or a release channel you trust with HOSTLET_RELEASES=." if [[ -n $DOMAIN ]]; then DOMAIN="${DOMAIN,,}" DOMAIN="${DOMAIN%.}" [[ $DOMAIN =~ ^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9-]{2,63}$ ]] \ || die "--domain takes a domain name such as example.com (punycode for other alphabets)." fi # A server with a settings file is an upgrade: its addresses and channel stay as they are. FRESH=1 if [[ -f /etc/hostlet/hostlet.env ]]; then FRESH=0 fi if [[ -n $DOMAIN && $FRESH -eq 0 ]]; then warn "--domain is for a new install: this server keeps its addresses (HOSTLET_BASE_DOMAIN in /etc/hostlet/hostlet.env)." DOMAIN="" fi . /etc/os-release case "$ID:$VERSION_ID" in ubuntu:22.04|ubuntu:24.04|ubuntu:26.04|debian:12|debian:13) ;; *) die "Supported systems: Ubuntu 22.04/24.04/26.04, Debian 12/13. This is $PRETTY_NAME." ;; esac case "$(uname -m)" in x86_64) ARCH=amd64 ;; aarch64) ARCH=arm64 ;; *) die "Supported processors: x86-64 and ARM64. This is $(uname -m)." ;; esac # The same requirements as `hostlet agent check`: memory and disk are advice, ports are not. memory_mb=$(awk '/^MemTotal:/ {print int($2 / 1024)}' /proc/meminfo) (( memory_mb >= 1000 )) || warn "The server has ${memory_mb} MB of memory; 1 GB or more is recommended." disk_gb=$(df -Pk /var/lib | awk 'NR == 2 {print int($4 / 1048576)}') (( disk_gb >= 10 )) || warn "/var/lib has ${disk_gb} GB free; 10 GB or more is recommended." # On an upgrade the panel's own name servers hold port 53; systemd-resolved's stubs # (127.0.0.53%lo, 127.0.0.54) don't get in the way. if [[ ! -x /usr/local/bin/hostlet ]] && ss -lunH 'sport = :53' | grep -vqE '127\.0\.0\.5[34](%[^:]+)?:53'; then warn "Something listens on port 53: the panel's own name servers won't start until it stops." fi if ! timeout 5 bash -c 'exec 3<>/dev/tcp/gmail-smtp-in.l.google.com/25' 2>/dev/null; then warn "Outgoing port 25 is closed: mail will need a relay (Mail → Settings) or the provider opening the port." fi # On an upgrade our own proxy already holds 80/443; that's fine. OWN_PROXY=0 if command -v docker >/dev/null 2>&1 && [[ "$(docker inspect -f '{{.State.Running}}' hostlet-caddy 2>/dev/null)" == "true" ]]; then OWN_PROXY=1 fi for port in 80 443; do if [[ $OWN_PROXY -eq 0 ]] && ss -ltnH "sport = :$port" | grep -q .; then # A server added to a panel can still join; it can't serve sites until the port is free. [[ $TOOLS -eq 1 ]] && { warn "Port $port is busy: this server can't serve sites until the program using it stops."; continue; } die "Port $port is busy. Hostlet needs ports 80 and 443 for your sites. Stop the program using it (for example nginx or apache) and run the installer again." fi done # The panel's entrance by the server's address, https://: (HOSTLET_DIRECT_PORT). # A new server has no settings file yet. DIRECT_PORT=$(sed -n 's/^HOSTLET_DIRECT_PORT=//p' /etc/hostlet/hostlet.env 2>/dev/null | tail -1 || true) DIRECT_PORT=${DIRECT_PORT:-9999} # On a server without the panel yet something else may hold it: the entrance stays shut till then. if [[ $TOOLS -eq 0 && $DIRECT_PORT != off && $FRESH -eq 1 ]] && ss -ltnH "sport = :$DIRECT_PORT" | grep -q .; then warn "Port $DIRECT_PORT is busy: the panel's entrance by IP won't open until the program using it stops, or set HOSTLET_DIRECT_PORT in /etc/hostlet/hostlet.env." fi # The address the server reaches the internet from; behind a cloud's NAT a private one (NAT_IP). ROUTE_IP=$(ip -4 route get 1.1.1.1 2>/dev/null | awk '{for (i = 1; i < NF; i++) if ($i == "src") print $(i + 1)}' || true) if [[ -z $ROUTE_IP && $TOOLS -eq 0 && $FRESH -eq 1 ]]; then die "This server has no IPv4 route to the internet (ip -4 route get 1.1.1.1): the panel needs a public IPv4 address." fi # The build to install: the file given, or the channel's newest stable release. It is tried before # anything on the server changes and kept next to its place, so putting it in is one rename. WORK="$(mktemp -d)" STAGED="" trap 'rm -rf "$WORK"; [[ -z $STAGED ]] || rm -f "$STAGED"' EXIT SOURCE="$BINARY" # Right after a fresh server boots, apt-daily, unattended-upgrades or cloud-init hold apt's locks # for a while: apt waits up to five minutes for them rather than failing. apt_get() { DEBIAN_FRONTEND=noninteractive apt-get -o DPkg::Lock::Timeout=300 "$@"; } # apt_update fetches the package lists. Their lock isn't covered by apt's timeout everywhere, so # it is waited for here. apt_update() { local try for try in $(seq 1 60); do if apt_get update -qq 2>"$WORK/apt.log"; then cat "$WORK/apt.log" >&2 return 0 fi grep -q 'Could not get lock' "$WORK/apt.log" || break sleep 5 done cat "$WORK/apt.log" >&2 return 1 } # apt_install installs packages. The lists are fetched once per run first: a provider's image may # come with them stale or emptied. APT_LISTS=0 apt_install() { if [[ $APT_LISTS -eq 0 ]]; then apt_update || return APT_LISTS=1 fi apt_get install -y -qq "$@" >/dev/null } # download_release puts the newest stable release in $BINARY once its signature and sum check out. download_release() { [[ $PUBLISHER_KEY == *"BEGIN PUBLIC KEY"* ]] || die "This copy of the installer has no publisher key: pass --binary, or use the installer from $RELEASES." if ! command -v curl >/dev/null 2>&1 || ! command -v openssl >/dev/null 2>&1; then apt_install curl openssl fi local tmp="$WORK" block path sum version curl -fsSL "$RELEASES/stable.json" -o "$tmp/stable.json" || die "Can't reach $RELEASES. Check the server's internet connection." curl -fsSL "$RELEASES/stable.json.sig" -o "$tmp/stable.json.sig" || die "The release has no signature at $RELEASES." { printf 'hostlet release v1\n'; cat "$tmp/stable.json"; } > "$tmp/signed" base64 -d "$tmp/stable.json.sig" > "$tmp/sig" 2>/dev/null || die "The release's signature is malformed. Nothing was installed." printf '%s\n' "$PUBLISHER_KEY" > "$tmp/key.pem" openssl pkeyutl -verify -pubin -inkey "$tmp/key.pem" -rawin -in "$tmp/signed" -sigfile "$tmp/sig" >/dev/null 2>&1 \ || die "The release's signature doesn't match the publisher's key. Nothing was installed." # The manifest is the publisher's tool's own output: one field per line. block=$(sed -n "/\"linux-$ARCH\": {/,/}/p" "$tmp/stable.json") path=$(sed -nE 's/.*"path": "([^"]+)".*/\1/p' <<<"$block") sum=$(sed -nE 's/.*"sha256": "([0-9a-f]{64})".*/\1/p' <<<"$block") version=$(sed -nE 's/^ "version": "([^"]+)".*/\1/p' "$tmp/stable.json") [[ -n $path && -n $sum && $path != *..* ]] || die "The release has no build for $ARCH." say "Downloading Hostlet $version" curl -fsSL "$RELEASES/$path" -o "$tmp/hostlet" || die "The download failed. Run the installer again." echo "$sum $tmp/hostlet" | sha256sum -c --quiet - || die "The download is damaged. Run the installer again." BINARY="$tmp/hostlet" SOURCE="Hostlet $version from $RELEASES" } # version_lt A B: A is older than B, compared as the panel does (release.Compare): the numbers, # then a -suffix before the plain version. version_lt() { local a="${1%%-*}" b="${2%%-*}" if [[ $a != "$b" ]]; then [[ "$(printf '%s\n%s\n' "$a" "$b" | sort -V | head -1)" == "$a" ]] return fi local sa="${1#"$a"}" sb="${2#"$b"}" [[ -n $sa && $sa != "$sb" ]] && { [[ -z $sb ]] || [[ "$(printf '%s\n%s\n' "$sa" "$sb" | sort -V | head -1)" == "$sa" ]]; } } # stage_binary stops on a build for another processor, one without a version (unless --dev) and # one older than the panel here (INSTALLED), whose database may be newer than it reads. INSTALLED="" stage_binary() { STAGED="$(mktemp /usr/local/bin/.hostlet.XXXXXX)" install -m 0755 "$BINARY" "$STAGED" PANEL_VERSION="$("$STAGED" version 2>/dev/null)" || die "$SOURCE doesn't run on this server: it has to be the build for linux/$ARCH." if [[ $PANEL_VERSION == dev ]]; then [[ $DEV -eq 1 ]] || die "$SOURCE is a development build without a version. Build it with scripts/server.sh or make panel-linux, or pass --dev on a test machine." elif [[ ! $PANEL_VERSION =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then die "$SOURCE isn't a build of the Hostlet panel." fi INSTALLED="$(/usr/local/bin/hostlet version 2>/dev/null || true)" if [[ $INSTALLED =~ ^[0-9] && $PANEL_VERSION != dev ]] && version_lt "$PANEL_VERSION" "$INSTALLED"; then die "This server has Hostlet $INSTALLED and $SOURCE is the older $PANEL_VERSION: the panel's database may already be newer than it reads. Install $INSTALLED or newer." fi } if [[ $TOOLS -eq 0 ]]; then [[ -n $BINARY ]] || download_release stage_binary fi # Docker before 28 lets machines on the same network reach ports bound to 127.0.0.1 and the # containers' published ports directly: the databases and apps the panel keeps to the server. DOCKER_MIN=28 docker_major() { local v v="$(docker version --format '{{.Server.Version}}' 2>/dev/null || true)" [[ ${v%%.*} =~ ^[0-9]+$ ]] && echo "${v%%.*}" || echo 0 } # docker_repo installs Docker from Docker's own repository: the current stable release, kept up # to date by apt. Packages given with a trailing "-" are removed in the same transaction, and apt # fetches everything before it removes anything. When it fails, DOCKER_REPO_FAILED says what. DOCKER_REPO_FAILED="" docker_repo() { install -d -m 0755 /etc/apt/keyrings if ! curl -fsSL --max-time 60 "https://download.docker.com/linux/$ID/gpg" -o /etc/apt/keyrings/docker.asc; then DOCKER_REPO_FAILED="its key didn't download from download.docker.com" return 1 fi chmod a+r /etc/apt/keyrings/docker.asc echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/$ID $VERSION_CODENAME stable" > /etc/apt/sources.list.d/docker.list # apt-get update succeeds when one source can't be fetched: its packages are what tells. apt_update || true APT_LISTS=1 if [[ "$(apt-cache policy docker-ce 2>/dev/null | awk '/Candidate:/ {print $2}')" != [0-9]* ]]; then DOCKER_REPO_FAILED="its package list didn't download from download.docker.com" elif apt_get install -y -qq docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin "$@" >/dev/null; then return 0 else DOCKER_REPO_FAILED="apt couldn't install its packages (apt's message is above)" fi rm -f /etc/apt/sources.list.d/docker.list apt_update || true return 1 } # distro_docker installs the distribution's Docker when Docker's repository can't be used and the # package (DISTRO_DOCKER) is new enough. DISTRO_DOCKER="" distro_docker() { DISTRO_DOCKER="$(apt-cache policy docker.io 2>/dev/null | awk '/Candidate:/ {print $2}')" [[ ${DISTRO_DOCKER%%.*} =~ ^[0-9]+$ ]] && (( ${DISTRO_DOCKER%%.*} >= DOCKER_MIN )) || return 1 say "Docker's repository can't be used: installing the distribution's Docker $DISTRO_DOCKER" apt_get install -y -qq docker.io docker-buildx >/dev/null } install_docker() { apt_install ca-certificates curl docker_repo || distro_docker \ || die "Docker's repository can't be used ($DOCKER_REPO_FAILED) and this system's Docker (${DISTRO_DOCKER:-none}) is older than $DOCKER_MIN, which the panel needs. Check the server's connection and run the installer again." } # replace_docker puts Docker's own release in place of one older than DOCKER_MIN, when nothing of # the panel's runs here yet. One apt transaction swaps the packages, so the old Docker and its # containers keep running when the new one can't be had. replace_docker() { local old="$1" pkg remove="" say "Docker $old is older than $DOCKER_MIN: replacing it with Docker's own release" apt_install ca-certificates curl for pkg in docker.io docker-doc docker-compose docker-compose-v2 docker-buildx podman-docker containerd runc; do if dpkg -s "$pkg" >/dev/null 2>&1; then remove="$remove $pkg-" fi done # shellcheck disable=SC2086 # package names, split on purpose docker_repo $remove && return # The distribution's newer package replaces its own older one only. dpkg -s docker.io >/dev/null 2>&1 && distro_docker && return die "Docker's repository can't be used ($DOCKER_REPO_FAILED) and this system has no Docker $DOCKER_MIN or newer (${DISTRO_DOCKER:-none}). Docker $old is still in place and running. Check the server's connection and run the installer again." } # Railpack builds code that has no Dockerfile. The version must match HOSTLET_RAILPACK_FRONTEND. RAILPACK_VERSION="0.40.1" install_railpack() { local arch sum case "$(uname -m)" in x86_64) arch="x86_64"; sum="2842de93e68713af9037e0bc0a398d7da78f3b96aa4804303a638db2bc69bd30" ;; aarch64) arch="arm64"; sum="c24a064b586b8f4f8c2fab44dd5ef19253e4c6cc4e1df793b3ae19cd87f7a5d4" ;; *) die "Unsupported CPU: $(uname -m)" ;; esac local tmp tmp="$(mktemp -d)" curl -fsSL -o "$tmp/railpack.tgz" "https://github.com/railwayapp/railpack/releases/download/v$RAILPACK_VERSION/railpack-v$RAILPACK_VERSION-$arch-unknown-linux-musl.tar.gz" echo "$sum $tmp/railpack.tgz" | sha256sum -c --quiet - || die "The Railpack download is damaged. Run the installer again." tar -xzf "$tmp/railpack.tgz" -C "$tmp" railpack install -m 0755 "$tmp/railpack" /usr/local/bin/railpack rm -rf "$tmp" } # restic keeps the backups of projects and databases. RESTIC_VERSION="0.19.1" install_restic() { local arch sum case "$(uname -m)" in x86_64) arch="amd64"; sum="f415415624dcc452f2a02b8c33641791a8c6d6d3b65bbb3543fcf9a25151585c" ;; aarch64) arch="arm64"; sum="a5f64aaab53d51e311fa3829124c5b703f2d14cf187d8640b6be3b2b49376465" ;; *) die "Unsupported CPU: $(uname -m)" ;; esac command -v bunzip2 >/dev/null 2>&1 || DEBIAN_FRONTEND=noninteractive apt-get install -y -qq bzip2 >/dev/null local tmp tmp="$(mktemp -d)" curl -fsSL -o "$tmp/restic.bz2" "https://github.com/restic/restic/releases/download/v$RESTIC_VERSION/restic_${RESTIC_VERSION}_linux_$arch.bz2" echo "$sum $tmp/restic.bz2" | sha256sum -c --quiet - || die "The restic download is damaged. Run the installer again." bunzip2 "$tmp/restic.bz2" install -m 0755 "$tmp/restic" /usr/local/bin/restic rm -rf "$tmp" } # Docker Compose v2 checks and normalises compose files; the panel starts the services itself. # Debian's own packages don't have it, so the release binary is the last resort. COMPOSE_VERSION="5.6.0" install_compose() { apt-get update -qq || true if DEBIAN_FRONTEND=noninteractive apt-get install -y -qq docker-compose-plugin >/dev/null 2>&1 \ || DEBIAN_FRONTEND=noninteractive apt-get install -y -qq docker-compose-v2 >/dev/null 2>&1; then return fi local arch sum case "$(uname -m)" in x86_64) arch="x86_64"; sum="40343e21ca777173e69cff5dbafeb37c6f81f3b0d57d9e597f036e95eb63e76a" ;; aarch64) arch="aarch64"; sum="733ec76717ceb59052a9609b9dadfb523b2df8eab57a54212872d10a58078ea2" ;; *) die "Unsupported CPU: $(uname -m)" ;; esac local tmp tmp="$(mktemp -d)" curl -fsSL -o "$tmp/docker-compose" "https://github.com/docker/compose/releases/download/v$COMPOSE_VERSION/docker-compose-linux-$arch" echo "$sum $tmp/docker-compose" | sha256sum -c --quiet - || die "The Docker Compose download is damaged. Run the installer again." install -D -m 0755 "$tmp/docker-compose" /usr/local/lib/docker/cli-plugins/docker-compose rm -rf "$tmp" } # The panel runs docker compose with an empty home folder: only plugins installed for the whole # system count, not ones in ~/.docker. compose_works() { local home rc home="$(mktemp -d)" HOME="$home" docker compose version >/dev/null 2>&1 && rc=0 || rc=1 rm -rf "$home" return $rc } if ! command -v docker >/dev/null 2>&1; then say "Installing Docker" install_docker fi systemctl enable --now docker >/dev/null if (( $(docker_major) < DOCKER_MIN )); then old="$(docker version --format '{{.Server.Version}}' 2>/dev/null || echo unknown)" if [[ ! -x /usr/local/bin/hostlet ]]; then # Nothing of the panel's runs here yet, so the old Docker can go. replace_docker "$old" systemctl enable --now docker >/dev/null else warn "Docker $old is older than $DOCKER_MIN: machines on the server's network can reach the databases and apps it keeps to itself. Upgrade it from Docker's repository (docs.docker.com/engine/install) when the sites can take a short pause." fi fi # Docker's default pools hold about thirty networks and the panel makes one per project and per # database server; container logs that set no limit of their own stay capped too. Only a fresh # server gets this file: one already there is left as it is. if [[ ! -x /usr/local/bin/hostlet && ! -s /etc/docker/daemon.json ]]; then install -d -m 0755 /etc/docker cat > /etc/docker/daemon.json <<'JSON' { "default-address-pools": [{"base": "10.210.0.0/16", "size": 24}], "log-driver": "json-file", "log-opts": {"max-size": "20m", "max-file": "3"} } JSON systemctl restart docker elif ! grep -q default-address-pools /etc/docker/daemon.json 2>/dev/null; then warn "Docker has its default address pools, about 30 networks: add \"default-address-pools\" to /etc/docker/daemon.json before the server holds that many projects and databases." fi # Two-step sign-in, certificates and the agents' connections need the right time. chrony or ntpd # already doing it are left alone. if command -v timedatectl >/dev/null 2>&1 && [[ "$(timedatectl show -p NTP --value 2>/dev/null)" != yes ]] \ && ! systemctl is-active --quiet chrony chronyd ntp ntpsec 2>/dev/null; then say "Turning on time sync" apt_install systemd-timesyncd >/dev/null 2>&1 || true timedatectl set-ntp true >/dev/null 2>&1 || warn "Time sync couldn't be turned on: run 'timedatectl set-ntp true', or two-step sign-in and certificates may fail." fi if ! command -v git >/dev/null 2>&1 || ! command -v ssh >/dev/null 2>&1; then say "Installing Git" apt_install git openssh-client fi if ! docker buildx version >/dev/null 2>&1; then say "Installing Docker Buildx" apt_install docker-buildx-plugin 2>/dev/null || apt_install docker-buildx fi if ! compose_works; then say "Installing Docker Compose" install_compose compose_works || die "docker compose doesn't work on this server. Install the docker-compose-plugin package and run the installer again." fi if [[ "$(railpack --version 2>/dev/null)" != *"$RAILPACK_VERSION"* ]]; then say "Installing Railpack $RAILPACK_VERSION" install_railpack fi if [[ "$(/usr/local/bin/restic version 2>/dev/null)" != *"restic $RESTIC_VERSION "* ]]; then say "Installing restic $RESTIC_VERSION" install_restic fi # open_firewall lets the web in through the server's own firewall, which keeps visitors and Let's # Encrypt out as surely as the provider's: ufw is opened for it, other rules (Oracle Cloud's # images reject everything but SSH) are pointed out. open_firewall() { if command -v ufw >/dev/null 2>&1 && ufw status 2>/dev/null | grep -q '^Status: active'; then if ufw allow 80/tcp >/dev/null && ufw allow 443/tcp >/dev/null; then say "ufw is on: ports 80 and 443 are open in it now" else warn "ufw is on and didn't take the rules for the web: ufw allow 80/tcp && ufw allow 443/tcp" fi # The panel's entrance by the server's address, on the server that runs the panel. if [[ $TOOLS -eq 0 && $DIRECT_PORT != off ]] && ! ufw allow "$DIRECT_PORT/tcp" >/dev/null; then warn "ufw didn't take the rule for the panel's entrance by IP: ufw allow $DIRECT_PORT/tcp" fi if [[ $FRESH -eq 1 ]]; then warn "ufw keeps out the panel's name servers, SFTP, database clients and other servers until they're let in: ufw allow 53 && ufw allow 2022,8444,8445/tcp" fi fi command -v iptables >/dev/null 2>&1 || return 0 # The number of the first INPUT rule rejecting everything before both web ports are accepted. local rule rule="$(iptables -S INPUT 2>/dev/null | awk ' !/^-A INPUT / { next } { n++ } / -j ACCEPT( |$)/ && /--dports? ([0-9]+,)*80(,| |$)/ { web80 = 1 } / -j ACCEPT( |$)/ && /--dports? ([0-9]+,)*443(,| |$)/ { web443 = 1 } /^-A INPUT -j ACCEPT( |$)/ { exit } /^-A INPUT -j (REJECT|DROP)( |$)/ && !(web80 && web443) { print n; exit } ' || true)" if [[ -n $rule ]]; then warn "The server's own firewall (iptables, rule $rule of INPUT) rejects ports 80 and 443: sites and the panel can't be reached, nor get certificates, until they're let in:" printf ' %s\n' "iptables -I INPUT $rule -p tcp -m multiport --dports 80,443 -j ACCEPT" if command -v netfilter-persistent >/dev/null 2>&1; then printf ' %s\n' "netfilter-persistent save" fi fi } if [[ $TOOLS -eq 1 ]]; then # A server the panel adds serves sites when it takes over: its own firewall lets the web in too. [[ $DEV -eq 1 ]] || open_firewall say "The server is ready: Docker $(docker version --format '{{.Server.Version}}' 2>/dev/null), Compose, Buildx, Railpack, restic" exit 0 fi # Behind a cloud's 1:1 NAT (AWS, Google Cloud, Oracle and others) the server only sees a private # address; sites, the panel's address and DNS answers need the public one. NAT_IP="${HOSTLET_PUBLIC_IP:-}" if [[ -z $NAT_IP && $DEV -eq 0 && $FRESH -eq 1 && $ROUTE_IP =~ ^(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.|100\.(6[4-9]|[7-9][0-9]|1[01][0-9]|12[0-7])\.) ]]; then seen=$(curl -4 -fsS --max-time 5 https://1.1.1.1/cdn-cgi/trace 2>/dev/null | sed -n 's/^ip=//p' || true) if [[ $seen =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ && $seen != "$ROUTE_IP" ]]; then NAT_IP=$seen warn "This server has the private address $ROUTE_IP and reaches the internet as $NAT_IP: the panel uses $NAT_IP. Open ports 80 and 443 to it at your provider." elif [[ -z $seen ]]; then warn "This server has the private address $ROUTE_IP and its public one couldn't be found: if it is behind NAT, put HOSTLET_PUBLIC_IP= in /etc/hostlet/hostlet.env and run systemctl restart hostlet." fi fi PUBLIC_IP="${NAT_IP:-$ROUTE_IP}" # Sites on the owner's domain need two A records; the certificates come once they point here. if [[ -n $DOMAIN ]]; then say "The panel and sites go on $DOMAIN. Create these A records at its DNS provider:" printf ' %-32s A %s\n' "panel.$DOMAIN" "${PUBLIC_IP:-}" "*.$DOMAIN" "${PUBLIC_IP:-}" if [[ -n $PUBLIC_IP && "$(getent ahostsv4 "panel.$DOMAIN" 2>/dev/null | awk 'NR == 1 {print $1}')" != "$PUBLIC_IP" ]]; then warn "panel.$DOMAIN doesn't point at $PUBLIC_IP yet: the panel's certificate comes once it does." fi fi if [[ $DEV -eq 0 ]]; then open_firewall fi # A server that runs the panel already gets a newer build the way `hostlet upgrade` gives it: the # database copied first, the version before put back if the new one doesn't start. if [[ $FRESH -eq 0 && $INSTALLED =~ ^[0-9] && $PANEL_VERSION != dev ]] && version_lt "$INSTALLED" "$PANEL_VERSION" \ && { /usr/local/bin/hostlet usage 2>&1 || true; } | grep -q '|upgrade|'; then say "Hostlet $INSTALLED runs here: updating it to $PANEL_VERSION" status=0 /usr/local/bin/hostlet upgrade "$STAGED" || status=$? exit "$status" fi say "Installing Hostlet $PANEL_VERSION" mv -f "$STAGED" /usr/local/bin/hostlet STAGED="" install -d -m 0755 /etc/hostlet /var/lib/hostlet if [[ $FRESH -eq 1 ]]; then { echo "# Hostlet settings. Restart after editing: systemctl restart hostlet" if [[ $DEV -eq 1 ]]; then echo "HOSTLET_TLS=off" fi if [[ -n $DOMAIN ]]; then echo "HOSTLET_BASE_DOMAIN=$DOMAIN" elif [[ -n ${HOSTLET_BASE_DOMAIN:-} ]]; then echo "HOSTLET_BASE_DOMAIN=$HOSTLET_BASE_DOMAIN" fi if [[ -n $NAT_IP ]]; then echo "HOSTLET_PUBLIC_IP=$NAT_IP" fi # The channel the release came from is where the panel looks for its updates too; a build # brought by hand has none. echo "HOSTLET_RELEASES=${RELEASES:-off}" } > /etc/hostlet/hostlet.env fi # The unit is written here: the one-line install has no files beside this script. Docker stopping # doesn't stop the panel: it keeps answering and picks up again when Docker is back. cat > /etc/systemd/system/hostlet.service <<'UNIT' [Unit] Description=Hostlet panel After=network-online.target docker.service Wants=network-online.target docker.service [Service] EnvironmentFile=-/etc/hostlet/hostlet.env ExecStart=/usr/local/bin/hostlet serve Restart=always RestartSec=3 [Install] WantedBy=multi-user.target UNIT systemctl daemon-reload systemctl enable --now hostlet >/dev/null systemctl restart hostlet say "Waiting for the panel to start" started=0 for _ in $(seq 1 60); do if curl -fsS http://127.0.0.1:7070/api/health >/dev/null 2>&1; then started=1 break fi sleep 1 done if [[ $started -eq 0 ]]; then journalctl -u hostlet -n 30 --no-pager || true die "The panel didn't start. The log above shows why." fi say "Hostlet is running" # check_https opens the panel's address the way a browser does, so its name, the ports at the # provider and the certificate all have to be right. Caddy has the certificate within a minute # or two of Let's Encrypt reaching the server. check_https() { local url host ports rc deadline=$((SECONDS + 120)) url="$(grep -oE 'https://[^/[:space:]]+' <<<"$1" | head -1 || true)" [[ -n $url ]] || return 0 host="${url#https://}" ports="80 and 443" [[ $host == *:* ]] && ports="80, 443 and ${host##*:}" say "Checking that $url opens with a valid certificate (up to 2 minutes)" # A name created during the install may still be cached here as missing. resolvectl flush-caches >/dev/null 2>&1 || true while :; do rc=0 curl -fsS -o /dev/null --max-time 10 "$url/api/health" 2>/dev/null || rc=$? if [[ $rc -eq 0 ]]; then say "The certificate is in: $url opens over HTTPS" return 0 fi (( SECONDS < deadline )) || break sleep 5 done case $rc in 6) warn "$host doesn't resolve yet: its A record has to point at ${PUBLIC_IP:-this server}." ;; 7|28) warn "Nothing answers at $url: let TCP ports $ports in at the provider (its firewall or security group) and on the server." ;; 35|51|58|60) warn "$host has no valid certificate yet: Let's Encrypt has to reach this server at that address on ports 80 and 443. It keeps trying; its answers are in docker logs hostlet-caddy." ;; *) warn "$url didn't open over HTTPS (curl error $rc)." ;; esac warn "If $url opens in your browser, all is well: some providers don't let a server reach its own public address." } # A fresh panel prints its one-time first sign-in link; an upgraded one, where to sign in. LINK="$(/usr/local/bin/hostlet setup-link)" DIRECT="$(/usr/local/bin/hostlet setup-link --direct)" MAIN=$LINK ALT=$DIRECT [[ $ALT != "$MAIN" ]] || ALT="" if [[ $DEV -eq 0 ]] && ! grep -qE '^HOSTLET_TLS=["'\'']?(off|internal)' /etc/hostlet/hostlet.env; then check_https "$MAIN" fi printf '%s\n' "$MAIN" | sed 's/^/ /' if [[ -n $ALT ]]; then if [[ $ALT == "$DIRECT" ]]; then say "If that address doesn't open, the panel is also at the server's address (let TCP port $DIRECT_PORT in at the provider; the browser may warn about the certificate once):" else say "The same link by the panel's name, once its certificate is in:" fi printf '%s\n' "$ALT" | sed 's/^/ /' fi