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