Merge pull request #336 from outis1one/claude/ionos-script-integration-x32ofw
Claude/ionos script integration x32ofw
This commit is contained in:
@@ -186,7 +186,7 @@ a ready-to-copy Caddy config snippet to `~/docker/caddy-snippets/`.
|
||||
|-------|---------|
|
||||
| `base` | `net-tools`, `ncdu`, `git`, `curl`, `wget`, `htop`, `tree`, `zip`/`unzip`, `ca-certificates`, `gnupg`, `jq`, `rsync`; `glow` (terminal markdown reader, Charm apt repo); Docker CE + Compose plugin; `openssh-server` with GitHub/Launchpad SSH key import, optional password-auth lockdown, and SSH Host aliases; optional NetBird overlay network |
|
||||
| `homelab` | `caddy`, `crowdsec`, `authelia`, `homeassistant`, `asterisk` (own dedicated coturn for TURN/STUN — see `mattermost` below for the other coturn-owning service), `pstn-trunk`, `sms-inbound`, `security-dashboard`, `sunshine`, `vpn-data-mount` (mount existing SMB shares from a NetBird-connected home box — SSH trust bootstrap, then read-only discovery of shares already configured there; never writes to the home box's Samba config; repeatable, pick from any number of a home box's shares in one pass; optional per-share [gocryptfs decrypt layer](#client-side-encryption-for-vpn-data-mount) so the VPS only ever handles ciphertext) |
|
||||
| `utilities` | `actualbudget`, `ai-gpu`, `ai-stack`, `archivebox`, `beszel` (lightweight server + Docker monitoring — CPU/RAM/disk/network, auto-discovers running containers via the Docker socket; complements Gatus rather than replacing it — Gatus is a black-box HTTP check, Beszel is white-box host/process monitoring), `beszel-agent` (agent-only Beszel install for a remote/homelab box reporting to a hub elsewhere — connects outbound over HTTPS, no VPN/port-forwarding/FQDN needed on that box), `changedetection`, `ddclient`, `filebrowser`, `fmd`, `gatus`, `homebox`, `iopaint`, `joplin`, `koha`, `magicmirror`, `mail-archiver`, `mattermost`, `mealie`, `meshcentral`, `n8n`, `nextcloud`, `ntfy`, `onlyoffice`, `paintplus`, `portainer`, `rustdesk`, `stirling-pdf`, `syncthing`, `traccar`, `unifi`, `uptimekuma`, `vaultwarden`, `watchyourlan`, `watchtower`, `wg-easy`, `wordpress` (multi-site, dedicated MariaDB per site — blogs, business sites, e-commerce via WooCommerce) |
|
||||
| `utilities` | `actualbudget`, `ai-gpu`, `ai-stack`, `archivebox`, `beszel` (lightweight server + Docker monitoring — CPU/RAM/disk/network, auto-discovers running containers via the Docker socket; complements Gatus rather than replacing it — Gatus is a black-box HTTP check, Beszel is white-box host/process monitoring), `beszel-agent` (agent-only Beszel install for a remote/homelab box reporting to a hub elsewhere — connects outbound over HTTPS, no VPN/port-forwarding/FQDN needed on that box), `changedetection`, `ddclient`, `filebrowser`, `fmd`, `gatus`, `homebox`, `iopaint`, `joplin`, `koha`, `magicmirror`, `mail-archiver`, `mattermost`, `mealie`, `meshcentral`, `n8n`, `nextcloud`, `ntfy`, `onlyoffice`, `paintplus`, `pihole` (standalone DNS ad/tracker blocking — not wired into any VPN's DNS push), `portainer`, `rustdesk`, `stirling-pdf`, `syncthing`, `traccar`, `unifi`, `uptimekuma`, `vaultwarden`, `watchyourlan`, `watchtower`, `wg-easy`, `wordpress` (multi-site, dedicated MariaDB per site — blogs, business sites, e-commerce via WooCommerce) |
|
||||
| `media` | `arm`, `audiobookshelf`, `calibre-web`, `emby`, `immich`, `jellyfin`, `lyrion` |
|
||||
| `cameras` | `frigate`, `frigate-audio`, `frigate-notify`, `sky-cam` |
|
||||
| `gaming` | `drum-rhythm-game`, `js99er`, `kyber-launcher`, `kyber-server`, `minecraft`, `wolf`, `wolf-pair` |
|
||||
|
||||
Executable
+302
@@ -0,0 +1,302 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
fix_pikapods_dump.py — Patches two confirmed Adminer PostgreSQL-export bugs
|
||||
in a Mattermost SQL dump, before importing it via migrate-from-pikapods.sh.
|
||||
|
||||
Bug 1: Adminer omits quotes around enum-label DEFAULT values, e.g.
|
||||
DEFAULT link instead of DEFAULT 'link'
|
||||
DEFAULT client_credentials instead of DEFAULT 'client_credentials'
|
||||
This makes Postgres treat the label as a column reference, which is
|
||||
illegal in a DEFAULT expression, so the whole CREATE TABLE fails.
|
||||
|
||||
Bug 2: Adminer serializes PostgreSQL boolean columns as bare integer
|
||||
literals (0 / 1) in INSERT statements instead of true/false.
|
||||
Postgres does not implicitly cast integer literals to boolean, so
|
||||
every row touching one of those columns is rejected.
|
||||
|
||||
Bug 2's fix parses each CREATE TABLE in the dump to find every column
|
||||
declared as `boolean` (not a hand-curated list from partial error
|
||||
messages — Postgres only reports the FIRST bad column per row, so a
|
||||
list built from error output alone would likely be incomplete). It then
|
||||
rewrites only the VALUES-tuple positions that correspond to those
|
||||
specific boolean columns, leaving every other value in the row
|
||||
(including other literal 0/1 integers) untouched.
|
||||
|
||||
Usage:
|
||||
python3 fix_pikapods_dump.py input.sql output.sql
|
||||
|
||||
Always writes to a NEW file — never modifies the input in place — so the
|
||||
original export is preserved if something looks wrong afterward.
|
||||
"""
|
||||
import re
|
||||
import sys
|
||||
|
||||
|
||||
def split_top_level(s, sep=","):
|
||||
"""Split s on sep, but only outside single-quoted strings and outside
|
||||
nested parens. '' inside a quoted string is the SQL escape for a
|
||||
literal quote and does not end the string."""
|
||||
parts = []
|
||||
buf = []
|
||||
depth = 0
|
||||
in_quote = False
|
||||
i = 0
|
||||
n = len(s)
|
||||
while i < n:
|
||||
c = s[i]
|
||||
if in_quote:
|
||||
if c == "'":
|
||||
# doubled quote = escaped literal quote, stays in_quote
|
||||
if i + 1 < n and s[i + 1] == "'":
|
||||
buf.append("''")
|
||||
i += 2
|
||||
continue
|
||||
in_quote = False
|
||||
buf.append(c)
|
||||
i += 1
|
||||
continue
|
||||
buf.append(c)
|
||||
i += 1
|
||||
continue
|
||||
if c == "'":
|
||||
in_quote = True
|
||||
buf.append(c)
|
||||
i += 1
|
||||
continue
|
||||
if c == "(":
|
||||
depth += 1
|
||||
buf.append(c)
|
||||
i += 1
|
||||
continue
|
||||
if c == ")":
|
||||
depth -= 1
|
||||
buf.append(c)
|
||||
i += 1
|
||||
continue
|
||||
if c == sep and depth == 0:
|
||||
parts.append("".join(buf))
|
||||
buf = []
|
||||
i += 1
|
||||
continue
|
||||
buf.append(c)
|
||||
i += 1
|
||||
parts.append("".join(buf))
|
||||
return parts
|
||||
|
||||
|
||||
def split_statements(sql_text):
|
||||
"""Split a whole dump into individual statements at semicolons that
|
||||
are not inside a quoted string. Returns list of (statement_text,
|
||||
trailing_terminator) so the exact original text can be reassembled
|
||||
byte-for-byte from the pieces."""
|
||||
stmts = []
|
||||
buf = []
|
||||
in_quote = False
|
||||
i = 0
|
||||
n = len(sql_text)
|
||||
while i < n:
|
||||
c = sql_text[i]
|
||||
if in_quote:
|
||||
buf.append(c)
|
||||
if c == "'":
|
||||
if i + 1 < n and sql_text[i + 1] == "'":
|
||||
buf.append(sql_text[i + 1])
|
||||
i += 2
|
||||
continue
|
||||
in_quote = False
|
||||
i += 1
|
||||
continue
|
||||
if c == "'":
|
||||
in_quote = True
|
||||
buf.append(c)
|
||||
i += 1
|
||||
continue
|
||||
if c == ";":
|
||||
buf.append(c)
|
||||
stmts.append("".join(buf))
|
||||
buf = []
|
||||
i += 1
|
||||
continue
|
||||
buf.append(c)
|
||||
i += 1
|
||||
if buf:
|
||||
stmts.append("".join(buf))
|
||||
return stmts
|
||||
|
||||
|
||||
CREATE_TABLE_NAME_RE = re.compile(
|
||||
r'CREATE TABLE\s+(?:"public"\.)?"([^"]+)"\s*\(',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
INSERT_RE = re.compile(
|
||||
r'^(INSERT INTO\s+(?:"public"\.)?"([^"]+)"\s*\()([^)]*)\)\s*VALUES\s*(.*);\s*$',
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
|
||||
|
||||
def find_matching_paren(s, open_idx):
|
||||
"""Return the index of the ')' matching the '(' at open_idx, skipping
|
||||
over quoted strings so a paren inside a quoted default value (e.g. a
|
||||
function call in a DEFAULT expression) doesn't miscount depth."""
|
||||
depth = 0
|
||||
in_quote = False
|
||||
i = open_idx
|
||||
n = len(s)
|
||||
while i < n:
|
||||
c = s[i]
|
||||
if in_quote:
|
||||
if c == "'":
|
||||
if i + 1 < n and s[i + 1] == "'":
|
||||
i += 2
|
||||
continue
|
||||
in_quote = False
|
||||
i += 1
|
||||
continue
|
||||
if c == "'":
|
||||
in_quote = True
|
||||
i += 1
|
||||
continue
|
||||
if c == "(":
|
||||
depth += 1
|
||||
elif c == ")":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return i
|
||||
i += 1
|
||||
return -1
|
||||
|
||||
|
||||
def parse_boolean_columns(create_table_stmt):
|
||||
"""Given a full CREATE TABLE statement (trailing syntax after the
|
||||
closing paren — WITHOUT OIDS, TABLESPACE, etc — is fine, not assumed
|
||||
absent), return the set of column names declared as boolean."""
|
||||
name_m = CREATE_TABLE_NAME_RE.search(create_table_stmt)
|
||||
if not name_m:
|
||||
return set()
|
||||
open_idx = name_m.end() - 1 # the '(' the regex matched
|
||||
close_idx = find_matching_paren(create_table_stmt, open_idx)
|
||||
if close_idx == -1:
|
||||
return set()
|
||||
body = create_table_stmt[open_idx + 1 : close_idx]
|
||||
cols = set()
|
||||
for coldef in split_top_level(body):
|
||||
coldef = coldef.strip()
|
||||
cm = re.match(r'^"([^"]+)"\s+([A-Za-z_][A-Za-z0-9_]*)', coldef)
|
||||
if cm and cm.group(2).lower() == "boolean":
|
||||
cols.add(cm.group(1))
|
||||
return cols
|
||||
|
||||
|
||||
def fix_default_quoting(stmt):
|
||||
"""Bug 1: unquoted enum-label DEFAULTs. Only touches DEFAULT clauses
|
||||
that name one of the two enum types confirmed broken in this export
|
||||
(channel_bookmark_type, outgoingoauthconnections_granttype) — narrow
|
||||
and conservative rather than a blanket 'quote anything after DEFAULT'
|
||||
rule that could misfire on legitimate unquoted defaults elsewhere
|
||||
(numbers, now(), etc)."""
|
||||
stmt, n1 = re.subn(
|
||||
r"(channel_bookmark_type\s+DEFAULT\s+)([A-Za-z_][A-Za-z0-9_]*)(?=[,\)])",
|
||||
r"\1'\2'",
|
||||
stmt,
|
||||
)
|
||||
stmt, n2 = re.subn(
|
||||
r"(outgoingoauthconnections_granttype\s+DEFAULT\s+)([A-Za-z_][A-Za-z0-9_]*)(?=[,\)])",
|
||||
r"\1'\2'",
|
||||
stmt,
|
||||
)
|
||||
return stmt, n1 + n2
|
||||
|
||||
|
||||
def patch_insert_booleans(stmt, table, col_list_raw, values_raw, bool_cols):
|
||||
"""Rewrite bare 0/1 literals to false/true at the positions in
|
||||
col_list_raw that correspond to bool_cols. Returns (new_values_text,
|
||||
count_of_values_changed)."""
|
||||
col_names = [c.strip().strip('"') for c in split_top_level(col_list_raw)]
|
||||
bool_positions = {i for i, c in enumerate(col_names) if c in bool_cols}
|
||||
if not bool_positions:
|
||||
return values_raw, 0
|
||||
|
||||
tuples = split_top_level(values_raw)
|
||||
changed = 0
|
||||
new_tuples = []
|
||||
for tup in tuples:
|
||||
tup_stripped = tup.strip()
|
||||
if not (tup_stripped.startswith("(") and tup_stripped.endswith(")")):
|
||||
new_tuples.append(tup)
|
||||
continue
|
||||
inner = tup_stripped[1:-1]
|
||||
vals = split_top_level(inner)
|
||||
for pos in bool_positions:
|
||||
if pos >= len(vals):
|
||||
continue
|
||||
v = vals[pos].strip()
|
||||
if v == "0":
|
||||
vals[pos] = "false"
|
||||
changed += 1
|
||||
elif v == "1":
|
||||
vals[pos] = "true"
|
||||
changed += 1
|
||||
prefix = tup[: len(tup) - len(tup.lstrip())]
|
||||
suffix = tup[len(tup.rstrip()):]
|
||||
new_tuples.append(prefix + "(" + ",".join(vals) + ")" + suffix)
|
||||
return ",".join(new_tuples), changed
|
||||
|
||||
|
||||
def process(input_path, output_path):
|
||||
with open(input_path, "r", encoding="utf-8", errors="surrogateescape") as f:
|
||||
text = f.read()
|
||||
|
||||
statements = split_statements(text)
|
||||
bool_cols_by_table = {}
|
||||
out = []
|
||||
default_fixes = 0
|
||||
total_value_fixes = 0
|
||||
tables_patched = set()
|
||||
|
||||
for stmt in statements:
|
||||
stripped = stmt.strip()
|
||||
upper = stripped.upper()
|
||||
|
||||
if upper.startswith("CREATE TABLE"):
|
||||
fixed_stmt, n = fix_default_quoting(stmt)
|
||||
default_fixes += n
|
||||
name_m = CREATE_TABLE_NAME_RE.search(fixed_stmt)
|
||||
if name_m:
|
||||
bool_cols_by_table[name_m.group(1)] = parse_boolean_columns(fixed_stmt)
|
||||
out.append(fixed_stmt)
|
||||
continue
|
||||
|
||||
if upper.startswith("INSERT INTO"):
|
||||
m = INSERT_RE.match(stripped)
|
||||
if m:
|
||||
prefix, table, col_list_raw, values_raw = m.groups()
|
||||
bool_cols = bool_cols_by_table.get(table, set())
|
||||
if bool_cols:
|
||||
new_values, n = patch_insert_booleans(
|
||||
stripped, table, col_list_raw, values_raw, bool_cols
|
||||
)
|
||||
if n:
|
||||
total_value_fixes += n
|
||||
tables_patched.add(table)
|
||||
rebuilt = f'{prefix}{col_list_raw}) VALUES {new_values};'
|
||||
out.append(rebuilt)
|
||||
continue
|
||||
out.append(stmt)
|
||||
continue
|
||||
|
||||
out.append(stmt)
|
||||
|
||||
with open(output_path, "w", encoding="utf-8", errors="surrogateescape") as f:
|
||||
f.write("".join(out))
|
||||
|
||||
print(f"DEFAULT-clause quoting fixes: {default_fixes}")
|
||||
print(f"Boolean literal fixes: {total_value_fixes} across {len(tables_patched)} table(s)")
|
||||
if tables_patched:
|
||||
print("Tables patched: " + ", ".join(sorted(tables_patched)))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) != 3:
|
||||
print(f"Usage: {sys.argv[0]} input.sql output.sql", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
process(sys.argv[1], sys.argv[2])
|
||||
+85
-15
@@ -421,6 +421,11 @@ install_backup() {
|
||||
echo ""
|
||||
|
||||
local DEFAULT_DEST="$ACTUAL_HOME/backups/kopia-backup"
|
||||
if [ -f "$CONF_FILE" ]; then
|
||||
local _existing_default_repo
|
||||
_existing_default_repo="$(grep '^DEST_default_REPO=' "$CONF_FILE" 2>/dev/null | sed -E 's/^DEST_default_REPO="(.*)"$/\1/')"
|
||||
[ -n "$_existing_default_repo" ] && DEFAULT_DEST="$_existing_default_repo"
|
||||
fi
|
||||
local _repo=""
|
||||
prompt_text " Default repository path [${DEFAULT_DEST}]:" "$DEFAULT_DEST" _repo
|
||||
_repo="${_repo/#\~/$ACTUAL_HOME}"; _repo="${_repo%/}"
|
||||
@@ -430,6 +435,28 @@ install_backup() {
|
||||
DEST_REPOS["default"]="$_repo"
|
||||
DEST_CONFIGS["default"]="/etc/kopia-backup/default.config"
|
||||
|
||||
# Preserve any extra (non-"default") destinations already configured.
|
||||
# This script has no update/fresh distinction, so without this,
|
||||
# skipping the "Add more destinations?" prompt below on a rerun would
|
||||
# silently drop every extra destination — and anything mapped to it —
|
||||
# from the rewritten backup.conf, rather than just leaving it as-is.
|
||||
if [ -f "$CONF_FILE" ]; then
|
||||
local _existing_dest_names _en _existing_repo _existing_cfg
|
||||
_existing_dest_names="$(grep '^DEST_NAMES=' "$CONF_FILE" 2>/dev/null | sed -E 's/^DEST_NAMES="(.*)"$/\1/')"
|
||||
for _en in $_existing_dest_names; do
|
||||
[ "$_en" = "default" ] && continue
|
||||
_existing_repo="$(grep "^DEST_${_en}_REPO=" "$CONF_FILE" 2>/dev/null | sed -E "s/^DEST_${_en}_REPO=\"(.*)\"\$/\1/")"
|
||||
[ -z "$_existing_repo" ] && continue
|
||||
_existing_cfg="$(grep "^DEST_${_en}_CONFIG=" "$CONF_FILE" 2>/dev/null | sed -E "s/^DEST_${_en}_CONFIG=\"(.*)\"\$/\1/")"
|
||||
DEST_REPOS["$_en"]="$_existing_repo"
|
||||
DEST_CONFIGS["$_en"]="${_existing_cfg:-/etc/kopia-backup/${_en}.config}"
|
||||
DEST_NAMES_ARR+=("$_en")
|
||||
done
|
||||
if [ "${#DEST_NAMES_ARR[@]}" -gt 1 ]; then
|
||||
log_info " Keeping already-configured destination(s): ${DEST_NAMES_ARR[*]:1}"
|
||||
fi
|
||||
fi
|
||||
|
||||
local _extra=""
|
||||
prompt_yn " Add more destinations (for services on different drives)? (y/N):" "n" _extra
|
||||
if [[ "$_extra" =~ ^[Yy]$ ]]; then
|
||||
@@ -440,12 +467,16 @@ install_backup() {
|
||||
[ -z "$_dn" ] && break
|
||||
_dn="${_dn//[^a-zA-Z0-9_]/_}"
|
||||
[ "$_dn" = "default" ] && { log_warning " 'default' is reserved — use another name."; continue; }
|
||||
prompt_text " Path for '$_dn' repository:" "" _dr
|
||||
# Typing an already-known name reconfigures its path rather than
|
||||
# duplicating it in DEST_NAMES_ARR.
|
||||
prompt_text " Path for '$_dn' repository:" "${DEST_REPOS[$_dn]:-}" _dr
|
||||
[ -z "$_dr" ] && continue
|
||||
_dr="${_dr/#\~/$ACTUAL_HOME}"; _dr="${_dr%/}"
|
||||
DEST_REPOS["$_dn"]="$_dr"
|
||||
DEST_CONFIGS["$_dn"]="/etc/kopia-backup/${_dn}.config"
|
||||
DEST_NAMES_ARR+=("$_dn")
|
||||
if [[ " ${DEST_NAMES_ARR[*]} " != *" $_dn "* ]]; then
|
||||
DEST_NAMES_ARR+=("$_dn")
|
||||
fi
|
||||
log_success " Destination '$_dn' → $_dr"
|
||||
done
|
||||
fi
|
||||
@@ -464,11 +495,17 @@ install_backup() {
|
||||
printf " %-16s %s\n" "$dn" "${DEST_REPOS[$dn]}"
|
||||
done
|
||||
echo ""
|
||||
echo " Press Enter to accept the default for each service."
|
||||
echo " Press Enter to accept the shown default for each service."
|
||||
echo ""
|
||||
local _d
|
||||
local _d _svc_var _existing_svc_dest
|
||||
for svc in "${ALL_SVCS[@]}"; do
|
||||
prompt_text " $svc [default]:" "default" _d
|
||||
_svc_var="${svc//-/_}"
|
||||
_existing_svc_dest="default"
|
||||
if [ -f "$CONF_FILE" ]; then
|
||||
_existing_svc_dest="$(grep -E "^#?SVC_${_svc_var}=" "$CONF_FILE" 2>/dev/null | tail -1 | sed -E 's/^#?SVC_[A-Za-z0-9_]+="(.*)"$/\1/')"
|
||||
[ -z "$_existing_svc_dest" ] && _existing_svc_dest="default"
|
||||
fi
|
||||
prompt_text " $svc [$_existing_svc_dest]:" "$_existing_svc_dest" _d
|
||||
if [ -n "$_d" ] && [ "$_d" != "default" ] && [ -n "${DEST_REPOS[$_d]:-}" ]; then
|
||||
SVC_DEST_MAP["$svc"]="$_d"
|
||||
fi
|
||||
@@ -518,13 +555,27 @@ install_backup() {
|
||||
echo " 3) Weekly (Sunday 02:00)"
|
||||
echo " 4) Custom (systemd OnCalendar)"
|
||||
echo ""
|
||||
local _sch=""
|
||||
prompt_text " How often? [1]:" "1" _sch
|
||||
# Preselect whatever's already scheduled, read back from the live timer
|
||||
# unit rather than backup.conf (the schedule isn't stored there — it's
|
||||
# baked directly into the .timer file). Without this, re-running the
|
||||
# installer and just hitting Enter through this prompt would silently
|
||||
# revert a customized schedule back to "1) Daily at 02:00" every time.
|
||||
local _sch="1" _existing_oncal=""
|
||||
if [ -f "/etc/systemd/system/${SVC_NAME}.timer" ]; then
|
||||
_existing_oncal="$(grep '^OnCalendar=' "/etc/systemd/system/${SVC_NAME}.timer" 2>/dev/null | cut -d= -f2-)"
|
||||
case "$_existing_oncal" in
|
||||
"*-*-* 02,14:00:00") _sch="2" ;;
|
||||
"Sun *-*-* 02:00:00") _sch="3" ;;
|
||||
"*-*-* 02:00:00"|"") _sch="1" ;;
|
||||
*) _sch="4" ;;
|
||||
esac
|
||||
fi
|
||||
prompt_text " How often? [$_sch]:" "$_sch" _sch
|
||||
local ONCALENDAR SCHED_LABEL
|
||||
case "${_sch:-1}" in
|
||||
2) ONCALENDAR="*-*-* 02,14:00:00"; SCHED_LABEL="every 12 hours" ;;
|
||||
3) ONCALENDAR="Sun *-*-* 02:00:00"; SCHED_LABEL="weekly Sunday 02:00" ;;
|
||||
4) prompt_text " OnCalendar expression:" "*-*-* 02:00:00" ONCALENDAR; SCHED_LABEL="$ONCALENDAR" ;;
|
||||
4) prompt_text " OnCalendar expression:" "${_existing_oncal:-*-*-* 02:00:00}" ONCALENDAR; SCHED_LABEL="$ONCALENDAR" ;;
|
||||
*) ONCALENDAR="*-*-* 02:00:00"; SCHED_LABEL="daily at 02:00" ;;
|
||||
esac
|
||||
local KEEP_LATEST=""
|
||||
@@ -542,9 +593,13 @@ install_backup() {
|
||||
echo " Example URL: https://ntfy.sh/my-backup-alerts"
|
||||
echo ""
|
||||
local NTFY_URL="" NTFY_TOKEN=""
|
||||
prompt_text " ntfy topic URL (blank to skip):" "" NTFY_URL
|
||||
if [ -f "$CONF_FILE" ]; then
|
||||
NTFY_URL="$(grep "^NTFY_URL=" "$CONF_FILE" 2>/dev/null | sed -E "s/^NTFY_URL='(.*)'\$/\1/")"
|
||||
NTFY_TOKEN="$(grep "^NTFY_TOKEN=" "$CONF_FILE" 2>/dev/null | sed -E "s/^NTFY_TOKEN='(.*)'\$/\1/")"
|
||||
fi
|
||||
prompt_text " ntfy topic URL (blank to skip)${NTFY_URL:+ — already set to $NTFY_URL}:" "$NTFY_URL" NTFY_URL
|
||||
if [ -n "$NTFY_URL" ]; then
|
||||
prompt_text " ntfy access token (blank if public/no auth):" "" NTFY_TOKEN
|
||||
prompt_text " ntfy access token (blank if public/no auth${NTFY_TOKEN:+ — one is already set, Enter keeps it}):" "$NTFY_TOKEN" NTFY_TOKEN
|
||||
fi
|
||||
|
||||
# ── Disaster-recovery spare box (optional) ────────────────────────────────
|
||||
@@ -774,14 +829,29 @@ install_backup() {
|
||||
# live: a paste into the hidden Application Key field can silently
|
||||
# capture nothing depending on the terminal/SSH client, with no
|
||||
# other symptom until this point.
|
||||
local B2_BUCKET="" B2_ENDPOINT="" B2_KEY_ID="" B2_APP_KEY=""
|
||||
prompt_text " Bucket name:" "" B2_BUCKET
|
||||
# Pre-fill from whatever's already configured (only meaningful if the
|
||||
# existing REMOTE_TYPE really is s3/B2 — a REMOTE_ARGS left over from
|
||||
# a different provider, e.g. sftp, wouldn't parse into anything
|
||||
# useful here and is harmlessly skipped). Otherwise reconfiguring
|
||||
# just to rotate one field means blindly retyping all four, and a
|
||||
# mispaste on any one of them loses the other three that were
|
||||
# already typed correctly this run.
|
||||
local _existing_b2_bucket="" _existing_b2_endpoint="" _existing_b2_keyid="" _existing_b2_appkey=""
|
||||
if [ "$REMOTE_TYPE" = "s3" ]; then
|
||||
_existing_b2_bucket="$(echo "$REMOTE_ARGS" | grep -oE -- '--bucket=[^ ]*' | cut -d= -f2-)"
|
||||
_existing_b2_endpoint="$(echo "$REMOTE_ARGS" | grep -oE -- '--endpoint=[^ ]*' | cut -d= -f2-)"
|
||||
_existing_b2_keyid="$(echo "$REMOTE_ARGS" | grep -oE -- '--access-key=[^ ]*' | cut -d= -f2-)"
|
||||
_existing_b2_appkey="$(echo "$REMOTE_ARGS" | grep -oE -- '--secret-access-key=[^ ]*' | cut -d= -f2-)"
|
||||
fi
|
||||
local B2_BUCKET="$_existing_b2_bucket" B2_ENDPOINT="$_existing_b2_endpoint" B2_KEY_ID="$_existing_b2_keyid" B2_APP_KEY=""
|
||||
prompt_text " Bucket name:" "$B2_BUCKET" B2_BUCKET
|
||||
echo " (${#B2_BUCKET} characters entered)"
|
||||
prompt_text " Endpoint (e.g. s3.us-west-004.backblazeb2.com):" "" B2_ENDPOINT
|
||||
prompt_text " Endpoint (e.g. s3.us-west-004.backblazeb2.com):" "$B2_ENDPOINT" B2_ENDPOINT
|
||||
echo " (${#B2_ENDPOINT} characters entered)"
|
||||
prompt_text " Application Key ID:" "" B2_KEY_ID
|
||||
prompt_text " Application Key ID:" "$B2_KEY_ID" B2_KEY_ID
|
||||
echo " (${#B2_KEY_ID} characters entered)"
|
||||
read -rsp " Application Key (input hidden): " B2_APP_KEY; echo
|
||||
read -rsp " Application Key (input hidden${_existing_b2_appkey:+ — leave blank to keep the existing one}): " B2_APP_KEY; echo
|
||||
[ -z "$B2_APP_KEY" ] && B2_APP_KEY="$_existing_b2_appkey"
|
||||
echo " (${#B2_APP_KEY} characters entered)"
|
||||
|
||||
local _B2_MISSING=""
|
||||
|
||||
+48
-2
@@ -518,6 +518,28 @@ ${_CADDY_NET_BLOCK} healthcheck:
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# Fixes ownership on the bind-mounted volumes below to the fixed UID/GID
|
||||
# (2000) mattermost/mattermost-team-edition runs as, before the mattermost
|
||||
# service starts — every time, not just at install time. Confirmed live:
|
||||
# importing data from another host (e.g. a PikaPods migration) can leave
|
||||
# these owned by whatever UID did the copy instead of 2000, and the
|
||||
# container doesn't fix this itself on start the way postgres's official
|
||||
# image does — it just fails every file write with "permission denied"
|
||||
# until someone notices and runs chown by hand. This removes the "by
|
||||
# hand" part permanently: runs on every `docker compose up`, including a
|
||||
# plain host reboot, so ownership drift from any future cause self-heals
|
||||
# without needing this installer re-run again.
|
||||
mattermost-fix-perms:
|
||||
image: busybox:latest
|
||||
container_name: ${MM_CONTAINER}-fix-perms
|
||||
command: sh -c "chown -R 2000:2000 /data /logs /config /plugins"
|
||||
volumes:
|
||||
- ./data:/data
|
||||
- ./logs:/logs
|
||||
- ./config:/config
|
||||
- ./plugins:/plugins
|
||||
restart: "no"
|
||||
|
||||
mattermost:
|
||||
image: mattermost/mattermost-team-edition:latest
|
||||
container_name: ${MM_CONTAINER}
|
||||
@@ -527,6 +549,8 @@ ${_CADDY_NET_BLOCK} healthcheck:
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
mattermost-fix-perms:
|
||||
condition: service_completed_successfully
|
||||
volumes:
|
||||
- ./data:/mattermost/data
|
||||
- ./logs:/mattermost/logs
|
||||
@@ -732,6 +756,15 @@ MD
|
||||
# default). If you have a custom-format pg_dump instead, use `pg_restore`
|
||||
# in place of the `psql < dump` step below.
|
||||
#
|
||||
# Adminer's plain-text export has two confirmed bugs of its own — unquoted
|
||||
# enum-label DEFAULTs, and boolean columns serialized as bare 0/1 instead
|
||||
# of true/false — either of which makes the import below fail outright.
|
||||
# extras/fix_pikapods_dump.py patches both; run it on the dump BEFORE this
|
||||
# script if psql reports errors on CREATE TYPE/CREATE TABLE or boolean
|
||||
# columns:
|
||||
# python3 fix_pikapods_dump.py input.sql output.sql
|
||||
#
|
||||
|
||||
# IMPORTANT: point the files argument at the SUBDIRECTORY that holds
|
||||
# Mattermost's own file storage inside whatever you downloaded via SFTP
|
||||
# (commonly named `data`), not the whole SFTP root — PikaPods' exact
|
||||
@@ -739,7 +772,7 @@ MD
|
||||
# before running.
|
||||
#
|
||||
# Usage:
|
||||
# ./migrate-from-pikapods.sh <path-to-sql-dump> <path-to-files-dir>
|
||||
# sudo ./migrate-from-pikapods.sh <path-to-sql-dump> <path-to-files-dir>
|
||||
################################################################################
|
||||
|
||||
MIGRATE_HEAD
|
||||
@@ -754,13 +787,18 @@ MIGRATE_VARS
|
||||
|
||||
cat >> "$DIR/migrate-from-pikapods.sh" << 'MIGRATE_BODY'
|
||||
set -uo pipefail
|
||||
|
||||
# Needed for the chown to UID 2000 near the end (an ordinary user generally
|
||||
# can't chown files to an arbitrary UID that isn't their own).
|
||||
[ "${EUID:-$(id -u)}" -eq 0 ] || { echo "Run as root: sudo $0 ..."; exit 1; }
|
||||
|
||||
cd "$PROJECT_DIR" || exit 1
|
||||
|
||||
SQL_DUMP="${1:-}"
|
||||
FILES_DIR="${2:-}"
|
||||
|
||||
if [ -z "$SQL_DUMP" ] || [ -z "$FILES_DIR" ]; then
|
||||
echo "Usage: $0 <path-to-sql-dump> <path-to-files-dir>"
|
||||
echo "Usage: sudo $0 <path-to-sql-dump> <path-to-files-dir>"
|
||||
exit 1
|
||||
fi
|
||||
[ -f "$SQL_DUMP" ] || { echo "SQL dump not found: $SQL_DUMP"; exit 1; }
|
||||
@@ -807,6 +845,14 @@ echo "Copying files into ./data..."
|
||||
mkdir -p ./data
|
||||
rsync -a "$FILES_DIR"/ ./data/ 2>/dev/null || cp -a "$FILES_DIR"/. ./data/
|
||||
|
||||
# mattermost/mattermost-team-edition runs as fixed UID/GID 2000 — an SFTP'd
|
||||
# copy from elsewhere lands owned by whoever ran this script instead, and
|
||||
# every file write then fails with "permission denied" until this is
|
||||
# fixed. Confirmed live: this is what broke image/file uploads with a
|
||||
# client-side "stream closed" error after a migration.
|
||||
echo "Fixing ownership on imported files (mattermost image expects UID/GID 2000)..."
|
||||
chown -R 2000:2000 ./data
|
||||
|
||||
echo "Starting Mattermost..."
|
||||
docker compose up -d
|
||||
|
||||
|
||||
@@ -0,0 +1,449 @@
|
||||
#!/bin/bash
|
||||
# services/pihole.sh — Pi-hole network-wide DNS ad/tracker blocking.
|
||||
# Part of the modular post-install system (sourced by setup.sh).
|
||||
#
|
||||
# Can also be run standalone on any machine:
|
||||
# sudo bash pihole.sh
|
||||
# (Docker must already be installed when run standalone)
|
||||
#
|
||||
# Deliberately standalone — not wired into wg-easy or any other VPN/DNS
|
||||
# push here. Point a device's DNS settings at this box's IP manually to use
|
||||
# it. If you want it pushed automatically to VPN clients, that's a
|
||||
# wg-easy-side change (WG_CONFIG's DNS setting), not something this
|
||||
# installer does on its own.
|
||||
|
||||
# ── Standalone bootstrap ──────────────────────────────────────────────────────
|
||||
# Detected when the script is executed directly rather than sourced by setup.sh.
|
||||
# Sets up helpers and globals, then defers execution until after the function
|
||||
# definition at the bottom of this file.
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
[[ "$(id -u)" == "0" ]] || { echo "Run with sudo: sudo bash $0"; exit 1; }
|
||||
|
||||
_SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
_COMMON="$_SELF_DIR/../lib/common.sh"
|
||||
|
||||
if [[ -f "$_COMMON" ]]; then
|
||||
# Full repo present — use the real helpers (picks up ~/docker/.config too)
|
||||
# shellcheck source=../lib/common.sh
|
||||
source "$_COMMON"
|
||||
else
|
||||
# One-off copy — inline minimal stubs so the script works without the repo
|
||||
log_info() { echo -e "\033[0;34m[INFO]\033[0m $*"; }
|
||||
log_success() { echo -e "\033[0;32m[OK]\033[0m $*"; }
|
||||
log_warning() { echo -e "\033[1;33m[WARN]\033[0m $*"; }
|
||||
log_error() { echo -e "\033[0;31m[ERROR]\033[0m $*" >&2; }
|
||||
|
||||
require_docker() {
|
||||
command -v docker &>/dev/null || {
|
||||
log_error "Docker not found. Install it first:"
|
||||
log_error " curl -fsSL https://get.docker.com | sudo sh"
|
||||
return 1
|
||||
}
|
||||
docker compose version &>/dev/null || {
|
||||
log_error "Docker Compose plugin missing:"
|
||||
log_error " sudo apt-get install -y docker-compose-plugin"
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
ensure_docker_dir_ownership() {
|
||||
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true
|
||||
}
|
||||
|
||||
generate_password() {
|
||||
local _len="${1:-32}"
|
||||
tr -dc 'a-zA-Z0-9' < /dev/urandom | head -c "$_len"
|
||||
}
|
||||
|
||||
port_in_use() {
|
||||
local _port="$1" _proto="${2:-tcp}"
|
||||
local _flag="-tlnH"
|
||||
[ "$_proto" = "udp" ] && _flag="-ulnH"
|
||||
ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q .
|
||||
}
|
||||
|
||||
find_free_port() {
|
||||
local _varname="$1" _port="$2" _proto="${3:-tcp}"
|
||||
while port_in_use "$_port" "$_proto"; do
|
||||
_port=$((_port + 1))
|
||||
done
|
||||
eval "$_varname='$_port'"
|
||||
}
|
||||
|
||||
# Match common.sh's eval-based pattern so local vars in install_* are set correctly
|
||||
prompt_text() {
|
||||
local _q="$1" _def="$2" _var="$3" _r
|
||||
[[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; }
|
||||
read -r -p " $_q " _r
|
||||
eval "$_var='${_r:-$_def}'"
|
||||
}
|
||||
|
||||
prompt_yn() {
|
||||
local _q="$1" _def="$2" _var="$3" _r
|
||||
[[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; }
|
||||
read -r -p " $_q " _r
|
||||
eval "$_var='${_r:-$_def}'"
|
||||
}
|
||||
|
||||
prompt_reinstall_mode() {
|
||||
local _var="$1" _r
|
||||
[[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='cancel'"; return; }
|
||||
echo ""
|
||||
echo " 1) Update — refresh the image only, leave config/data as-is"
|
||||
echo " 2) Full reinstall — wipe and reconfigure from scratch"
|
||||
echo " 3) Cancel — leave the existing install untouched"
|
||||
read -r -p " Choice [3]: " _r
|
||||
case "$_r" in
|
||||
1) eval "$_var='update'" ;;
|
||||
2) eval "$_var='fresh'" ;;
|
||||
*) eval "$_var='cancel'" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
configure_caddy_for_service() {
|
||||
local _name="$1" _upstream="$2" _subdomain="$3" _extra="${4:-}"
|
||||
local _caddy_dir="$DOCKER_DIR/caddy"
|
||||
local _caddyfile="$_caddy_dir/Caddyfile"
|
||||
local _display_port="${_upstream##*:}"
|
||||
|
||||
# Determine mode: local Caddy, remote Caddy, or none
|
||||
local _mode="none"
|
||||
[[ -d "$_caddy_dir" ]] && _mode="local"
|
||||
[[ -n "${CADDY_REMOTE_HOST:-}" ]] && [[ "$_mode" != "local" ]] && _mode="remote"
|
||||
[[ "$_mode" == "none" ]] && {
|
||||
log_info "Access $_name directly on port $_display_port."
|
||||
return 0
|
||||
}
|
||||
|
||||
echo ""
|
||||
local _do_caddy=""
|
||||
if [[ "$_mode" == "remote" ]]; then
|
||||
log_info "Remote Caddy configured (${CADDY_REMOTE_HOST})."
|
||||
log_info "A snippet file will be saved to ~/docker/caddy-snippets/."
|
||||
fi
|
||||
read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy
|
||||
[[ "${_do_caddy,,}" == "y" ]] || {
|
||||
log_info "Skipping — access at: http://localhost:$_display_port"
|
||||
return 0
|
||||
}
|
||||
|
||||
# Domain prompt — pre-fill from SITE_DOMAIN when available
|
||||
local _default_domain=""
|
||||
if [[ -n "${SITE_DOMAIN:-}" ]] && [[ "$SITE_DOMAIN" != "example.com" ]]; then
|
||||
_default_domain="${_subdomain}.${SITE_DOMAIN}"
|
||||
log_info "Default: $_default_domain"
|
||||
fi
|
||||
local _domain=""
|
||||
read -r -p " Domain [${_default_domain:-required}]: " _domain
|
||||
_domain="${_domain:-$_default_domain}"
|
||||
[[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; }
|
||||
|
||||
# Build upstream — remote Caddy uses host IP:port, not container name
|
||||
local _block_upstream="$_upstream"
|
||||
if [[ "$_mode" == "remote" ]]; then
|
||||
_block_upstream="${CADDY_REMOTE_HOST}:${_display_port}"
|
||||
fi
|
||||
|
||||
local _site_block
|
||||
_site_block="$(cat << CBLOCK
|
||||
|
||||
# $_name
|
||||
${_domain} {
|
||||
reverse_proxy ${_block_upstream}
|
||||
|
||||
header {
|
||||
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
|
||||
X-Content-Type-Options "nosniff"
|
||||
X-Frame-Options "SAMEORIGIN"
|
||||
Referrer-Policy "strict-origin-when-cross-origin"
|
||||
}
|
||||
|
||||
log {
|
||||
output file /var/log/caddy/${_domain}.log
|
||||
format json
|
||||
}
|
||||
${_extra}
|
||||
}
|
||||
CBLOCK
|
||||
)"
|
||||
|
||||
if [[ "$_mode" == "local" ]]; then
|
||||
if [[ -f "$_caddyfile" ]]; then
|
||||
local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)"
|
||||
cp "$_caddyfile" "$_bk"
|
||||
log_info "Backed up Caddyfile to $(basename "$_bk")"
|
||||
else
|
||||
touch "$_caddyfile"
|
||||
fi
|
||||
|
||||
if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then
|
||||
log_warning "$_domain already in Caddyfile"
|
||||
local _ow=""
|
||||
read -r -p " Overwrite? [y/N]: " _ow
|
||||
[[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; }
|
||||
sed -i "/^${_domain}/,/^}/d" "$_caddyfile"
|
||||
fi
|
||||
|
||||
printf '%s\n' "$_site_block" >> "$_caddyfile"
|
||||
log_success "Added $_domain to Caddyfile"
|
||||
docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true
|
||||
if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then
|
||||
log_success "$_name accessible at: https://$_domain"
|
||||
else
|
||||
log_warning "Reload failed — check: docker logs caddy"
|
||||
log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile"
|
||||
fi
|
||||
else
|
||||
local _snippet_dir="$DOCKER_DIR/caddy-snippets"
|
||||
local _snippet_file="$_snippet_dir/${_subdomain}.caddy"
|
||||
mkdir -p "$_snippet_dir"
|
||||
printf '%s\n' "$_site_block" > "$_snippet_file"
|
||||
chown "$ACTUAL_USER:$ACTUAL_USER" "$_snippet_file" 2>/dev/null || true
|
||||
log_success "Snippet saved: $_snippet_file"
|
||||
log_info "Copy to Caddy machine:"
|
||||
log_info " scp $_snippet_file caddy-host:~/caddy-snippets/"
|
||||
log_info " rsync -av $_snippet_dir/ caddy-host:~/caddy-snippets/ (all at once)"
|
||||
fi
|
||||
}
|
||||
write_readme() {
|
||||
local _dir="$1"; shift
|
||||
mkdir -p "$_dir"
|
||||
cat > "$_dir/README.md"
|
||||
}
|
||||
fi
|
||||
|
||||
# Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR
|
||||
# ($HOME under sudo is /root, not the real user's home)
|
||||
ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}"
|
||||
ACTUAL_HOME="$(getent passwd "$ACTUAL_USER" 2>/dev/null | cut -d: -f6 || echo "${HOME:-/root}")"
|
||||
DOCKER_DIR="${DOCKER_DIR:-$ACTUAL_HOME/docker}"
|
||||
DRY_RUN="${DRY_RUN:-false}"
|
||||
UNATTENDED="${UNATTENDED:-false}"
|
||||
SITE_TZ="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}"
|
||||
SITE_DOMAIN="${SITE_DOMAIN:-example.com}"
|
||||
SITE_CADDY_NET="${SITE_CADDY_NET:-caddy_net}"
|
||||
|
||||
register_service() { :; } # no-op — no wizard to register into
|
||||
_RUN_STANDALONE=1
|
||||
fi
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
register_service pihole utilities "Network-wide DNS ad/tracker blocking (Pi-hole) — standalone, not wired into any VPN" 80
|
||||
|
||||
install_pihole() {
|
||||
require_docker || return 1
|
||||
|
||||
local DIR="$DOCKER_DIR/pihole"
|
||||
local WEB_PORT="8081"
|
||||
|
||||
if [ "$DRY_RUN" = true ]; then
|
||||
echo "[DRY-RUN] Would create $DIR with docker-compose.yml + .env"
|
||||
echo "[DRY-RUN] Would warn (not block) if port 53/tcp or 53/udp is already in use"
|
||||
echo "[DRY-RUN] Would auto-scan for a free host port for the web admin UI"
|
||||
echo "[DRY-RUN] Would generate a random admin password"
|
||||
echo "[DRY-RUN] Would offer a Caddy reverse proxy for the admin UI only (never DNS)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ -f "$DIR/docker-compose.yml" && -f "$DIR/.env" ]]; then
|
||||
local MODE=""
|
||||
prompt_reinstall_mode MODE
|
||||
case "$MODE" in
|
||||
update)
|
||||
log_info "Refreshing the Pi-hole image only — existing blocklists, config, and"
|
||||
log_info "port are left as-is."
|
||||
( cd "$DIR" && docker compose pull && docker compose up -d ) \
|
||||
&& log_success "Pi-hole image refreshed" \
|
||||
|| log_warning "Refresh failed — check: docker compose -f $DIR/docker-compose.yml logs"
|
||||
return 0
|
||||
;;
|
||||
cancel)
|
||||
log_info "Leaving the existing install as-is."
|
||||
return 0
|
||||
;;
|
||||
fresh) ;; # fall through to the full install flow below
|
||||
esac
|
||||
fi
|
||||
|
||||
# DNS itself is never scanned/moved — shifting Pi-hole off port 53 would
|
||||
# defeat the point, since every device on the network expects to find DNS
|
||||
# there by convention (unlike a web UI port, nothing lets a client be told
|
||||
# "try a different port instead"). Warn instead: the common case on Ubuntu
|
||||
# is systemd-resolved bound only to 127.0.0.53:53 (loopback), which does
|
||||
# NOT collide with Pi-hole's container publishing 53 on the host's real
|
||||
# interfaces — but if something else really is bound to 0.0.0.0:53, this
|
||||
# says so up front instead of failing silently at `docker compose up`.
|
||||
if port_in_use 53 tcp || port_in_use 53 udp; then
|
||||
log_warning "Something is already listening on port 53 (DNS) — check with:"
|
||||
log_warning " ss -tulnp | grep ':53 '"
|
||||
log_warning "If that's systemd-resolved bound to 127.0.0.53 only, this is fine —"
|
||||
log_warning "Pi-hole binds the host's real interfaces, not the loopback stub. If"
|
||||
log_warning "it's something else bound to 0.0.0.0:53, Pi-hole's container won't"
|
||||
log_warning "be able to start until that's freed or reconfigured."
|
||||
fi
|
||||
|
||||
find_free_port WEB_PORT "$WEB_PORT"
|
||||
|
||||
mkdir -p "$DIR"
|
||||
ensure_docker_dir_ownership "$DIR"
|
||||
cd "$DIR" || return 1
|
||||
|
||||
# Mirrors configure_caddy_for_service's own mode resolution (lib/common.sh):
|
||||
# explicit CADDY_MODE from the site config wins, then a local ~/docker/caddy,
|
||||
# then the legacy CADDY_REMOTE_HOST var. Only "local" joins caddy_net — a
|
||||
# remote Caddy box can't resolve container names on this host's bridge
|
||||
# network anyway; it reaches this service via the host's published port.
|
||||
# This only ever applies to the WEB admin UI — DNS itself is never behind
|
||||
# Caddy (Caddy only speaks HTTP).
|
||||
local _CADDY_MODE="${CADDY_MODE:-none}"
|
||||
[ "$_CADDY_MODE" = "none" ] && [ -d "$DOCKER_DIR/caddy" ] && _CADDY_MODE="local"
|
||||
[ "$_CADDY_MODE" = "none" ] && [ -n "${CADDY_REMOTE_HOST:-}" ] && _CADDY_MODE="remote"
|
||||
|
||||
local _CADDY_NET_BLOCK=""
|
||||
local _CADDY_NET_SECTION=""
|
||||
if [ "$_CADDY_MODE" = "local" ]; then
|
||||
_CADDY_NET_BLOCK=" networks:
|
||||
- caddy_net
|
||||
"
|
||||
_CADDY_NET_SECTION="
|
||||
networks:
|
||||
caddy_net:
|
||||
external: true
|
||||
name: ${SITE_CADDY_NET:-caddy_net}
|
||||
"
|
||||
fi
|
||||
|
||||
local WEBPASSWORD
|
||||
WEBPASSWORD="$(generate_password 24)"
|
||||
|
||||
# v6 image: config lives entirely under /etc/pihole (TOML-based
|
||||
# pihole.toml) — the old v5 split with a separate /etc/dnsmasq.d volume
|
||||
# and WEBPASSWORD env var are both gone. FTLCONF_webserver_api_password
|
||||
# replaces WEBPASSWORD; FTLCONF_dns_listeningMode=ALL is required under
|
||||
# Docker's default bridge networking or Pi-hole ignores queries from
|
||||
# anything but localhost. cap_add matches Pi-hole's own official compose
|
||||
# example — NET_ADMIN specifically is only needed if this instance is
|
||||
# ever used as a DHCP server too, which it isn't here, but the other two
|
||||
# (SYS_TIME, SYS_NICE) are part of that same documented baseline.
|
||||
cat > docker-compose.yml << PIHOLE_COMPOSE
|
||||
name: pihole
|
||||
|
||||
services:
|
||||
pihole:
|
||||
image: pihole/pihole:latest
|
||||
container_name: pihole
|
||||
hostname: pihole
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
TZ: \${TZ}
|
||||
FTLCONF_webserver_api_password: \${WEBPASSWORD}
|
||||
FTLCONF_dns_listeningMode: ALL
|
||||
volumes:
|
||||
- ./etc-pihole:/etc/pihole
|
||||
ports:
|
||||
- "53:53/tcp"
|
||||
- "53:53/udp"
|
||||
- "${WEB_PORT}:80/tcp"
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
- SYS_TIME
|
||||
- SYS_NICE
|
||||
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
|
||||
PIHOLE_COMPOSE
|
||||
|
||||
cat > .env << PIHOLE_ENV
|
||||
TZ=${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}
|
||||
# Admin web UI password (System Console / login screen).
|
||||
WEBPASSWORD='${WEBPASSWORD}'
|
||||
CADDY_NET=$SITE_CADDY_NET
|
||||
PIHOLE_ENV
|
||||
chmod 600 .env
|
||||
|
||||
mkdir -p etc-pihole
|
||||
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$DIR"
|
||||
|
||||
echo ""
|
||||
log_success "Pi-hole configured at $DIR (DNS on 53, admin UI on port $WEB_PORT)"
|
||||
|
||||
configure_caddy_for_service "Pi-hole" "pihole:80" "pihole"
|
||||
local _caddy_configured=false _caddy_mode=""
|
||||
if declare -p CADDY_SERVICE_CONFIGURED >/dev/null 2>&1; then
|
||||
_caddy_configured="$CADDY_SERVICE_CONFIGURED"
|
||||
_caddy_mode="$CADDY_SERVICE_MODE"
|
||||
fi
|
||||
|
||||
# ── Firewall ─────────────────────────────────────────────────────────────
|
||||
# DNS (53) is opened unconditionally — this is meant to be queried
|
||||
# directly by devices on the network, never fronted by Caddy. The web UI
|
||||
# port only needs opening if Caddy ISN'T fronting it locally (matches the
|
||||
# pattern documented in CLAUDE.md for every other service in this repo).
|
||||
if command -v ufw &>/dev/null; then
|
||||
ufw allow 53/tcp comment "Pi-hole DNS"
|
||||
ufw allow 53/udp comment "Pi-hole DNS"
|
||||
if [ "$_caddy_configured" != "true" ] || [ "$_caddy_mode" = "remote" ]; then
|
||||
ufw allow "${WEB_PORT}/tcp" comment "Pi-hole admin UI"
|
||||
fi
|
||||
fi
|
||||
|
||||
write_readme "$DIR" << MD
|
||||
# Pi-hole
|
||||
|
||||
Network-wide DNS-based ad/tracker blocking. Standalone install — **not**
|
||||
wired into wg-easy, Netbird, or any other VPN here. To actually use it,
|
||||
point a device's DNS settings at this box's IP on port 53, either by hand
|
||||
per-device or via your router's DHCP DNS setting.
|
||||
|
||||
## Access
|
||||
- Admin UI: http://localhost:${WEB_PORT} (or via Caddy if configured above)
|
||||
- Admin password: see \`.env\` → \`WEBPASSWORD\`
|
||||
- DNS: this box's IP, port 53 (standard DNS port — not configurable per-instance)
|
||||
|
||||
## Using it
|
||||
Nothing points at this automatically. Options, in order of how much you
|
||||
want blocked by default:
|
||||
- **Per-device**: change that device's DNS server setting to this box's IP.
|
||||
- **Whole LAN**: change your router's DHCP-assigned DNS server to this box's IP
|
||||
(every device on that network picks it up automatically going forward).
|
||||
- **Over the VPN too**: this needs a manual edit on the wg-easy side — set
|
||||
wg-easy's DNS setting to this box's IP so it's pushed to VPN peers. Not
|
||||
done automatically by this installer, on purpose (you said standalone).
|
||||
|
||||
## Port 53 already in use?
|
||||
The installer warns but doesn't block if something's already listening on
|
||||
53 at install time. The common, harmless case on Ubuntu is systemd-resolved
|
||||
bound only to \`127.0.0.53:53\` (loopback stub) — Pi-hole's container
|
||||
publishes on the host's real interfaces, not that loopback address, so the
|
||||
two normally coexist fine. If something else genuinely holds \`0.0.0.0:53\`,
|
||||
free it first or Pi-hole's container won't start:
|
||||
\`\`\`
|
||||
ss -tulnp | grep ':53 '
|
||||
\`\`\`
|
||||
|
||||
## Manage
|
||||
\`\`\`bash
|
||||
cd $DIR
|
||||
docker compose up -d # start
|
||||
docker compose down # stop
|
||||
docker compose logs -f # logs
|
||||
docker exec -it pihole pihole -g # force a blocklist update (gravity)
|
||||
\`\`\`
|
||||
MD
|
||||
|
||||
local START=""
|
||||
prompt_yn "Start Pi-hole now? (y/n):" "y" START
|
||||
if [ "$START" = "y" ] || [ "$START" = "Y" ]; then
|
||||
docker compose up -d \
|
||||
&& log_success "Pi-hole started" \
|
||||
|| log_warning "Start failed — check: docker compose logs"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo " Admin UI: http://localhost:${WEB_PORT}"
|
||||
echo " Password: see $DIR/.env"
|
||||
echo " Nothing points at this DNS server yet — see the README for how to"
|
||||
echo " actually route devices to it."
|
||||
echo ""
|
||||
}
|
||||
|
||||
# Run immediately when executed directly (deferred until after function definition)
|
||||
[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_pihole
|
||||
+15
-5
@@ -9,7 +9,17 @@
|
||||
# Ported from ubuntu-post-install-24.04-crowdsec.sh (# ---- WG-EASY ----).
|
||||
# Own ~/docker/wg-easy/ with a standalone docker-compose.yml + .env.
|
||||
# Requires cap_add: NET_ADMIN + SYS_MODULE and ip_forward sysctl.
|
||||
# Forward UDP 51820 on your router to this server for external VPN access.
|
||||
# Forward UDP 51830 on your router to this server for external VPN access
|
||||
# (default — scanned/moved at install time if already taken; see below).
|
||||
#
|
||||
# Default port deliberately isn't WireGuard's conventional 51820: Netbird's
|
||||
# own WireGuard listener also defaults to exactly 51820, and this is the
|
||||
# one service in this repo where two completely independent tools (this
|
||||
# repo's own wg-easy and a separately-installed Netbird) are both likely to
|
||||
# reach for the same hardcoded upstream default with no scanning of their
|
||||
# own on Netbird's side. Starting one port family away avoids that
|
||||
# collision in the common case; the scan below still moves both further if
|
||||
# even the new default is somehow already taken.
|
||||
|
||||
# ── Standalone bootstrap ──────────────────────────────────────────────────────
|
||||
# Detected when the script is executed directly rather than sourced by setup.sh.
|
||||
@@ -207,13 +217,13 @@ CBLOCK
|
||||
fi
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
register_service wg-easy utilities "WireGuard VPN with web management UI (wg-easy); peers mesh through this hub automatically, with a script to sync SSH aliases" 51821
|
||||
register_service wg-easy utilities "WireGuard VPN with web management UI (wg-easy); peers mesh through this hub automatically, with a script to sync SSH aliases" 51831
|
||||
|
||||
install_wg-easy() {
|
||||
require_docker || return 1
|
||||
|
||||
local WGEASY_DIR="$DOCKER_DIR/wg-easy"
|
||||
local WEB_PORT="51821" VPN_PORT="51820"
|
||||
local WEB_PORT="51831" VPN_PORT="51830"
|
||||
|
||||
if [ "$DRY_RUN" = true ]; then
|
||||
echo "[DRY-RUN] wg-easy would:"
|
||||
@@ -221,7 +231,7 @@ install_wg-easy() {
|
||||
echo " - Auto-detect public IP for WG_HOST"
|
||||
echo " - Generate a random web UI password"
|
||||
echo " - Pin WG_DEFAULT_ADDRESS=10.8.0.x (subnet 10.8.0.0/24)"
|
||||
echo " - Expose port 51821 (web UI) + 51820/udp (VPN), both auto-scanned if occupied"
|
||||
echo " - Expose port 51831 (web UI) + 51830/udp (VPN), both auto-scanned if occupied"
|
||||
echo " - Require router port-forward: UDP <VPN port> → this server"
|
||||
echo " - Offer a Caddy reverse proxy and to start the container"
|
||||
echo " - Offer to also allow SSH from the VPN subnet (additive, doesn't remove public SSH)"
|
||||
@@ -232,7 +242,7 @@ install_wg-easy() {
|
||||
# Scan for free host ports, moving both together — a plain install
|
||||
# shouldn't silently claim a port another already-running service holds.
|
||||
# Whatever VPN_PORT ends up as is what needs forwarding on the router
|
||||
# (the messaging below reflects the final value, not the 51820 default).
|
||||
# (the messaging below reflects the final value, not the 51830 default).
|
||||
# See CLAUDE.md's "Port collision avoidance" section.
|
||||
while port_in_use "$WEB_PORT" || port_in_use "$VPN_PORT" udp; do
|
||||
WEB_PORT=$((WEB_PORT + 1))
|
||||
|
||||
Reference in New Issue
Block a user