forked from
j4ck.xyz/tweets2bsky
A simple tool which lets you scrape twitter accounts and crosspost them to bluesky accounts! Comes with a CLI and a webapp for managing profiles! Works with images/videos/link embeds/threads.
1#!/usr/bin/env bash
2
3set -euo pipefail
4
5APP_NAME="tweets-2-bsky"
6SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
7cd "$SCRIPT_DIR"
8
9ENV_FILE="$SCRIPT_DIR/.env"
10APP_PORT=""
11TS_HTTPS_PORT=""
12TS_AUTHKEY=""
13TS_HOSTNAME=""
14USE_FUNNEL=0
15INSTALL_ARGS=()
16
17usage() {
18 cat <<'USAGE'
19Usage: ./install-server.sh [options]
20
21Secure Linux VPS install for Tailscale-first access:
22 - Runs regular app installer
23 - Forces app bind to localhost only (HOST=127.0.0.1)
24 - Installs and starts Tailscale if needed
25 - Publishes app through Tailscale Serve (HTTPS on tailnet)
26
27Options:
28 --port <number> App port (default: 3000; auto-adjusts if already in use)
29 --https-port <number> Tailscale HTTPS serve port (auto-select if omitted)
30 --auth-key <key> Tailscale auth key for non-interactive login
31 --hostname <name> Optional Tailscale device hostname
32 --funnel Also enable Tailscale Funnel (public internet)
33
34Install passthrough options (forwarded to ./install.sh):
35 --no-start
36 --start-only
37 --pm2
38 --nohup
39 --skip-install
40 --skip-build
41
42 -h, --help Show this help
43USAGE
44}
45
46require_command() {
47 local command_name="$1"
48 if ! command -v "$command_name" >/dev/null 2>&1; then
49 echo "Required command not found: $command_name"
50 exit 1
51 fi
52}
53
54is_valid_port() {
55 local candidate="$1"
56 [[ "$candidate" =~ ^[0-9]+$ ]] || return 1
57 (( candidate >= 1 && candidate <= 65535 ))
58}
59
60is_local_port_free() {
61 local port="$1"
62 node -e '
63const net = require("node:net");
64const port = Number(process.argv[1]);
65const server = net.createServer();
66server.once("error", () => process.exit(1));
67server.once("listening", () => server.close(() => process.exit(0)));
68server.listen(port, "127.0.0.1");
69' "$port" >/dev/null 2>&1
70}
71
72find_next_free_local_port() {
73 local start_port="$1"
74 local port
75 for port in $(seq "$start_port" 65535); do
76 if is_local_port_free "$port"; then
77 printf '%s\n' "$port"
78 return 0
79 fi
80 done
81 return 1
82}
83
84ensure_linux() {
85 if [[ "$(uname -s)" != "Linux" ]]; then
86 echo "install-server.sh currently supports Linux only."
87 echo "Use ./install.sh on macOS or other environments."
88 exit 1
89 fi
90}
91
92ensure_sudo() {
93 if [[ "$(id -u)" -eq 0 ]]; then
94 return
95 fi
96
97 if ! command -v sudo >/dev/null 2>&1; then
98 echo "sudo is required to install and configure Tailscale on this host."
99 exit 1
100 fi
101}
102
103run_as_root() {
104 if [[ "$(id -u)" -eq 0 ]]; then
105 "$@"
106 return
107 fi
108 sudo "$@"
109}
110
111get_env_value() {
112 local key="$1"
113 if [[ ! -f "$ENV_FILE" ]]; then
114 return 0
115 fi
116 local line
117 line="$(grep -E "^${key}=" "$ENV_FILE" | tail -n 1 || true)"
118 if [[ -z "$line" ]]; then
119 return 0
120 fi
121 printf '%s\n' "${line#*=}"
122}
123
124upsert_env_value() {
125 local key="$1"
126 local value="$2"
127 touch "$ENV_FILE"
128 local tmp_file
129 tmp_file="$(mktemp)"
130 awk -v key="$key" -v value="$value" '
131 BEGIN { updated = 0 }
132 $0 ~ ("^" key "=") {
133 print key "=" value
134 updated = 1
135 next
136 }
137 { print }
138 END {
139 if (!updated) {
140 print key "=" value
141 }
142 }
143 ' "$ENV_FILE" > "$tmp_file"
144 mv "$tmp_file" "$ENV_FILE"
145}
146
147ensure_local_only_env() {
148 local configured_port
149 configured_port="$(get_env_value PORT)"
150 if [[ -n "$configured_port" && -z "${APP_PORT:-}" ]]; then
151 APP_PORT="$configured_port"
152 fi
153 if [[ -z "${APP_PORT:-}" ]]; then
154 APP_PORT="3000"
155 fi
156
157 if ! is_valid_port "$APP_PORT"; then
158 echo "Invalid port: $APP_PORT"
159 exit 1
160 fi
161
162 if ! is_local_port_free "$APP_PORT"; then
163 local requested_port="$APP_PORT"
164 APP_PORT="$(find_next_free_local_port "$APP_PORT" || true)"
165 if [[ -z "$APP_PORT" ]]; then
166 echo "Could not find a free local port for the app."
167 exit 1
168 fi
169 echo "⚠️ App port ${requested_port} is already in use. Using ${APP_PORT} instead."
170 fi
171
172 upsert_env_value PORT "$APP_PORT"
173 upsert_env_value HOST "127.0.0.1"
174}
175
176run_app_install() {
177 local install_cmd=(bash "$SCRIPT_DIR/install.sh" --port "$APP_PORT")
178 install_cmd+=("${INSTALL_ARGS[@]}")
179 "${install_cmd[@]}"
180}
181
182install_tailscale_if_needed() {
183 if command -v tailscale >/dev/null 2>&1; then
184 echo "✅ Tailscale already installed."
185 return
186 fi
187
188 echo "📦 Installing Tailscale..."
189 if command -v curl >/dev/null 2>&1; then
190 run_as_root bash -c 'curl -fsSL https://tailscale.com/install.sh | sh'
191 elif command -v wget >/dev/null 2>&1; then
192 run_as_root bash -c 'wget -qO- https://tailscale.com/install.sh | sh'
193 else
194 echo "Need curl or wget to install Tailscale automatically."
195 exit 1
196 fi
197
198 if ! command -v tailscale >/dev/null 2>&1; then
199 echo "Tailscale installation did not complete successfully."
200 exit 1
201 fi
202}
203
204ensure_tailscaled_running() {
205 echo "🔧 Ensuring tailscaled is running..."
206 if command -v systemctl >/dev/null 2>&1; then
207 run_as_root systemctl enable --now tailscaled
208 return
209 fi
210
211 if command -v rc-service >/dev/null 2>&1; then
212 run_as_root rc-service tailscaled start || true
213 if command -v rc-update >/dev/null 2>&1; then
214 run_as_root rc-update add tailscaled default || true
215 fi
216 return
217 fi
218
219 if command -v service >/dev/null 2>&1; then
220 run_as_root service tailscaled start || true
221 return
222 fi
223
224 echo "Could not detect init system to start tailscaled automatically."
225 echo "Please start tailscaled manually, then re-run this script."
226 exit 1
227}
228
229ensure_tailscale_connected() {
230 if run_as_root tailscale ip -4 >/dev/null 2>&1; then
231 echo "✅ Tailscale is already connected."
232 return
233 fi
234
235 echo "🔐 Connecting this host to Tailscale..."
236 local up_cmd=(tailscale up)
237
238 if [[ -n "$TS_AUTHKEY" ]]; then
239 up_cmd+=(--authkey "$TS_AUTHKEY")
240 fi
241
242 if [[ -n "$TS_HOSTNAME" ]]; then
243 up_cmd+=(--hostname "$TS_HOSTNAME")
244 fi
245
246 if ! run_as_root "${up_cmd[@]}"; then
247 echo "Failed to connect to Tailscale."
248 if [[ -z "$TS_AUTHKEY" ]]; then
249 echo "Tip: provide --auth-key for non-interactive server setup."
250 fi
251 exit 1
252 fi
253}
254
255get_used_tailscale_https_ports() {
256 local json
257 json="$(run_as_root tailscale serve status --json 2>/dev/null || true)"
258 if [[ -z "$json" || "$json" == "{}" ]]; then
259 return 0
260 fi
261
262 printf '%s' "$json" | node -e '
263const fs = require("node:fs");
264try {
265 const data = JSON.parse(fs.readFileSync(0, "utf8"));
266 const sets = [data?.Web, data?.web, data?.TCP, data?.tcp];
267 const used = new Set();
268 for (const obj of sets) {
269 if (!obj || typeof obj !== "object") continue;
270 for (const key of Object.keys(obj)) {
271 if (/^\d+$/.test(key)) used.add(Number(key));
272 }
273 }
274 process.stdout.write([...used].sort((a, b) => a - b).join("\n"));
275} catch {}
276'
277}
278
279pick_tailscale_https_port() {
280 local preferred="$1"
281 local allow_used_preferred="${2:-0}"
282 local used_ports
283 used_ports="$(get_used_tailscale_https_ports || true)"
284
285 local is_used=0
286 if [[ -n "$preferred" ]]; then
287 if ! is_valid_port "$preferred"; then
288 echo "Invalid Tailscale HTTPS port: $preferred"
289 exit 1
290 fi
291 if [[ -z "$used_ports" ]] || ! grep -qx "$preferred" <<<"$used_ports"; then
292 printf '%s\n' "$preferred"
293 return 0
294 fi
295 if [[ "$allow_used_preferred" -eq 1 ]]; then
296 printf '%s\n' "$preferred"
297 return 0
298 fi
299 is_used=1
300 fi
301
302 local candidate
303 for candidate in 443 8443 9443; do
304 if [[ -z "$used_ports" ]] || ! grep -qx "$candidate" <<<"$used_ports"; then
305 printf '%s\n' "$candidate"
306 return 0
307 fi
308 done
309
310 for candidate in $(seq 10000 65535); do
311 if [[ -z "$used_ports" ]] || ! grep -qx "$candidate" <<<"$used_ports"; then
312 printf '%s\n' "$candidate"
313 return 0
314 fi
315 done
316
317 if [[ "$is_used" -eq 1 ]]; then
318 echo "No free Tailscale HTTPS serve port available (preferred port is already used)."
319 else
320 echo "No free Tailscale HTTPS serve port available."
321 fi
322 exit 1
323}
324
325configure_tailscale_serve() {
326 local preferred_https_port="$TS_HTTPS_PORT"
327 local preferred_from_saved=0
328 local saved_https_port
329 saved_https_port="$(get_env_value TAILSCALE_HTTPS_PORT)"
330 if [[ -z "$preferred_https_port" && -n "$saved_https_port" ]]; then
331 preferred_https_port="$saved_https_port"
332 preferred_from_saved=1
333 fi
334
335 TS_HTTPS_PORT="$(pick_tailscale_https_port "$preferred_https_port" "$preferred_from_saved")"
336 upsert_env_value TAILSCALE_HTTPS_PORT "$TS_HTTPS_PORT"
337
338 if [[ -n "$preferred_https_port" && "$preferred_https_port" != "$TS_HTTPS_PORT" ]]; then
339 echo "⚠️ Tailscale HTTPS port ${preferred_https_port} is already used. Using ${TS_HTTPS_PORT}."
340 fi
341
342 echo "🌐 Configuring Tailscale Serve (HTTPS ${TS_HTTPS_PORT} -> localhost:${APP_PORT})..."
343 if ! run_as_root tailscale serve --https "$TS_HTTPS_PORT" --bg --yes "http://127.0.0.1:${APP_PORT}"; then
344 run_as_root tailscale serve --https "$TS_HTTPS_PORT" --bg "http://127.0.0.1:${APP_PORT}"
345 fi
346
347 if [[ "$USE_FUNNEL" -eq 1 ]]; then
348 echo "⚠️ Enabling Tailscale Funnel (public internet exposure)..."
349 if ! run_as_root tailscale funnel --https "$TS_HTTPS_PORT" --bg --yes "http://127.0.0.1:${APP_PORT}"; then
350 run_as_root tailscale funnel --https "$TS_HTTPS_PORT" --bg "http://127.0.0.1:${APP_PORT}"
351 fi
352 fi
353}
354
355get_tailscale_dns_name() {
356 local json
357 json="$(run_as_root tailscale status --json 2>/dev/null || true)"
358 if [[ -z "$json" ]]; then
359 return 0
360 fi
361
362 printf '%s' "$json" | node -e '
363const fs = require("node:fs");
364try {
365 const data = JSON.parse(fs.readFileSync(0, "utf8"));
366 const dnsName = typeof data?.Self?.DNSName === "string" ? data.Self.DNSName : "";
367 process.stdout.write(dnsName.replace(/\.$/, ""));
368} catch {}
369'
370}
371
372get_tailscale_ipv4() {
373 run_as_root tailscale ip -4 2>/dev/null | head -n 1 || true
374}
375
376print_summary() {
377 local dns_name
378 dns_name="$(get_tailscale_dns_name)"
379 local ts_ip
380 ts_ip="$(get_tailscale_ipv4)"
381 local final_url=""
382 local port_suffix=""
383 if [[ "$TS_HTTPS_PORT" != "443" ]]; then
384 port_suffix=":${TS_HTTPS_PORT}"
385 fi
386 if [[ -n "$dns_name" ]]; then
387 final_url="https://${dns_name}${port_suffix}"
388 elif [[ -n "$ts_ip" ]]; then
389 final_url="https://${ts_ip}${port_suffix}"
390 fi
391
392 echo ""
393 echo "Setup complete for Linux server mode."
394 echo ""
395 echo "App binding:"
396 echo " HOST=127.0.0.1 (local-only)"
397 echo " PORT=${APP_PORT}"
398 echo ""
399 echo "Local checks on server:"
400 echo " http://127.0.0.1:${APP_PORT}"
401 echo ""
402 echo "Tailnet access:"
403 if [[ -n "$final_url" ]]; then
404 echo " ${final_url}"
405 echo ""
406 echo "✅ It will be accessible on ${final_url} wherever that person is authenticated on Tailscale."
407 else
408 echo " Run: sudo tailscale status"
409 fi
410
411 if [[ "$USE_FUNNEL" -eq 1 ]]; then
412 echo ""
413 echo "Public access is enabled via Funnel."
414 else
415 echo ""
416 echo "Public internet exposure is disabled."
417 fi
418
419 echo ""
420 echo "Useful commands:"
421 echo " ./install.sh --status"
422 echo " sudo tailscale serve status"
423 if [[ "$USE_FUNNEL" -eq 1 ]]; then
424 echo " sudo tailscale funnel status"
425 fi
426}
427
428while [[ $# -gt 0 ]]; do
429 case "$1" in
430 --port)
431 if [[ $# -lt 2 ]]; then
432 echo "Missing value for --port"
433 exit 1
434 fi
435 APP_PORT="$2"
436 shift
437 ;;
438 --https-port)
439 if [[ $# -lt 2 ]]; then
440 echo "Missing value for --https-port"
441 exit 1
442 fi
443 TS_HTTPS_PORT="$2"
444 shift
445 ;;
446 --auth-key)
447 if [[ $# -lt 2 ]]; then
448 echo "Missing value for --auth-key"
449 exit 1
450 fi
451 TS_AUTHKEY="$2"
452 shift
453 ;;
454 --hostname)
455 if [[ $# -lt 2 ]]; then
456 echo "Missing value for --hostname"
457 exit 1
458 fi
459 TS_HOSTNAME="$2"
460 shift
461 ;;
462 --funnel)
463 USE_FUNNEL=1
464 ;;
465 --pm2|--nohup|--skip-install|--skip-build|--no-start|--start-only)
466 INSTALL_ARGS+=("$1")
467 ;;
468 -h|--help)
469 usage
470 exit 0
471 ;;
472 *)
473 echo "Unknown option: $1"
474 usage
475 exit 1
476 ;;
477 esac
478 shift
479done
480
481ensure_linux
482ensure_sudo
483require_command bash
484require_command node
485require_command npm
486require_command git
487
488ensure_local_only_env
489run_app_install
490install_tailscale_if_needed
491ensure_tailscaled_running
492ensure_tailscale_connected
493configure_tailscale_serve
494print_summary