/ installer.sh
installer.sh
  1  #!/bin/bash
  2  set -o errexit
  3  set -o nounset
  4  set -o pipefail
  5  
  6  # Disable prompts for apt-get.
  7  export DEBIAN_FRONTEND="noninteractive"
  8  
  9  # System info.
 10  PLATFORM="$(uname --hardware-platform || true)"
 11  DISTRIB_CODENAME="$(lsb_release --codename --short || true)"
 12  DISTRIB_ID="$(lsb_release --id --short | tr '[:upper:]' '[:lower:]' || true)"
 13  
 14  # Secure generator comands
 15  GENERATE_SECURE_SECRET_CMD="openssl rand --hex 16"
 16  GENERATE_K256_PRIVATE_KEY_CMD="openssl ecparam --name secp256k1 --genkey --noout --outform DER | tail --bytes=+8 | head --bytes=32 | xxd --plain --cols 32"
 17  
 18  # The Docker compose file.
 19  COMPOSE_URL="https://raw.githubusercontent.com/bluesky-social/pds/main/compose.yaml"
 20  
 21  # The pdsadmin script.
 22  PDSADMIN_URL="https://raw.githubusercontent.com/bluesky-social/pds/main/pdsadmin.sh"
 23  
 24  # System dependencies.
 25  REQUIRED_SYSTEM_PACKAGES="
 26    ca-certificates
 27    curl
 28    gnupg
 29    jq
 30    lsb-release
 31    openssl
 32    sqlite3
 33    xxd
 34  "
 35  # Docker packages.
 36  REQUIRED_DOCKER_PACKAGES="
 37    containerd.io
 38    docker-ce
 39    docker-ce-cli
 40    docker-compose-plugin
 41  "
 42  
 43  PUBLIC_IP=""
 44  METADATA_URLS=()
 45  METADATA_URLS+=("http://169.254.169.254/v1/interfaces/0/ipv4/address") # Vultr
 46  METADATA_URLS+=("http://169.254.169.254/metadata/v1/interfaces/public/0/ipv4/address") # DigitalOcean
 47  METADATA_URLS+=("http://169.254.169.254/2021-03-23/meta-data/public-ipv4") # AWS
 48  METADATA_URLS+=("http://169.254.169.254/hetzner/v1/metadata/public-ipv4") # Hetzner
 49  
 50  PDS_DATADIR="${1:-/pds}"
 51  PDS_HOSTNAME="${2:-}"
 52  PDS_ADMIN_EMAIL="${3:-}"
 53  PDS_DID_PLC_URL="https://plc.directory"
 54  PDS_BSKY_APP_VIEW_URL="https://api.bsky.app"
 55  PDS_BSKY_APP_VIEW_DID="did:web:api.bsky.app"
 56  PDS_REPORT_SERVICE_URL="https://mod.bsky.app"
 57  PDS_REPORT_SERVICE_DID="did:plc:ar7c4by46qjdydhdevvrndac"
 58  PDS_CRAWLERS="https://bsky.network"
 59  
 60  function usage {
 61    local error="${1}"
 62    cat <<USAGE >&2
 63  ERROR: ${error}
 64  Usage:
 65  sudo bash $0
 66  
 67  Please try again.
 68  USAGE
 69    exit 1
 70  }
 71  
 72  function main {
 73    # Check that user is root.
 74    if [[ "${EUID}" -ne 0 ]]; then
 75      usage "This script must be run as root. (e.g. sudo $0)"
 76    fi
 77  
 78    # Check for a supported architecture.
 79    # If the platform is unknown (not uncommon) then we assume x86_64
 80    if [[ "${PLATFORM}" == "unknown" ]]; then
 81      PLATFORM="x86_64"
 82    fi
 83    if [[ "${PLATFORM}" != "x86_64" ]] && [[ "${PLATFORM}" != "aarch64" ]] && [[ "${PLATFORM}" != "arm64" ]]; then
 84      usage "Sorry, only x86_64 and aarch64/arm64 are supported. Exiting..."
 85    fi
 86  
 87    # Check for a supported distribution.
 88    SUPPORTED_OS="false"
 89    if [[ "${DISTRIB_ID}" == "ubuntu" ]]; then
 90      if [[ "${DISTRIB_CODENAME}" == "focal" ]]; then
 91        SUPPORTED_OS="true"
 92        echo "* Detected supported distribution Ubuntu 20.04 LTS"
 93      elif [[ "${DISTRIB_CODENAME}" == "jammy" ]]; then
 94        SUPPORTED_OS="true"
 95        echo "* Detected supported distribution Ubuntu 22.04 LTS"
 96      elif [[ "${DISTRIB_CODENAME}" == "mantic" ]]; then
 97        SUPPORTED_OS="true"
 98        echo "* Detected supported distribution Ubuntu 23.10 LTS"
 99      fi
100    elif [[ "${DISTRIB_ID}" == "debian" ]]; then
101      if [[ "${DISTRIB_CODENAME}" == "bullseye" ]]; then
102        SUPPORTED_OS="true"
103        echo "* Detected supported distribution Debian 11"
104      elif [[ "${DISTRIB_CODENAME}" == "bookworm" ]]; then
105        SUPPORTED_OS="true"
106        echo "* Detected supported distribution Debian 12"
107      fi
108    fi
109  
110    if [[ "${SUPPORTED_OS}" != "true" ]]; then
111      echo "Sorry, only Ubuntu 20.04, 22.04, Debian 11 and Debian 12 are supported by this installer. Exiting..."
112      exit 1
113    fi
114  
115    # Enforce that the data directory is /pds since we're assuming it for now.
116    # Later we can make this actually configurable.
117    if [[ "${PDS_DATADIR}" != "/pds" ]]; then
118      usage "The data directory must be /pds. Exiting..."
119    fi
120  
121    # Check if PDS is already installed.
122    if [[ -e "${PDS_DATADIR}/pds.sqlite" ]]; then
123      echo
124      echo "ERROR: pds is already configured in ${PDS_DATADIR}"
125      echo
126      echo "To do a clean re-install:"
127      echo "------------------------------------"
128      echo "1. Stop the service"
129      echo
130      echo "  sudo systemctl stop pds"
131      echo
132      echo "2. Delete the data directory"
133      echo
134      echo "  sudo rm -rf ${PDS_DATADIR}"
135      echo
136      echo "3. Re-run this installation script"
137        echo
138      echo "  sudo bash ${0}"
139      echo
140      echo "For assistance, check https://github.com/bluesky-social/pds"
141      exit 1
142    fi
143  
144    #
145    # Attempt to determine server's public IP.
146    #
147  
148    # First try using the hostname command, which usually works.
149    if [[ -z "${PUBLIC_IP}" ]]; then
150      PUBLIC_IP=$(hostname --all-ip-addresses | awk '{ print $1 }')
151    fi
152  
153    # Prevent any private IP address from being used, since it won't work.
154    if [[ "${PUBLIC_IP}" =~ ^(127\.|10\.|172\.1[6-9]\.|172\.2[0-9]\.|172\.3[0-1]\.|192\.168\.) ]]; then
155      PUBLIC_IP=""
156    fi
157  
158    # Check the various metadata URLs.
159    if [[ -z "${PUBLIC_IP}" ]]; then
160      for METADATA_URL in "${METADATA_URLS[@]}"; do
161        METADATA_IP="$(timeout 2 curl --silent --show-error "${METADATA_URL}" | head --lines=1 || true)"
162        if [[ "${METADATA_IP}" =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then
163          PUBLIC_IP="${METADATA_IP}"
164          break
165        fi
166      done
167    fi
168  
169    if [[ -z "${PUBLIC_IP}" ]]; then
170      PUBLIC_IP="Server's IP"
171    fi
172  
173    #
174    # Prompt user for required variables.
175    #
176    if [[ -z "${PDS_HOSTNAME}" ]]; then
177      cat <<INSTALLER_MESSAGE
178  ---------------------------------------
179       Add DNS Record for Public IP
180  ---------------------------------------
181  
182    From your DNS provider's control panel, create the required
183    DNS record with the value of your server's public IP address.
184  
185    + Any DNS name that can be resolved on the public internet will work.
186    + Replace example.com below with any valid domain name you control.
187    + A TTL of 600 seconds (10 minutes) is recommended.
188  
189    Example DNS record:
190  
191      NAME                TYPE   VALUE
192      ----                ----   -----
193      example.com         A      ${PUBLIC_IP:-Server public IP}
194      *.example.com       A      ${PUBLIC_IP:-Server public IP}
195  
196    **IMPORTANT**
197    It's recommended to wait 3-5 minutes after creating a new DNS record
198    before attempting to use it. This will allow time for the DNS record
199    to be fully updated.
200  
201  INSTALLER_MESSAGE
202  
203      if [[ -z "${PDS_HOSTNAME}" ]]; then
204        read -p "Enter your public DNS address (e.g. example.com): " PDS_HOSTNAME
205      fi
206    fi
207  
208    if [[ -z "${PDS_HOSTNAME}" ]]; then
209      usage "No public DNS address specified"
210    fi
211  
212    if [[ "${PDS_HOSTNAME}" =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then
213      usage "Invalid public DNS address (must not be an IP address)"
214    fi
215  
216    # Admin email
217    if [[ -z "${PDS_ADMIN_EMAIL}" ]]; then
218      read -p "Enter an admin email address (e.g. you@example.com): " PDS_ADMIN_EMAIL
219    fi
220    if [[ -z "${PDS_ADMIN_EMAIL}" ]]; then
221      usage "No admin email specified"
222    fi
223  
224    if [[ -z "${PDS_ADMIN_EMAIL}" ]]; then
225      read -p "Enter an admin email address (e.g. you@example.com): " PDS_ADMIN_EMAIL
226    fi
227    if [[ -z "${PDS_ADMIN_EMAIL}" ]]; then
228      usage "No admin email specified"
229    fi
230  
231    #
232    # Install system packages.
233    #
234    if lsof -v >/dev/null 2>&1; then
235      while true; do
236        apt_process_count="$(lsof -n -t /var/cache/apt/archives/lock /var/lib/apt/lists/lock /var/lib/dpkg/lock | wc --lines || true)"
237        if (( apt_process_count == 0 )); then
238          break
239        fi
240        echo "* Waiting for other apt process to complete..."
241        sleep 2
242      done
243    fi
244  
245    apt-get update
246    apt-get install --yes ${REQUIRED_SYSTEM_PACKAGES}
247  
248    #
249    # Install Docker
250    #
251    if ! docker version >/dev/null 2>&1; then
252      echo "* Installing Docker"
253      mkdir --parents /etc/apt/keyrings
254  
255      # Remove the existing file, if it exists,
256      # so there's no prompt on a second run.
257      rm --force /etc/apt/keyrings/docker.gpg
258      curl --fail --silent --show-error --location "https://download.docker.com/linux/${DISTRIB_ID}/gpg" | \
259        gpg --dearmor --output /etc/apt/keyrings/docker.gpg
260  
261      echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/${DISTRIB_ID} ${DISTRIB_CODENAME} stable" >/etc/apt/sources.list.d/docker.list
262  
263      apt-get update
264      apt-get install --yes ${REQUIRED_DOCKER_PACKAGES}
265    fi
266  
267    #
268    # Configure the Docker daemon so that logs don't fill up the disk.
269    #
270    if ! [[ -e /etc/docker/daemon.json ]]; then
271      echo "* Configuring Docker daemon"
272      cat <<'DOCKERD_CONFIG' >/etc/docker/daemon.json
273  {
274    "log-driver": "json-file",
275    "log-opts": {
276      "max-size": "500m",
277      "max-file": "4"
278    }
279  }
280  DOCKERD_CONFIG
281      systemctl restart docker
282    else
283      echo "* Docker daemon already configured! Ensure log rotation is enabled."
284    fi
285  
286    #
287    # Create data directory.
288    #
289    if ! [[ -d "${PDS_DATADIR}" ]]; then
290      echo "* Creating data directory ${PDS_DATADIR}"
291      mkdir --parents "${PDS_DATADIR}"
292    fi
293    chmod 700 "${PDS_DATADIR}"
294  
295    #
296    # Configure Caddy
297    #
298    if ! [[ -d "${PDS_DATADIR}/caddy/data" ]]; then
299      echo "* Creating Caddy data directory"
300      mkdir --parents "${PDS_DATADIR}/caddy/data"
301    fi
302    if ! [[ -d "${PDS_DATADIR}/caddy/etc/caddy" ]]; then
303      echo "* Creating Caddy config directory"
304      mkdir --parents "${PDS_DATADIR}/caddy/etc/caddy"
305    fi
306  
307    echo "* Creating Caddy config file"
308    cat <<CADDYFILE >"${PDS_DATADIR}/caddy/etc/caddy/Caddyfile"
309  {
310  	email ${PDS_ADMIN_EMAIL}
311  	on_demand_tls {
312  		ask http://localhost:3000/tls-check
313  	}
314  }
315  
316  *.${PDS_HOSTNAME}, ${PDS_HOSTNAME} {
317  	tls {
318  		on_demand
319  	}
320  	reverse_proxy http://localhost:3000
321  }
322  CADDYFILE
323  
324    #
325    # Create the PDS env config
326    #
327    # Created here so that we can use it later in multiple places.
328    PDS_ADMIN_PASSWORD=$(eval "${GENERATE_SECURE_SECRET_CMD}")
329    cat <<PDS_CONFIG >"${PDS_DATADIR}/pds.env"
330  PDS_HOSTNAME=${PDS_HOSTNAME}
331  PDS_JWT_SECRET=$(eval "${GENERATE_SECURE_SECRET_CMD}")
332  PDS_ADMIN_PASSWORD=${PDS_ADMIN_PASSWORD}
333  PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX=$(eval "${GENERATE_K256_PRIVATE_KEY_CMD}")
334  PDS_DATA_DIRECTORY=${PDS_DATADIR}
335  PDS_BLOBSTORE_DISK_LOCATION=${PDS_DATADIR}/blocks
336  PDS_BLOB_UPLOAD_LIMIT=52428800
337  PDS_DID_PLC_URL=${PDS_DID_PLC_URL}
338  PDS_BSKY_APP_VIEW_URL=${PDS_BSKY_APP_VIEW_URL}
339  PDS_BSKY_APP_VIEW_DID=${PDS_BSKY_APP_VIEW_DID}
340  PDS_REPORT_SERVICE_URL=${PDS_REPORT_SERVICE_URL}
341  PDS_REPORT_SERVICE_DID=${PDS_REPORT_SERVICE_DID}
342  PDS_CRAWLERS=${PDS_CRAWLERS}
343  LOG_ENABLED=true
344  PDS_CONFIG
345  
346    #
347    # Download and install pds launcher.
348    #
349    echo "* Downloading PDS compose file"
350    curl \
351      --silent \
352      --show-error \
353      --fail \
354      --output "${PDS_DATADIR}/compose.yaml" \
355      "${COMPOSE_URL}"
356  
357    # Replace the /pds paths with the ${PDS_DATADIR} path.
358    sed --in-place "s|/pds|${PDS_DATADIR}|g" "${PDS_DATADIR}/compose.yaml"
359  
360    #
361    # Create the systemd service.
362    #
363    echo "* Starting the pds systemd service"
364    cat <<SYSTEMD_UNIT_FILE >/etc/systemd/system/pds.service
365  [Unit]
366  Description=Bluesky PDS Service
367  Documentation=https://github.com/bluesky-social/pds
368  Requires=docker.service
369  After=docker.service
370  
371  [Service]
372  Type=oneshot
373  RemainAfterExit=yes
374  WorkingDirectory=${PDS_DATADIR}
375  ExecStart=/usr/bin/docker compose --file ${PDS_DATADIR}/compose.yaml up --detach
376  ExecStop=/usr/bin/docker compose --file ${PDS_DATADIR}/compose.yaml down
377  
378  [Install]
379  WantedBy=default.target
380  SYSTEMD_UNIT_FILE
381  
382    systemctl daemon-reload
383    systemctl enable pds
384    systemctl restart pds
385  
386    # Enable firewall access if ufw is in use.
387    if ufw status >/dev/null 2>&1; then
388      if ! ufw status | grep --quiet '^80[/ ]'; then
389        echo "* Enabling access on TCP port 80 using ufw"
390        ufw allow 80/tcp >/dev/null
391      fi
392      if ! ufw status | grep --quiet '^443[/ ]'; then
393        echo "* Enabling access on TCP port 443 using ufw"
394        ufw allow 443/tcp >/dev/null
395      fi
396    fi
397  
398    #
399    # Download and install pdadmin.
400    #
401    echo "* Downloading pdsadmin"
402    curl \
403      --silent \
404      --show-error \
405      --fail \
406      --output "/usr/local/bin/pdsadmin" \
407      "${PDSADMIN_URL}"
408    chmod +x /usr/local/bin/pdsadmin
409  
410    cat <<INSTALLER_MESSAGE
411  ========================================================================
412  PDS installation successful!
413  ------------------------------------------------------------------------
414  
415  Check service status      : sudo systemctl status pds
416  Watch service logs        : sudo docker logs -f pds
417  Backup service data       : ${PDS_DATADIR}
418  PDS Admin command         : pdsadmin
419  
420  Required Firewall Ports
421  ------------------------------------------------------------------------
422  Service                Direction  Port   Protocol  Source
423  -------                ---------  ----   --------  ----------------------
424  HTTP TLS verification  Inbound    80     TCP       Any
425  HTTP Control Panel     Inbound    443    TCP       Any
426  
427  Required DNS entries
428  ------------------------------------------------------------------------
429  Name                         Type       Value
430  -------                      ---------  ---------------
431  ${PDS_HOSTNAME}              A          ${PUBLIC_IP}
432  *.${PDS_HOSTNAME}            A          ${PUBLIC_IP}
433  
434  Detected public IP of this server: ${PUBLIC_IP}
435  
436  To see pdsadmin commands, run "pdsadmin help"
437  
438  ========================================================================
439  INSTALLER_MESSAGE
440  
441    CREATE_ACCOUNT_PROMPT=""
442    read -p "Create a PDS user account? (y/N): " CREATE_ACCOUNT_PROMPT
443  
444    if [[ "${CREATE_ACCOUNT_PROMPT}" =~ ^[Yy] ]]; then
445      pdsadmin account create
446    fi
447  
448  }
449  
450  # Run main function.
451  main