···11+#!/bin/bash
22+set -o errexit
33+set -o nounset
44+set -o pipefail
55+66+# Disable prompts for apt-get.
77+export DEBIAN_FRONTEND="noninteractive"
88+99+# System info.
1010+PLATFORM="$(uname --hardware-platform || true)"
1111+DISTRIB_CODENAME="$(lsb_release --codename --short || true)"
1212+DISTRIB_ID="$(lsb_release --id --short | tr '[:upper:]' '[:lower:]' || true)"
1313+1414+# Secure generator comands
1515+GENERATE_SECURE_SECRET_CMD="openssl rand --hex 16"
1616+GENERATE_K256_PRIVATE_KEY_CMD="openssl ecparam --name secp256k1 --genkey --noout --outform DER | tail --bytes=+8 | head --bytes=32 | xxd --plain --cols 32"
1717+1818+# The Docker compose file.
1919+COMPOSE_URL="https://raw.githubusercontent.com/bluesky-social/pds/main/compose.yaml"
2020+2121+# System dependencies.
2222+REQUIRED_SYSTEM_PACKAGES="
2323+ ca-certificates
2424+ curl
2525+ gnupg
2626+ lsb-release
2727+ openssl
2828+ xxd
2929+"
3030+# Docker packages.
3131+REQUIRED_DOCKER_PACKAGES="
3232+ docker-ce
3333+ docker-ce-cli
3434+ docker-compose-plugin
3535+ containerd.io
3636+"
3737+3838+PUBLIC_IP=""
3939+METADATA_URLS=()
4040+METADATA_URLS+=("http://169.254.169.254/v1/interfaces/0/ipv4/address") # Vultr
4141+METADATA_URLS+=("http://169.254.169.254/metadata/v1/interfaces/public/0/ipv4/address") # DigitalOcean
4242+METADATA_URLS+=("http://169.254.169.254/2021-03-23/meta-data/public-ipv4") # AWS
4343+METADATA_URLS+=("http://169.254.169.254/hetzner/v1/metadata/public-ipv4") # Hetzner
4444+4545+PDS_DATADIR="${1:-/pds}"
4646+PDS_HOSTNAME="${2:-}"
4747+PDS_ADMIN_EMAIL="${3:-}"
4848+PDS_DID_PLC_URL="https://plc.bsky-sandbox.dev"
4949+PDS_BSKY_APP_VIEW_ENDPOINT="https://api.bsky-sandbox.dev"
5050+PDS_BSKY_APP_VIEW_DID="did:web:api.bsky-sandbox.dev"
5151+PDS_CRAWLERS="https://bgs.bsky-sandbox.dev"
5252+5353+function usage {
5454+ local error="${1}"
5555+ cat <<USAGE >&2
5656+ERROR: ${error}
5757+Usage:
5858+sudo bash $0
5959+6060+Please try again.
6161+USAGE
6262+ exit 1
6363+}
6464+6565+function main {
6666+ # Check that user is root.
6767+ if [[ "${EUID}" -ne 0 ]]; then
6868+ usage "This script must be run as root. (e.g. sudo $0)"
6969+ fi
7070+7171+ # Check for a supported architecture.
7272+ # If the platform is unknown (not uncommon) then we assume x86_64
7373+ if [[ "${PLATFORM}" == "unknown" ]]; then
7474+ PLATFORM="x86_64"
7575+ fi
7676+ if [[ "${PLATFORM}" != "x86_64" ]] && [[ "${PLATFORM}" != "aarch64" ]] && [[ "${PLATFORM}" != "arm64" ]]; then
7777+ usage "Sorry, only x86_64 and aarch64/arm64 are supported. Exiting..."
7878+ fi
7979+8080+ # Check for a supported distribution.
8181+ SUPPORTED_OS="false"
8282+ if [[ "${DISTRIB_ID}" == "ubuntu" ]]; then
8383+ if [[ "${DISTRIB_CODENAME}" == "focal" ]]; then
8484+ SUPPORTED_OS="true"
8585+ echo "* Detected supported distribution Ubuntu 20.04 LTS"
8686+ elif [[ "${DISTRIB_CODENAME}" == "jammy" ]]; then
8787+ SUPPORTED_OS="true"
8888+ echo "* Detected supported distribution Ubuntu 22.04 LTS"
8989+ fi
9090+ elif [[ "${DISTRIB_ID}" == "debian" ]]; then
9191+ if [[ "${DISTRIB_CODENAME}" == "bullseye" ]]; then
9292+ SUPPORTED_OS="true"
9393+ echo "* Detected supported distribution Debian 11"
9494+ fi
9595+ fi
9696+9797+ if [[ "${SUPPORTED_OS}" != "true" ]]; then
9898+ echo "Sorry, only Ubuntu 20.04, 22.04, and Debian 11 are supported by this installer. Exiting..."
9999+ exit 1
100100+ fi
101101+102102+103103+ #
104104+ # Attempt to determine server's public IP.
105105+ #
106106+107107+ # First try using the hostname command, which usually works.
108108+ if [[ -z "${PUBLIC_IP}" ]]; then
109109+ PUBLIC_IP=$(hostname --all-ip-addresses | awk '{ print $1 }')
110110+ fi
111111+112112+ # Prevent any private IP address from being used, since it won't work.
113113+ if [[ "${PUBLIC_IP}" =~ ^(127\.|10\.|172\.1[6-9]\.|172\.2[0-9]\.|172\.3[0-1]\.|192\.168\.) ]]; then
114114+ PUBLIC_IP=""
115115+ fi
116116+117117+ # Check the various metadata URLs.
118118+ if [[ -z "${PUBLIC_IP}" ]]; then
119119+ for METADATA_URL in "${METADATA_URLS[@]}"; do
120120+ METADATA_IP="$(timeout 2 curl --silent --show-error "${METADATA_URL}" | head --lines=1 || true)"
121121+ if [[ "${METADATA_IP}" =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then
122122+ PUBLIC_IP="${METADATA_IP}"
123123+ break
124124+ fi
125125+ done
126126+ fi
127127+128128+ if [[ -z "${PUBLIC_IP}" ]]; then
129129+ PUBLIC_IP="Server's IP"
130130+ fi
131131+132132+ #
133133+ # Prompt user for required variables.
134134+ #
135135+ if [[ -z "${PDS_HOSTNAME}" ]]; then
136136+ cat <<INSTALLER_MESSAGE
137137+---------------------------------------
138138+ Add DNS Record for Public IP
139139+---------------------------------------
140140+141141+ From your DNS provider's control panel, create the required
142142+ DNS record with the value of your server's public IP address.
143143+144144+ + Any DNS name that can be resolved on the public internet will work.
145145+ + Replace example.com below with any valid domain name you control.
146146+ + A TTL of 600 seconds (10 minutes) is recommended.
147147+148148+ Example DNS record:
149149+150150+ NAME TYPE VALUE
151151+ ---- ---- -----
152152+ example.com A ${PUBLIC_IP:-Server public IP}
153153+ *.example.com A ${PUBLIC_IP:-Server public IP}
154154+155155+ **IMPORTANT**
156156+ It's recommended to wait 3-5 minutes after creating a new DNS record
157157+ before attempting to use it. This will allow time for the DNS record
158158+ to be fully updated.
159159+160160+INSTALLER_MESSAGE
161161+162162+ if [[ -z "${PDS_HOSTNAME}" ]]; then
163163+ read -p "Enter your public DNS address (e.g. example.com): " PDS_HOSTNAME
164164+ fi
165165+ fi
166166+167167+ if [[ -z "${PDS_HOSTNAME}" ]]; then
168168+ usage "No public DNS address specified"
169169+ fi
170170+171171+ if [[ "${PDS_HOSTNAME}" =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then
172172+ usage "Invalid public DNS address (must not be an IP address)"
173173+ fi
174174+175175+ # Admin email
176176+ if [[ -z "${PDS_ADMIN_EMAIL}" ]]; then
177177+ read -p "Enter an admin email address (e.g. you@example.com): " PDS_ADMIN_EMAIL
178178+ fi
179179+ if [[ -z "${PDS_ADMIN_EMAIL}" ]]; then
180180+ usage "No admin email specified"
181181+ fi
182182+183183+ if [[ -z "${PDS_ADMIN_EMAIL}" ]]; then
184184+ read -p "Enter an admin email address (e.g. you@example.com): " PDS_ADMIN_EMAIL
185185+ fi
186186+ if [[ -z "${PDS_ADMIN_EMAIL}" ]]; then
187187+ usage "No admin email specified"
188188+ fi
189189+190190+191191+ if [[ -e "${PDS_DATADIR}/pds.sqlite" ]]; then
192192+ echo
193193+ echo "ERROR: pds is already configured in ${PDS_DATADIR}"
194194+ echo
195195+ echo "To do a clean re-install:"
196196+ echo "------------------------------------"
197197+ echo "1. Stop the service"
198198+ echo
199199+ echo " sudo systemctl stop pds"
200200+ echo
201201+ echo "2. Delete the data directory"
202202+ echo
203203+ echo " sudo rm -rf ${PDS_DATADIR}"
204204+ echo
205205+ echo "3. Re-run this installation script"
206206+ echo
207207+ echo " sudo bash ${0}"
208208+ echo
209209+ echo "For assistance, contact support@pds.com"
210210+ exit 1
211211+ fi
212212+213213+ #
214214+ # Install system packages.
215215+ #
216216+ if lsof -v >/dev/null 2>&1; then
217217+ while true; do
218218+ apt_process_count="$(lsof -n -t /var/cache/apt/archives/lock /var/lib/apt/lists/lock /var/lib/dpkg/lock | wc --lines || true)"
219219+ if (( apt_process_count == 0 )); then
220220+ break
221221+ fi
222222+ echo "* Waiting for other apt process to complete..."
223223+ sleep 2
224224+ done
225225+ fi
226226+227227+ apt-get update
228228+ apt-get install --yes ${REQUIRED_SYSTEM_PACKAGES}
229229+230230+ #
231231+ # Install Docker
232232+ #
233233+ if ! docker version >/dev/null 2>&1; then
234234+ echo "* Installing Docker"
235235+ mkdir --parents /etc/apt/keyrings
236236+237237+ # Remove the existing file, if it exists,
238238+ # so there's no prompt on a second run.
239239+ rm --force /etc/apt/keyrings/docker.gpg
240240+ curl --fail --silent --show-error --location "https://download.docker.com/linux/${DISTRIB_ID}/gpg" | \
241241+ gpg --dearmor --output /etc/apt/keyrings/docker.gpg
242242+243243+ 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
244244+245245+ apt-get update
246246+ apt-get install --yes ${REQUIRED_DOCKER_PACKAGES}
247247+ fi
248248+249249+ #
250250+ # Configure the Docker daemon so that logs don't fill up the disk.
251251+ #
252252+ if ! [[ -e /etc/docker/daemon.json ]]; then
253253+ echo "* Configuring Docker daemon"
254254+ cat <<'DOCKERD_CONFIG' >/etc/docker/daemon.json
255255+{
256256+ "log-driver": "json-file",
257257+ "log-opts": {
258258+ "max-size": "500m",
259259+ "max-file": "4"
260260+ }
261261+}
262262+DOCKERD_CONFIG
263263+ systemctl restart docker
264264+ else
265265+ echo "* Docker daemon already configured! Ensure log rotation is enabled."
266266+ fi
267267+268268+ #
269269+ # Create data directory.
270270+ #
271271+ if ! [[ -d "${PDS_DATADIR}" ]]; then
272272+ echo "* Creating data directory ${PDS_DATADIR}"
273273+ mkdir --parents "${PDS_DATADIR}"
274274+ fi
275275+ chmod 700 "${PDS_DATADIR}"
276276+277277+ #
278278+ # Configure Caddy
279279+ #
280280+ if ! [[ -d "${PDS_DATADIR}/caddy/data" ]]; then
281281+ echo "* Creating Caddy data directory"
282282+ mkdir --parents "${PDS_DATADIR}/caddy/data"
283283+ fi
284284+ if ! [[ -d "${PDS_DATADIR}/caddy/etc/caddy" ]]; then
285285+ echo "* Creating Caddy config directory"
286286+ mkdir --parents "${PDS_DATADIR}/caddy/etc/caddy"
287287+ fi
288288+289289+ echo "* Creating Caddy config file"
290290+ cat <<CADDYFILE >"${PDS_DATADIR}/caddy/etc/caddy/Caddyfile"
291291+{
292292+ email ${PDS_ADMIN_EMAIL}
293293+}
294294+295295+*.${PDS_HOSTNAME}, ${PDS_HOSTNAME} {
296296+ tls {
297297+ on_demand
298298+ }
299299+ reverse_proxy http://localhost:3000
300300+}
301301+CADDYFILE
302302+303303+ #
304304+ # Create the PDS env config
305305+ #
306306+ cat <<PDS_CONFIG >"${PDS_DATADIR}/pds.env"
307307+PDS_HOSTNAME=${PDS_HOSTNAME}
308308+PDS_JWT_SECRET=$(eval "${GENERATE_SECURE_SECRET_CMD}")
309309+PDS_ADMIN_PASSWORD=$(eval "${GENERATE_SECURE_SECRET_CMD}")
310310+PDS_REPO_SIGNING_KEY_K256_PRIVATE_KEY_HEX=$(eval "${GENERATE_K256_PRIVATE_KEY_CMD}")
311311+PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX=$(eval "${GENERATE_K256_PRIVATE_KEY_CMD}")
312312+PDS_DB_SQLITE_LOCATION=${PDS_DATADIR}/pds.sqlite
313313+PDS_BLOBSTORE_DISK_LOCATION=${PDS_DATADIR}/blocks
314314+PDS_DID_PLC_URL=${PDS_DID_PLC_URL}
315315+PDS_BSKY_APP_VIEW_ENDPOINT=${PDS_BSKY_APP_VIEW_ENDPOINT}
316316+PDS_BSKY_APP_VIEW_DID=${PDS_BSKY_APP_VIEW_DID}
317317+PDS_CRAWLERS=${PDS_CRAWLERS}
318318+PDS_CONFIG
319319+320320+ #
321321+ # Download and install pds launcher.
322322+ #
323323+ echo "* Downloading pds compose file"
324324+ curl \
325325+ --silent \
326326+ --show-error \
327327+ --fail \
328328+ --output "${PDS_DATADIR}/compose.yaml" \
329329+ "${COMPOSE_URL}"
330330+331331+ # Replace the /pds paths with the ${PDS_DATADIR} path.
332332+ sed --in-place "s|/pds|${PDS_DATADIR}|g" "${PDS_DATADIR}/compose.yaml"
333333+334334+ #
335335+ # Create the systemd service.
336336+ #
337337+ echo "* Starting the pds systemd service"
338338+ cat <<SYSTEMD_UNIT_FILE >/etc/systemd/system/pds.service
339339+[Unit]
340340+Description=Bluesky PDS Service
341341+Documentation=https://github.com/bluesky-social/pds
342342+Requires=docker.service
343343+After=docker.service
344344+345345+[Service]
346346+Type=oneshot
347347+RemainAfterExit=yes
348348+WorkingDirectory=${PDS_DATADIR}
349349+ExecStart=/usr/bin/docker compose --file ${PDS_DATADIR}/compose.yaml up --detach
350350+ExecStop=/usr/bin/docker compose --file ${PDS_DATADIR}/compose.yaml down
351351+352352+[Install]
353353+WantedBy=default.target
354354+SYSTEMD_UNIT_FILE
355355+356356+ systemctl daemon-reload
357357+ systemctl enable pds
358358+ systemctl restart pds
359359+360360+ # Enable firewall access if ufw is in use.
361361+ if ufw status >/dev/null 2>&1; then
362362+ if ! ufw status | grep --quiet '^80[/ ]'; then
363363+ echo "* Enabling access on TCP port 80 using ufw"
364364+ ufw allow 80/tcp >/dev/null
365365+ fi
366366+ if ! ufw status | grep --quiet '^443[/ ]'; then
367367+ echo "* Enabling access on TCP port 443 using ufw"
368368+ ufw allow 443/tcp >/dev/null
369369+ fi
370370+ fi
371371+372372+ cat <<INSTALLER_MESSAGE
373373+========================================================================
374374+PDS installation successful!
375375+------------------------------------------------------------------------
376376+377377+Check service status : sudo systemctl status pds
378378+Watch service logs : sudo docker logs -f pds
379379+Backup service data : ${PDS_DATADIR}
380380+381381+Required Firewall Ports
382382+------------------------------------------------------------------------
383383+Service Direction Port Protocol Source
384384+------- --------- ---- -------- ----------------------
385385+HTTP TLS verification Inbound 80 TCP Any
386386+HTTP Control Panel Inbound 443 TCP Any
387387+388388+Required DNS entries
389389+------------------------------------------------------------------------
390390+Name Type Value
391391+------- --------- ---------------
392392+${PDS_HOSTNAME} A ${PUBLIC_IP}
393393+*.${PDS_HOSTNAME} A ${PUBLIC_IP}
394394+395395+Detected public IP of this server: ${PUBLIC_IP}
396396+397397+========================================================================
398398+INSTALLER_MESSAGE
399399+}
400400+401401+# Run main function.
402402+main