Skip to content

Instantly share code, notes, and snippets.

@ltlapy
Created May 16, 2026 04:14
Show Gist options
  • Select an option

  • Save ltlapy/aa0dac36db06c997ca9598a111fcf546 to your computer and use it in GitHub Desktop.

Select an option

Save ltlapy/aa0dac36db06c997ca9598a111fcf546 to your computer and use it in GitHub Desktop.
Helper script for managing nginx user configuration of Synology DSM Web station
#!/bin/bash
# dsm-nginx - Synology DSM nginx custom config manager
#
# DSM periodically regenerates nginx config files, but includes 'include path*'
# directives pointing to files it never creates. This script manages those
# user-owned files, which survive DSM resets.
#
# Custom config hooks per Web Station portal:
# conf.d/<service-id>/user.conf — server block context
# conf.d/<service-id>/proxy.conf — proxy_pass location context
# conf.d/<service-id>/fastcgi.conf — fastcgi location context
set -uo pipefail
readonly NGINX_DIR="/usr/local/etc/nginx"
readonly SITES_DIR="$NGINX_DIR/sites-enabled"
readonly CONF_DIR="$NGINX_DIR/conf.d"
EDITOR="${EDITOR:-vi}"
SHOW_ALL=false
SHOW_REVERSE_PROXY=false
SHOW_VERBOSE=false
AUTO_YES=false
if [[ -t 1 ]]; then
RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m'
BLUE='\033[0;34m' BOLD='\033[1m' DIM='\033[2m' NC='\033[0m'
else
RED='' GREEN='' YELLOW='' BLUE='' BOLD='' DIM='' NC=''
fi
# Hook context descriptions shown in 'show' and as edit-time templates
declare -A HOOK_CONTEXT=(
[user.conf]="server block — add_header, limit_req, access rules, etc."
[proxy.conf]="proxy location — proxy_set_header, proxy_cache, timeouts, etc."
[fastcgi.conf]="fastcgi location — fastcgi_param, fastcgi_read_timeout, etc."
)
declare -A HOOK_TEMPLATE=(
[user.conf]="# server block context
# Directives here apply to the entire virtual host.
# Examples: add_header, limit_req_zone, satisfy, allow/deny
"
[proxy.conf]="# proxy_pass location context
# Directives here are injected inside the proxy location block.
# Examples: proxy_set_header, proxy_cache, proxy_read_timeout
"
[fastcgi.conf]="# fastcgi location context
# Directives here are injected inside the fastcgi location block.
# Examples: fastcgi_param, fastcgi_read_timeout, fastcgi_buffers
"
)
# ---------- Helpers ----------
usage() {
cat <<EOF
dsm-nginx - Synology DSM nginx custom config manager
Usage: $(basename "$0") [options] <command> [domain]
Commands:
list List domains with custom config status
show <domain> Print custom config content and available hooks
edit <domain> Open custom config in \$EDITOR (default: vi)
path <domain> Print custom config file path(s)
delete <domain> Delete custom config file(s)
clean Report (and optionally remove) orphaned config files
Options:
-a, --all Also show system/Synology-internal service domains
-r, --reverse-proxy Also show Reverse Proxy domains in list
-v, --verbose Show source config file path for each domain (list only)
-y, --yes Auto-confirm deletions (clean only)
-h, --help Show this help
EOF
}
die() { echo -e "${RED}error: $*${NC}" >&2; exit 1; }
msg() { echo -e "$*"; }
confirm() {
local resp
read -r -p "$1 [y/N] " resp
[[ "$resp" =~ ^[Yy] ]]
}
nginx_check_and_reload() {
msg ""
msg "Validating nginx config..."
local test_out
if test_out=$(nginx -t 2>&1); then
msg "${GREEN}nginx -t: OK${NC}"
elif test_out=$(sudo nginx -t 2>&1); then
msg "${GREEN}nginx -t: OK (sudo)${NC}"
else
msg "${RED}nginx -t failed:${NC}"
echo "$test_out" | sed 's/^/ /'
msg "${YELLOW}Config not reloaded. Fix the error above first.${NC}"
return
fi
confirm "Reload nginx?" || return
if nginx -s reload 2>/dev/null; then
msg "${GREEN}nginx reloaded.${NC}"
elif sudo nginx -s reload 2>/dev/null; then
msg "${GREEN}nginx reloaded (sudo).${NC}"
else
msg "${YELLOW}Could not reload. Run: ${BOLD}sudo nginx -s reload${NC}"
fi
}
# ---------- Config parsing ----------
is_system_conf() {
local f; f=$(basename "$1")
[[ "$f" == server.syno-app-portal.* ]] && return 0
[[ "$f" == server.pkg-static.* ]] && return 0
[[ "$f" == server.webstation.conf ]] && return 0
[[ "$f" == synowstransfer-nginx.conf ]] && return 0
return 1
}
conf_type() {
local f; f=$(basename "$1")
case "$f" in
webservice_portal_*) echo "WebStation" ;;
server.ReverseProxy*) echo "ReverseProxy" ;;
server.syno-app-portal.*) echo "SynoApp" ;;
*) echo "System" ;;
esac
}
server_names() {
awk '/^[[:space:]]*server_name[[:space:]]/ {
for (i = 2; i <= NF; i++) {
v = $i; gsub(/[;[:space:]]/, "", v)
if (v != "" && v != "_") print v
}
}' "$1" | sort -u
}
# Prints full paths to user-editable include files for a webservice_portal conf.
# Paths are returned whether or not the files currently exist.
portal_custom_includes() {
local portal_file="$1"
local uuid
uuid=$(basename "$portal_file" | sed 's/webservice_portal_//')
local svc_rel
svc_rel=$(grep -oE "conf\.d/\.service\.$uuid\.[^*;[:space:]]+\.conf" \
"$portal_file" 2>/dev/null | head -1) || true
[[ -z "$svc_rel" ]] && return 0
local svc_file="$NGINX_DIR/$svc_rel"
[[ -f "$svc_file" ]] || return 0
grep -oE "$NGINX_DIR/conf\.d/[^/]+/(user|proxy|fastcgi)\.conf" \
"$svc_file" 2>/dev/null | sort -u || true
}
all_custom_include_paths() {
for pf in "$SITES_DIR"/webservice_portal_*; do
[[ -f "$pf" ]] || continue
portal_custom_includes "$pf"
done | sort -u
}
# Given a service directory name (e.g. "Docker-abc123-3000"), try to find
# the domain that used to reference it via a .service.<portal>.<svc-id>.conf file.
find_domain_for_service_dir() {
local service_id="$1"
# .service.<portal-uuid>.<service-id>.conf — service-id has no dots
local match=""
for f in "$CONF_DIR"/.service.*.conf; do
[[ -f "$f" ]] || continue
local bname; bname=$(basename "$f")
# Match: bname ends with .<service_id>.conf
[[ "$bname" == *."$service_id".conf ]] && { match="$f"; break; }
done
[[ -z "$match" ]] && return 0
local bname; bname=$(basename "$match")
local without_prefix="${bname#.service.}"
local portal_uuid="${without_prefix%%.*}" # portal UUID has no dots
local portal_file="$SITES_DIR/webservice_portal_$portal_uuid"
if [[ -f "$portal_file" ]]; then
local domain; domain=$(server_names "$portal_file" | head -1)
echo "${domain} (portal active, service replaced)"
else
echo "(portal $portal_uuid — portal no longer configured)"
fi
}
# Domains (other than the given conf file) that share the same hook paths
shared_domains_for_conf() {
local target_cf="$1"
local target_includes
target_includes=$(portal_custom_includes "$target_cf" | sort) || true
[[ -z "$target_includes" ]] && return 0
for pf in "$SITES_DIR"/webservice_portal_*; do
[[ -f "$pf" && "$pf" != "$target_cf" ]] || continue
local other_includes
other_includes=$(portal_custom_includes "$pf" | sort) || true
[[ "$target_includes" == "$other_includes" ]] && server_names "$pf"
done
}
# ---------- Domain lookup ----------
find_conf_for_domain() {
local target="$1"
for cf in "$SITES_DIR"/webservice_portal_* \
"$SITES_DIR"/server.ReverseProxy.conf \
"$SITES_DIR"/server.syno-app-portal.*.conf; do
[[ -f "$cf" ]] || continue
if server_names "$cf" | grep -qxF "$target"; then
echo "$cf"; return 0
fi
done
return 1
}
# ---------- Commands ----------
cmd_list() {
local any=false
printf "${BOLD}%-38s %-13s %s${NC}\n" "DOMAIN" "TYPE" "HOOKS"
printf '%0.s─' {1..72}; echo
local cf_list=(
"$SITES_DIR"/webservice_portal_*
"$SITES_DIR"/server.ReverseProxy.conf
"$SITES_DIR"/server.syno-app-portal.*.conf
"$SITES_DIR"/server.pkg-static.*.conf
"$SITES_DIR"/server.webstation.conf
"$SITES_DIR"/synowstransfer-nginx.conf
)
for cf in "${cf_list[@]}"; do
[[ -f "$cf" ]] || continue
local type; type=$(conf_type "$cf")
is_system_conf "$cf" && ! $SHOW_ALL && continue
[[ "$type" == "ReverseProxy" ]] && ! $SHOW_REVERSE_PROXY && continue
local includes=""
[[ "$type" == "WebStation" ]] && includes=$(portal_custom_includes "$cf")
while IFS= read -r domain; do
[[ -z "$domain" ]] && continue
any=true
local hook_str=""
if [[ -n "$includes" ]]; then
while IFS= read -r inc; do
[[ -z "$inc" ]] && continue
local name; name=$(basename "$inc")
local stem="${name%.conf}"
if [[ -f "$inc" ]]; then
hook_str+="${GREEN}${stem}●${NC} "
else
hook_str+="${DIM}${stem}○${NC} "
fi
done <<< "$includes"
elif [[ "$type" == "WebStation" ]]; then
hook_str="${DIM}(no hooks available)${NC}"
elif [[ "$type" == "ReverseProxy" ]]; then
hook_str="${DIM}—${NC}"
else
hook_str="${BLUE}[system]${NC}"
fi
printf "%-38s %-13s %b\n" "$domain" "$type" "$hook_str"
if $SHOW_VERBOSE; then
printf " ${DIM}→ %s${NC}\n" "${cf#$NGINX_DIR/}"
fi
done < <(server_names "$cf")
done
$any || msg "No domains found."
echo
msg "${DIM}● exists ○ available (not yet created)${NC}"
if ! $SHOW_REVERSE_PROXY; then
msg "${DIM}Reverse Proxy domains hidden — use -r to show them.${NC}"
fi
}
cmd_show() {
local domain="${1:-}"
[[ -z "$domain" ]] && die "Usage: $(basename "$0") show <domain>"
local cf
cf=$(find_conf_for_domain "$domain") || die "Domain not found: $domain"
local type; type=$(conf_type "$cf")
[[ "$type" != "WebStation" ]] && \
die "Domain '$domain' ($type) has no user-customizable include files"
local paths
paths=$(portal_custom_includes "$cf")
[[ -z "$paths" ]] && die "No customizable hooks found for $domain"
local shared
shared=$(shared_domains_for_conf "$cf")
[[ -n "$shared" ]] && \
msg "${YELLOW}Shared with: $(echo "$shared" | tr '\n' ' ')${NC}\n"
while IFS= read -r path; do
[[ -z "$path" ]] && continue
local name; name=$(basename "$path")
local ctx="${HOOK_CONTEXT[$name]:-}"
local header="${BOLD}=== $name ===${NC}"
[[ -n "$ctx" ]] && header+="${DIM} ($ctx)${NC}"
msg "$header"
if [[ -f "$path" ]]; then
cat "$path"
else
msg "${DIM}(not created yet)${NC}"
fi
echo
done <<< "$paths"
}
# Result variable for select_path (avoids subshell/stdin issues)
_SELECTED_PATH=""
# Sets _SELECTED_PATH to the chosen path. Returns 1 if paths is empty.
select_path() {
local paths="$1"
local action="$2"
_SELECTED_PATH=""
local arr=()
while IFS= read -r p; do [[ -n "$p" ]] && arr+=("$p"); done <<< "$paths"
[[ ${#arr[@]} -eq 0 ]] && return 1
if [[ ${#arr[@]} -eq 1 ]]; then
_SELECTED_PATH="${arr[0]}"
return 0
fi
msg "Multiple hook files available:"
local i
for i in "${!arr[@]}"; do
local p="${arr[$i]}"
local name; name=$(basename "$p")
local ctx="${HOOK_CONTEXT[$name]:-}"
local status
[[ -f "$p" ]] && status="${GREEN}[exists]${NC}" || status="${DIM}[not created]${NC}"
printf " %d) %-15s %b %s\n" $((i+1)) "$name" "$status" "${DIM}${ctx}${NC}"
done
local choice
read -r -p "Select file to $action [1-${#arr[@]}]: " choice
[[ "$choice" =~ ^[0-9]+$ && "$choice" -ge 1 && "$choice" -le ${#arr[@]} ]] \
|| die "Invalid selection"
_SELECTED_PATH="${arr[$((choice-1))]}"
}
cmd_edit() {
local domain="${1:-}"
[[ -z "$domain" ]] && die "Usage: $(basename "$0") edit <domain>"
local cf
cf=$(find_conf_for_domain "$domain") || die "Domain not found: $domain"
local type; type=$(conf_type "$cf")
[[ "$type" != "WebStation" ]] && \
die "Domain '$domain' ($type) has no user-customizable include files"
local paths
paths=$(portal_custom_includes "$cf")
[[ -z "$paths" ]] && die "No customizable hooks found for $domain"
local shared
shared=$(shared_domains_for_conf "$cf")
if [[ -n "$shared" ]]; then
msg "${YELLOW}Warning: this config is shared with: $(echo "$shared" | tr '\n' ' ')${NC}"
confirm "Continue? (edits affect all shared domains)" \
|| { msg "Aborted."; return 0; }
fi
select_path "$paths" "edit" || die "No path selected"
local target="$_SELECTED_PATH"
local dir; dir=$(dirname "$target")
if [[ ! -d "$dir" ]]; then
msg "Creating directory: $dir"
mkdir -p "$dir" || die "Cannot create $dir — try running with sudo"
fi
# Populate a new file with a context-appropriate template
if [[ ! -f "$target" ]]; then
local name; name=$(basename "$target")
local tmpl="${HOOK_TEMPLATE[$name]:-}"
[[ -n "$tmpl" ]] && printf '%s' "$tmpl" > "$target"
fi
"$EDITOR" "$target"
# Only offer reload if file exists and is non-empty after editing
if [[ -s "$target" ]]; then
nginx_check_and_reload
elif [[ -f "$target" ]]; then
msg "${YELLOW}File is empty — skipping reload.${NC}"
fi
}
cmd_path() {
local domain="${1:-}"
[[ -z "$domain" ]] && die "Usage: $(basename "$0") path <domain>"
local cf
cf=$(find_conf_for_domain "$domain") || die "Domain not found: $domain"
local type; type=$(conf_type "$cf")
if [[ "$type" != "WebStation" ]]; then
msg "Domain '$domain' ($type) has no user-customizable include files."
return 0
fi
local paths
paths=$(portal_custom_includes "$cf")
[[ -z "$paths" ]] && die "No customizable hooks found for $domain"
while IFS= read -r path; do
[[ -z "$path" ]] && continue
local status
[[ -f "$path" ]] && status="[exists]" || status="[not created]"
printf "%-60s %s\n" "$path" "$status"
done <<< "$paths"
local shared
shared=$(shared_domains_for_conf "$cf")
[[ -n "$shared" ]] && \
msg "${YELLOW}Note: config is shared with: $(echo "$shared" | tr '\n' ' ')${NC}"
}
cmd_delete() {
local domain="${1:-}"
[[ -z "$domain" ]] && die "Usage: $(basename "$0") delete <domain>"
local cf
cf=$(find_conf_for_domain "$domain") || die "Domain not found: $domain"
local type; type=$(conf_type "$cf")
[[ "$type" != "WebStation" ]] && \
die "Domain '$domain' ($type) has no user-customizable include files"
local paths
paths=$(portal_custom_includes "$cf")
[[ -z "$paths" ]] && die "No customizable hooks found for $domain"
local existing=()
while IFS= read -r p; do
[[ -f "$p" ]] && existing+=("$p")
done <<< "$paths"
[[ ${#existing[@]} -eq 0 ]] && { msg "No custom config files exist for $domain."; return 0; }
select_path "$(printf '%s\n' "${existing[@]}")" "delete" || die "No path selected"
local target="$_SELECTED_PATH"
msg "File: $target"
confirm "Delete this file?" || { msg "Aborted."; return 0; }
rm "$target"
msg "${GREEN}Deleted: $target${NC}"
local dir; dir=$(dirname "$target")
if [[ -d "$dir" && -z "$(ls -A "$dir" 2>/dev/null)" ]]; then
rmdir "$dir"
msg "Removed empty directory: $dir"
fi
nginx_check_and_reload
}
cmd_clean() {
declare -A referenced
while IFS= read -r path; do
[[ -n "$path" ]] && referenced["$path"]=1
done < <(all_custom_include_paths)
# Collect orphaned files with context info
local orphan_files=()
local orphan_hints=()
for dir in "$CONF_DIR"/*/; do
[[ -d "$dir" ]] || continue
local service_id; service_id=$(basename "$dir")
local hint
hint=$(find_domain_for_service_dir "$service_id")
for fname in user.conf proxy.conf fastcgi.conf; do
local fpath="${dir}${fname}"
[[ -f "$fpath" ]] || continue
if [[ -z "${referenced[$fpath]+x}" ]]; then
orphan_files+=("$fpath")
orphan_hints+=("$hint")
fi
done
done
if [[ ${#orphan_files[@]} -eq 0 ]]; then
msg "${GREEN}No orphaned custom config files found.${NC}"
return 0
fi
msg "${YELLOW}Orphaned custom config files:${NC}"
local i
for i in "${!orphan_files[@]}"; do
local hint="${orphan_hints[$i]}"
[[ -n "$hint" ]] && msg " ${DIM}was: $hint${NC}"
msg " ${orphan_files[$i]}"
done
if ! $AUTO_YES; then
msg ""
msg "No files deleted. Re-run with ${BOLD}-y${NC} to delete these files."
return 0
fi
msg ""
for i in "${!orphan_files[@]}"; do
local fpath="${orphan_files[$i]}"
rm "$fpath"
msg "${GREEN}Deleted:${NC} $fpath"
local dir; dir=$(dirname "$fpath")
if [[ -d "$dir" && -z "$(ls -A "$dir" 2>/dev/null)" ]]; then
rmdir "$dir"
msg " Removed empty directory: $dir"
fi
done
}
# ---------- Argument parsing ----------
# Options are accepted in any position (before or after the command).
[[ $# -eq 0 ]] && { usage; exit 0; }
# Help doesn't need root — handle before sudo escalation
for _arg in "$@"; do
[[ "$_arg" == "-h" || "$_arg" == "--help" ]] && { usage; exit 0; }
done
unset _arg
# Re-execute as root if needed — conf.d service directories require root access
if [[ $EUID -ne 0 ]]; then
exec sudo "$0" "$@"
fi
cmd=""
extra_args=()
while [[ $# -gt 0 ]]; do
case "$1" in
-a|--all) SHOW_ALL=true; shift ;;
-r|--reverse-proxy) SHOW_REVERSE_PROXY=true; shift ;;
-v|--verbose) SHOW_VERBOSE=true; shift ;;
-y|--yes) AUTO_YES=true; shift ;;
-h|--help) usage; exit 0 ;;
-*) die "Unknown option: $1" ;;
*)
if [[ -z "$cmd" ]]; then
cmd="$1"
else
extra_args+=("$1")
fi
shift ;;
esac
done
[[ -z "$cmd" ]] && { usage; exit 1; }
case "$cmd" in
list) cmd_list ;;
show) cmd_show "${extra_args[0]:-}" ;;
edit) cmd_edit "${extra_args[0]:-}" ;;
path) cmd_path "${extra_args[0]:-}" ;;
delete) cmd_delete "${extra_args[0]:-}" ;;
clean) cmd_clean ;;
*) die "Unknown command: $cmd" ;;
esac
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment