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 |
|
| `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) |
|
| `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` |
|
| `media` | `arm`, `audiobookshelf`, `calibre-web`, `emby`, `immich`, `jellyfin`, `lyrion` |
|
||||||
| `cameras` | `frigate`, `frigate-audio`, `frigate-notify`, `sky-cam` |
|
| `cameras` | `frigate`, `frigate-audio`, `frigate-notify`, `sky-cam` |
|
||||||
| `gaming` | `drum-rhythm-game`, `js99er`, `kyber-launcher`, `kyber-server`, `minecraft`, `wolf`, `wolf-pair` |
|
| `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 ""
|
echo ""
|
||||||
|
|
||||||
local DEFAULT_DEST="$ACTUAL_HOME/backups/kopia-backup"
|
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=""
|
local _repo=""
|
||||||
prompt_text " Default repository path [${DEFAULT_DEST}]:" "$DEFAULT_DEST" _repo
|
prompt_text " Default repository path [${DEFAULT_DEST}]:" "$DEFAULT_DEST" _repo
|
||||||
_repo="${_repo/#\~/$ACTUAL_HOME}"; _repo="${_repo%/}"
|
_repo="${_repo/#\~/$ACTUAL_HOME}"; _repo="${_repo%/}"
|
||||||
@@ -430,6 +435,28 @@ install_backup() {
|
|||||||
DEST_REPOS["default"]="$_repo"
|
DEST_REPOS["default"]="$_repo"
|
||||||
DEST_CONFIGS["default"]="/etc/kopia-backup/default.config"
|
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=""
|
local _extra=""
|
||||||
prompt_yn " Add more destinations (for services on different drives)? (y/N):" "n" _extra
|
prompt_yn " Add more destinations (for services on different drives)? (y/N):" "n" _extra
|
||||||
if [[ "$_extra" =~ ^[Yy]$ ]]; then
|
if [[ "$_extra" =~ ^[Yy]$ ]]; then
|
||||||
@@ -440,12 +467,16 @@ install_backup() {
|
|||||||
[ -z "$_dn" ] && break
|
[ -z "$_dn" ] && break
|
||||||
_dn="${_dn//[^a-zA-Z0-9_]/_}"
|
_dn="${_dn//[^a-zA-Z0-9_]/_}"
|
||||||
[ "$_dn" = "default" ] && { log_warning " 'default' is reserved — use another name."; continue; }
|
[ "$_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
|
[ -z "$_dr" ] && continue
|
||||||
_dr="${_dr/#\~/$ACTUAL_HOME}"; _dr="${_dr%/}"
|
_dr="${_dr/#\~/$ACTUAL_HOME}"; _dr="${_dr%/}"
|
||||||
DEST_REPOS["$_dn"]="$_dr"
|
DEST_REPOS["$_dn"]="$_dr"
|
||||||
DEST_CONFIGS["$_dn"]="/etc/kopia-backup/${_dn}.config"
|
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"
|
log_success " Destination '$_dn' → $_dr"
|
||||||
done
|
done
|
||||||
fi
|
fi
|
||||||
@@ -464,11 +495,17 @@ install_backup() {
|
|||||||
printf " %-16s %s\n" "$dn" "${DEST_REPOS[$dn]}"
|
printf " %-16s %s\n" "$dn" "${DEST_REPOS[$dn]}"
|
||||||
done
|
done
|
||||||
echo ""
|
echo ""
|
||||||
echo " Press Enter to accept the default for each service."
|
echo " Press Enter to accept the shown default for each service."
|
||||||
echo ""
|
echo ""
|
||||||
local _d
|
local _d _svc_var _existing_svc_dest
|
||||||
for svc in "${ALL_SVCS[@]}"; do
|
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
|
if [ -n "$_d" ] && [ "$_d" != "default" ] && [ -n "${DEST_REPOS[$_d]:-}" ]; then
|
||||||
SVC_DEST_MAP["$svc"]="$_d"
|
SVC_DEST_MAP["$svc"]="$_d"
|
||||||
fi
|
fi
|
||||||
@@ -518,13 +555,27 @@ install_backup() {
|
|||||||
echo " 3) Weekly (Sunday 02:00)"
|
echo " 3) Weekly (Sunday 02:00)"
|
||||||
echo " 4) Custom (systemd OnCalendar)"
|
echo " 4) Custom (systemd OnCalendar)"
|
||||||
echo ""
|
echo ""
|
||||||
local _sch=""
|
# Preselect whatever's already scheduled, read back from the live timer
|
||||||
prompt_text " How often? [1]:" "1" _sch
|
# 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
|
local ONCALENDAR SCHED_LABEL
|
||||||
case "${_sch:-1}" in
|
case "${_sch:-1}" in
|
||||||
2) ONCALENDAR="*-*-* 02,14:00:00"; SCHED_LABEL="every 12 hours" ;;
|
2) ONCALENDAR="*-*-* 02,14:00:00"; SCHED_LABEL="every 12 hours" ;;
|
||||||
3) ONCALENDAR="Sun *-*-* 02:00:00"; SCHED_LABEL="weekly Sunday 02:00" ;;
|
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" ;;
|
*) ONCALENDAR="*-*-* 02:00:00"; SCHED_LABEL="daily at 02:00" ;;
|
||||||
esac
|
esac
|
||||||
local KEEP_LATEST=""
|
local KEEP_LATEST=""
|
||||||
@@ -542,9 +593,13 @@ install_backup() {
|
|||||||
echo " Example URL: https://ntfy.sh/my-backup-alerts"
|
echo " Example URL: https://ntfy.sh/my-backup-alerts"
|
||||||
echo ""
|
echo ""
|
||||||
local NTFY_URL="" NTFY_TOKEN=""
|
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
|
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
|
fi
|
||||||
|
|
||||||
# ── Disaster-recovery spare box (optional) ────────────────────────────────
|
# ── Disaster-recovery spare box (optional) ────────────────────────────────
|
||||||
@@ -774,14 +829,29 @@ install_backup() {
|
|||||||
# live: a paste into the hidden Application Key field can silently
|
# live: a paste into the hidden Application Key field can silently
|
||||||
# capture nothing depending on the terminal/SSH client, with no
|
# capture nothing depending on the terminal/SSH client, with no
|
||||||
# other symptom until this point.
|
# other symptom until this point.
|
||||||
local B2_BUCKET="" B2_ENDPOINT="" B2_KEY_ID="" B2_APP_KEY=""
|
# Pre-fill from whatever's already configured (only meaningful if the
|
||||||
prompt_text " Bucket name:" "" B2_BUCKET
|
# 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)"
|
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)"
|
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)"
|
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)"
|
echo " (${#B2_APP_KEY} characters entered)"
|
||||||
|
|
||||||
local _B2_MISSING=""
|
local _B2_MISSING=""
|
||||||
|
|||||||
+48
-2
@@ -518,6 +518,28 @@ ${_CADDY_NET_BLOCK} healthcheck:
|
|||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
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:
|
mattermost:
|
||||||
image: mattermost/mattermost-team-edition:latest
|
image: mattermost/mattermost-team-edition:latest
|
||||||
container_name: ${MM_CONTAINER}
|
container_name: ${MM_CONTAINER}
|
||||||
@@ -527,6 +549,8 @@ ${_CADDY_NET_BLOCK} healthcheck:
|
|||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
mattermost-fix-perms:
|
||||||
|
condition: service_completed_successfully
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/mattermost/data
|
- ./data:/mattermost/data
|
||||||
- ./logs:/mattermost/logs
|
- ./logs:/mattermost/logs
|
||||||
@@ -732,6 +756,15 @@ MD
|
|||||||
# default). If you have a custom-format pg_dump instead, use `pg_restore`
|
# default). If you have a custom-format pg_dump instead, use `pg_restore`
|
||||||
# in place of the `psql < dump` step below.
|
# 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
|
# IMPORTANT: point the files argument at the SUBDIRECTORY that holds
|
||||||
# Mattermost's own file storage inside whatever you downloaded via SFTP
|
# Mattermost's own file storage inside whatever you downloaded via SFTP
|
||||||
# (commonly named `data`), not the whole SFTP root — PikaPods' exact
|
# (commonly named `data`), not the whole SFTP root — PikaPods' exact
|
||||||
@@ -739,7 +772,7 @@ MD
|
|||||||
# before running.
|
# before running.
|
||||||
#
|
#
|
||||||
# Usage:
|
# 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
|
MIGRATE_HEAD
|
||||||
@@ -754,13 +787,18 @@ MIGRATE_VARS
|
|||||||
|
|
||||||
cat >> "$DIR/migrate-from-pikapods.sh" << 'MIGRATE_BODY'
|
cat >> "$DIR/migrate-from-pikapods.sh" << 'MIGRATE_BODY'
|
||||||
set -uo pipefail
|
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
|
cd "$PROJECT_DIR" || exit 1
|
||||||
|
|
||||||
SQL_DUMP="${1:-}"
|
SQL_DUMP="${1:-}"
|
||||||
FILES_DIR="${2:-}"
|
FILES_DIR="${2:-}"
|
||||||
|
|
||||||
if [ -z "$SQL_DUMP" ] || [ -z "$FILES_DIR" ]; then
|
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
|
exit 1
|
||||||
fi
|
fi
|
||||||
[ -f "$SQL_DUMP" ] || { echo "SQL dump not found: $SQL_DUMP"; exit 1; }
|
[ -f "$SQL_DUMP" ] || { echo "SQL dump not found: $SQL_DUMP"; exit 1; }
|
||||||
@@ -807,6 +845,14 @@ echo "Copying files into ./data..."
|
|||||||
mkdir -p ./data
|
mkdir -p ./data
|
||||||
rsync -a "$FILES_DIR"/ ./data/ 2>/dev/null || cp -a "$FILES_DIR"/. ./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..."
|
echo "Starting Mattermost..."
|
||||||
docker compose up -d
|
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 ----).
|
# Ported from ubuntu-post-install-24.04-crowdsec.sh (# ---- WG-EASY ----).
|
||||||
# Own ~/docker/wg-easy/ with a standalone docker-compose.yml + .env.
|
# Own ~/docker/wg-easy/ with a standalone docker-compose.yml + .env.
|
||||||
# Requires cap_add: NET_ADMIN + SYS_MODULE and ip_forward sysctl.
|
# 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 ──────────────────────────────────────────────────────
|
# ── Standalone bootstrap ──────────────────────────────────────────────────────
|
||||||
# Detected when the script is executed directly rather than sourced by setup.sh.
|
# Detected when the script is executed directly rather than sourced by setup.sh.
|
||||||
@@ -207,13 +217,13 @@ CBLOCK
|
|||||||
fi
|
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() {
|
install_wg-easy() {
|
||||||
require_docker || return 1
|
require_docker || return 1
|
||||||
|
|
||||||
local WGEASY_DIR="$DOCKER_DIR/wg-easy"
|
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
|
if [ "$DRY_RUN" = true ]; then
|
||||||
echo "[DRY-RUN] wg-easy would:"
|
echo "[DRY-RUN] wg-easy would:"
|
||||||
@@ -221,7 +231,7 @@ install_wg-easy() {
|
|||||||
echo " - Auto-detect public IP for WG_HOST"
|
echo " - Auto-detect public IP for WG_HOST"
|
||||||
echo " - Generate a random web UI password"
|
echo " - Generate a random web UI password"
|
||||||
echo " - Pin WG_DEFAULT_ADDRESS=10.8.0.x (subnet 10.8.0.0/24)"
|
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 " - Require router port-forward: UDP <VPN port> → this server"
|
||||||
echo " - Offer a Caddy reverse proxy and to start the container"
|
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)"
|
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
|
# Scan for free host ports, moving both together — a plain install
|
||||||
# shouldn't silently claim a port another already-running service holds.
|
# shouldn't silently claim a port another already-running service holds.
|
||||||
# Whatever VPN_PORT ends up as is what needs forwarding on the router
|
# 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.
|
# See CLAUDE.md's "Port collision avoidance" section.
|
||||||
while port_in_use "$WEB_PORT" || port_in_use "$VPN_PORT" udp; do
|
while port_in_use "$WEB_PORT" || port_in_use "$VPN_PORT" udp; do
|
||||||
WEB_PORT=$((WEB_PORT + 1))
|
WEB_PORT=$((WEB_PORT + 1))
|
||||||
|
|||||||
Reference in New Issue
Block a user