Created
May 16, 2026 04:14
-
-
Save ltlapy/aa0dac36db06c997ca9598a111fcf546 to your computer and use it in GitHub Desktop.
Helper script for managing nginx user configuration of Synology DSM Web station
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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