Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ nginx/custom/*
php/custom/*
ols/*
custom/*
config_backup/*
170 changes: 170 additions & 0 deletions nginx/common/8g-firewall.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
# 8G FIREWALL v1.5 20251006
# https://perishablepress.com/8g-firewall/
# nginx version by Tonkünstler-on-the-Bund

# Addon available: Block AI Bots
# https://perishablepress.com/ultimate-ai-block-list/

map default $ng_reason {
default '';
}

Comment on lines +8 to +11
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

Verify the map directive syntax for $ng_reason initialization.

The map directive appears to have incorrect syntax. The first parameter should be a variable to match against, not the literal string "default". This configuration might not work as intended.


🏁 Script executed:

#!/bin/bash
# Description: Check if this map syntax is valid and search for similar patterns in nginx configs

# Search for similar map directive patterns
rg "map\s+default\s+" -A 3

# Search for proper map directive examples
rg "map\s+\$\w+\s+\$ng_reason" -A 3

# Look for nginx configuration examples with reason variables
fd -e conf | xargs rg -l "ng_reason" | head -10 | xargs cat

Length of output: 12309


🌐 Web query:

nginx map directive syntax “map default” first argument literal string

💡 Result:

The map directive in NGINX is used to create variables whose values depend on the value of another variable or string. The syntax generally looks like this:

map $variable $result {
    default value;
    key1 value1;
    key2 value2;
    ...
}
  • The first argument is the source variable or string to be evaluated (e.g., $http_host, $request_uri).
  • The second argument is the name of the new variable to be assigned the mapped value.

When you say "map default" first argument literal string, I believe you want to know how to use a literal string as the first argument in the map directive or how to use default in the map.

Example of map usage:

map $http_user_agent $mobile {
    default 0;
    "~*iphone" 1;
    "~*android" 1;
}

Here:

  • $http_user_agent is the first argument (a variable),
  • $mobile is the second argument (the new variable),
  • default is a special key inside the map block meaning "any other unmatched value".

If you want to use a literal string (like "map default") as the first argument, it's usually not done directly because the first argument is supposed to be a variable or something to evaluate.

Could you clarify your use case or give me an example configuration snippet? That way, I can provide a more precise explanation or correction.


Invalid map directive—first argument must be a variable
The snippet in nginx/common/8g-firewall.conf (lines 5–8):

map default $ng_reason {
    default '';
}

uses the literal “default” as the source, which isn’t valid. The first argument to map must be an existing variable. To initialize $ng_reason to an empty string, please pick one of these approaches:

  • Replace the block with a simple set directive:

    - map default $ng_reason {
    -     default '';
    - }
    + set $ng_reason '';
  • Or change to a proper map using a real variable (for example, $request_uri):

    - map default $ng_reason {
    + map $request_uri $ng_reason {
        default '';
      }

Either will correctly initialize $ng_reason.

🤖 Prompt for AI Agents
In nginx/common/8g-firewall.conf around lines 5 to 8, the map directive
incorrectly uses the literal "default" as the source variable, which is invalid.
To fix this, either replace the entire map block with a set directive to
initialize $ng_reason to an empty string, or modify the map to use a valid
existing variable like $request_uri as the source. Choose one approach to
properly initialize $ng_reason without syntax errors.

map $query_string $bad_querystring_ng {

default 0;

"~*^(?:%2d|-)[^=]+$" 1;
"~*(?:/|%2f)(?::|%3a)(?:/|%2f)" 2;
"~*etc/(?:hosts|motd|shadow)" 3;
"~*order(?:\s|%20)by(?:\s|%20)1--" 4;
"~*(?:/|%2f)(?:\*|%2a)(?:\*|%2a)(?:/|%2f)" 5;
"~*`|<|>|\^|\|\\\|0x00|%00|%0d%0a" 6;
"~*f?ckfinder|f?ckeditor|fullclick" 7;
"~*header:|set-cookie:.*=" 8;
"~*localhost|127(?:\.|%2e)0(?:\.|%2e)0(?:\.|%2e)1" 9;
"~*(?:cmd|command)(?:=|%3d)(?:chdir|mkdir).*x20" 10;
"~*(?:globals|mosconfig[a-z_]{1,22}|(?<!SAML)request)(?:=|\[)" 11;
"~*(?:/|%2f)(?:wp-)?config(?:(?:\.|%2e)inc)?(?:\.|%2e)php" 12;
"~*(?:thumbs?(?:_editor|open)?|tim(?:thumbs?)?)(?:\.|%2e)php" 13;
"~*(?:absolute_|base|root_)(?:dir|path)(?:=|%3d)(?:ftp|https?)" 14;
"~*s?(?:ftp|inurl|php)s?:(?:/|%2f|%u2215)(?:/|%2f|%u2215)" 15;
"~*(?:\.|20)(?:get|the)(?:_|%5f)(?:permalink|posts_page_url)(?:\(|%28)" 16;
"~*(?:boot|win)(?:\.|%2e)ini|etc(?:/|%2f)passwd|self(?:/|%2f)environ" 17;
"~*(?:/|%2f){3,3}|(?:\.|%2e){3,3}|(?:\.|%2e){2,2}(?:/|%2f|%u2215)" 18;
"~*(?:benchmark|exec|fopen|function|html).*(?:\(|%28)" 19;
"~*php[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}" 20;
"~*eval.*(?:\(|%28)" 21;
"~*(?:/|%2f)(?:=|%3d|$&|_mm|cgi(?:\.|-)|inurl(?::|%3a)(?:/|%2f)|(?:mod|path)(?:=|%3d)(?:\.|%2e))" 22;
"~*%3c.*embed" 23;
"~*%3c.*iframe" 24;
"~*%3c.*object" 25;
"~*%3c.*script" 26;
"~*(?:\+|%2b|%20)delete(?:\+|%2b|%20)" 27;
"~*(?:\+|%2b|%20)insert(?:\+|%2b|%20)" 28;
"~*(?:\+|%2b|%20)select(?:\+|%2b|%20)" 29;
"~*(?:\+|%2b|%20)update(?:\+|%2b|%20)" 30;
"~*\\\x00|(?:\"|%22|\'|%27)?0(?:\"|%22|\'|%27)?(?:=|%3d)(?:\"|%22|\'|%27)?0|cast(?:\(|%28)0x|or%201(?:=|%3d)1" 31;
"~*globals(?:=|\[|%[0-9A-Z]{0,2})" 32;
"~*_request(?:=|\[|%[0-9A-Z]{2,})" 33;
"~*javascript(?::|%3a).*(?:;|%3b|\)|%29)" 34;
"~*base64_(?:en|de)code.*\)" 35;
"~*@copy|\$_(?:files|get|post)|allow_url_(?:fopen|include)|auto_prepend_file|blexbot|browsersploit|call_user_func_array|(?:php|web)shell|curl(?:_exec|test)|disable_functions?|document_root" 36;
"~*elastix|encodeuricom|exploit|fclose|fgets|file_put_contents|fputs|fsbuff|fsockopen|gethostbyname|grablogin|hmei7|hubs_post-cta|input_file|invokefunction|\bload_file|open_basedir|outfile|p3dlite" 37;
"~*pass(?:=|%3d)shell|passthru|phpshells|popen|proc_open|quickbrute|remoteview|root_path|safe_mode|shell_exec|site.{0,2}copier|sp_executesql|sux0r|trojan|udtudt|user_func_array|wget|wp_insert_user|xertive" 38;
"~*(?:\+|%2b)(?:concat|delete|get|select|union)(?:\+|%2b)" 39;
"~*union.*select" 40;
"~*(?:concat|eval).*(?:\(|%28)" 41;

}

map $uri $bad_request_ng {

default 0;

"~*,,," 1;
"~*-------" 2;
"~*\^|`|<|>|\\\|\|" 3;
"~*=?\\\(?:\'|%27)/?\." 4;
"~*/(?:\*|\"|\'|\.|,|&|&amp;?)/?$" 5;
"~*\.php(\()?[0-9]+\)?/?$" 6;
"~*(?:header:|set-cookie:.*=)" 7;
"~*\.s?ftp-?config|s?ftp-?config\." 8;
"~*/(?:f?ckfinder|fck/|fckeditor|fullclick)" 9;
"~*/(?:(?:force-)?download|framework/main)\.php" 10;
"~*\{0\}|\"?0\"?=\"?0|\(/\(|\.\.\.|\+\+\+|\\\\\"" 11;
"~*/(?:vbull(?:etin)?|boards|vbforum|vbweb|webvb)/?" 12;
"~*(?:\.|20)(?:get|the)_(?:permalink|posts_page_url)\(" 13;
"~*///|\?\?|/&&|/\*.*\*/|/:/|\\\\\\\|0x00|%00|%0d%0a" 14;
"~*/(?:cgi_?)?alfa(?:_?cgiapi|_?data|_?v[0-9]+)?\.php" 15;
"~*(?:thumbs?(?:_editor|open)?|tim(?:thumbs?)?)(?:\.|%2e)php" 16;
"~*/(?:boot)?_?admin(?:er|istrator|s)(?:_events)?\.php" 17;
"~*/%7e(?:root|ftp|bin|nobody|named|guest|logs|sshd)/" 18;
"~*(?:archive|backup|db|master|sql|wp|www|wwwroot)\.(?:gz|zip)" 19;
"~*/(?:\.?mad|alpha|c99|php|web)?sh(?:3|e)ll(?:[0-9]+|\w)\.php" 20;
"~*/(?:admin-?|file-?)upload(?:bg|_?file|ify|svu|ye)?\.php" 21;
"~*/(?:etc|var)/(?:hidden|secret|shadow|ninja|passwd|tmp)/?$" 22;
"~*s?(?:ftp|http|inurl|php)s?:(?:/|%2f|%u2215)(?:/|%2f|%u2215)" 23;
"~*/(?:=|\$&?|&?(?:pws|rk)=0|_mm|_vti_|cgi(?:\.|-)|(?:=|/|;|,)nt\.)" 24;
"~*\.(?:ds_store|htaccess|htpasswd|init?|mysql-select-db)/?$" 25;
"~*/bin/(?:cc|chmod|chsh|cpp|echo|id|kill|mail|nasm|perl|ping|ps|python|tclsh)/?$" 26;
"~*/(?:::[0-9999]|%3a%3a[0-9999]|127\.0\.0\.1|ccx|localhost|makefile|pingserver|wwwroot)/?" 27;
"~*^/(?:123|backup|bak|beta|bkp|default|demo|dev(?:new|old)?|new-?site|null|old|old_files|old1)/?$" 28;
"~*j\s*a\s*v\s*a\s*s\s*c\s*r\s*i\s*p\s*t\s*(?:%3a|:)" 29;
"~*^/(?:old-?site(?:back)?|old(?:web)?site(?:here)?|sites?|staging|undefined|wordpress[0-9]+|wordpress-old)/?$" 30;
"~*/(?:filemanager|htdocs|httpdocs|https?|mailman|mailto|msoffice|undefined|var|vhosts|webmaster|www)/" 31;
"~*\(null\)|\{\$itemURL\}|cast\(0x|echo.*kae|etc/passwd|eval\(|null.*null|open_basedir|self/environ|\+union\+all\+select" 32;
"~*(?:conf\b|conf(?:ig)?)(?:uration)?(?:\.?bak|\.inc|\.old|\.php|\.txt)" 33;
"~*/(?:.*crlf-?injection|.*xss-?protection|__(?:inc|jsc)|author-panel|cgi-bin|database|downloader|(?:db|mysql)-?admin)/" 34;
"~*/(?:haders|head|hello|helpear|incahe|includes?|indo(?:sec)?|infos?|ioptimizes?|jmail|js|king|kiss|kodox|kro|legion|libsoft)\.php" 35;
"~*/(?:awstats|document_root|dologin\.action|error.log|extension/ext|htaccess\.|lib/php|listinfo|phpunit/php|remoteview|server/php|www\.root\.)" 36;
"~*(?:base64_(?:en|de)code|benchmark|curl_exec|echr|eval|function|fwrite|(?:f|p)open|html|leak|passthru|p?fsockopen|phpinfo).*(?:\)|%29)" 37;
"~*(?:posix_(?:kill|mkfifo|setpgid|setsid|setuid)|(?:child|proc)_(?:close|get_status|nice|open|terminate)|(?:shell_)?exec|system).*(?:\)|%29)" 38;
"~*/(?:(?:c99|php|web)?shell|crossdomain|fileditor|locus7|nstview|php(?:get|remoteview|writer)|r57|remview|sshphp|storm7|webadmin).*(?:\.|%2e|\(|%28)" 39;
"~*/wp-(?:201\d|202\d|[0-9]{2}|ad|admin(?:fx|rss|setup)|booking|confirm|crons|data|file|mail|one|plugins?|readindex|reset|setups?|story)\.php" 40;
"~*/(?:^$|-|\!|\w|\..*|100|123|[^iI]?ndex|index\.php/index|3xp|777|7yn|90sec|99|active|aill|ajs\.delivery|al277|alexuse?|ali|allwrite)\.php" 41;
"~*/(?:analyser|apache|apikey|apismtp|authenticat(?:e|ing)|autoload_classmap|backup(?:_index)?|bakup|bkht|black|bogel|bookmark|bypass|cachee?)\.php" 42;
"~*/(?:clean|cm(?:d|s)|con|connector\.minimal|contexmini|contral|curl(?:test)?|data(?:base)?|db|db-cache|db-safe-mode|defau11|defau1t|dompdf|dst)\.php" 43;
"~*/(?:elements|emails?|error.log|ecscache|edit-form|eval-stdin|evil|fbrrchive|filemga|filenetworks?|f0x|gank(?:\.php)?|gass|gel|guide)\.php" 44;
"~*/(?:logo_img|lufix|mage|marg|mass|mide|moon|mssqli|mybak|myshe|mysql|mytag_js?|nasgor|newfile|news|nf_?tracking|nginx|ngoi|ohayo|old-?index)\.php" 45;
"~*/(?:olux|owl|pekok|petx|php-?info|phpping|popup-pomo|priv|r3x|radio|rahma|randominit|readindex|readmy|reads|repair-?bak|robot(?:s\.txt)?|root)\.php" 46;
"~*/(?:router|savepng|semayan|shell|shootme|sky|socket(?:c|i|iasrgasf)ontrol|sql(?:bak|_?dump)?|support|sym403|sys|system_log|test|tmp-?(?:uploads)?)\.php" 47;
"~*/(?:traffic-advice|u2p|udd|ukauka|up__uzegp|up14|upa?|upxx?|vega|vip|vu(?:ln)?\w?|webroot|weki|wikindex|wordpress|wp_logns?|wp_wrong_datlib)\.php" 48;
"~*/(?:wp-?install|installation|wp(?:3|4|5|6)|wpfootes|wpzip|ws0|wsdl|wso\w?|www|(?:uploads|wp-admin)?xleet(?:-shell)?|xmlsrpc|xup|xxu|xxx|zibi|zipy)\.php" 49;
"~*bkv74|cachedsimilar|core-stab|crgrvnkb|ctivrc|deadcode|deathshop|dkiz|e7xue|eqxafaj90zir|exploits|ffmkpcal|filellli7|(?:fox|sid)wso|gel4y|goog1es|gvqqpinc" 50;
"~*@md5|00.temp00|0byte|0d4y|0day|0xor|wso1337|1h6j5|3xp|40dd1d|4price|70bex?|a57bze893|abbrevsprl|abruzi|adminer|aqbmkwwx|archivarix|backdoor|beez5|bgvzc29" 51;
"~*handler_to_code|hax(?:0|o)r|hmei7|hnap1|home_url=|ibqyiove|icxbsx|indoxploi|jahat|jijle3|kcrew|keywordspy|laobiao|lock360|longdog|marijuan|mod_(?:aratic|ariimag)" 52;
"~*mobiquo|muiebl|nessus|osbxamip|phpunit|priv8|qcmpecgy|r3vn330|racrew|raiz0|reportserver|r00t|respectmus|rom2823|roseleif|sh3ll|site.{0,2}copier|sqlpatch|sux0r" 53;
"~*sym403|telerik|uddatasql|utchiha|visualfrontend|w0rm|wangdafa|wpyii2|wsoyanzo|x5cv|xattack|xbaner|xertive|xiaolei|xltavrat|xorz|xsamxad|xsvip|xxxs?s?|zabbix|zebda" 54;
"~*\.(?:7z|ab4|ace|afm|alfa|as(?:h|m)x?|aspx?|aws|axd|bash|ba?k?|bat|bz2|cfg|cfml?|cgi|cms|conf\b|config|ctl|dat|db|dist|dll|eml|eng(?:ine)?|env|et2|exe|fec|fla|git(?:ignore)?)$" 55;
"~*\.(?:hg|idea|inc|index|ini|inv|jar|jspa?|lib|local|log|lqd|make|mbf|mdb|mmw|mny|mod(?:ule)?|msi|old|one|orig|out|passwd|pdb|php\.(?:php|suspect(?:ed)?)|php[^\/]|phtml?|pl|profiles?)$" 56;
"~*\.(?:psd|pst|ptdb|production|pwd|py|qbb|qdf|rar|rdf|remote|save|sdb|sql|sh|soa|svn|swf|swl|swo|swp|stx|tar|tax|tgz?|theme|tls|tmb|tmd|wok|wow|xsd|xtmpl|xz|ya?ml|za|zlib)$" 57;

}

map $http_user_agent $bad_bot_ng {

default 0;

"~*^[a-z0-9]$" 1;
"~*&lt;|%0a|%0d|%27|%3c|%3e|%00|0x00|\\\\\x22|\\\\\"" 2;
"~*ahrefs|archiver|curl|libwww-perl|pycurl|scan" 3;
"~*oppo\sa33|(?:c99|php|web)shell|site.{0,2}copier" 4;
"~*base64_decode|bin/bash|disconnect|eval|unserializ" 5;
"~*acapbot|acoonbot|alexibot|antbot|asterias|attackbot|awario|babbar|backdor|barkrowler|becomebot|binlar|bitsightbot|blackwidow|blekkobot|blex|blowfish|bullseye|bunnys|butterfly|bytespider|careerbot|casper|censysinspect" 6;
"~*checkpriv|cheesebot|cherrypick|chinaclaw|choppy|claudebot|clshttp|cmsworld|commoncrawl|copernic|copyrightcheck|cosmos|crawlergo|crescent|criteo|datacha|dataforseo|\bdemon\b|diavol|discobot|dittospyder" 7;
"~*dotbot|dotnetdotcom|dumbot|econtext|emailcollector|emailsiphon|emailwolf|eolasbot|eventures|extract|eyenetie|feedfinder|flaming|flashget|flicky|foobot|fuck" 8;
"~*g00g1e|getright|gigabot|go-ahead-got|go-http-client|gozilla|grabnet|grafula|harvest|heritrix|hrankbot|httracks?|icarus6j|imagesiftbot|intelx|jetbot|jetcar|jikespider|kmccrew|leechftp|libweb|liebaofast" 9;
"~*linkscan|linkwalker|lwp-download|majestic|masscan|mauibot|miner|mechanize|mj12bot|morfeus|moveoverbot|mozlila|netmechanic|netspider|nicerspro|nikto|ninja|nominet|nutch" 10;
"~*octopus|pagegrabber|petalbot|planetwork|postrank|proximic|purebot|queryn|queryseeker|radian6|radiation|realdownload|remoteview|rogerbot|scan|scooter|scrapy|seekerspid" 11;
"~*semalt|seokicks|seznambot|siclab|sindice|sistrix|sitebot|siteexplorer|sitesnagger|skygrid|smartdownload|snoopy|sosospider|spankbot|spbot|sqlmap|stackrambler|stripper|sucker|surftbot" 12;
"~*sux0r|suzukacz|suzuran|takeout|teleport|telesoft|true_robots|turingos|turnit|tweetmemebot|vampire|vikspider|voideye|webleacher|webreaper|webstripper|webvac|webviewer|webwhacker|wellknownbot" 13;
"~*winhttp|wwwoffle|woxbot|xaldon|xxxyy|yamanalab|yioopbot|yisouspider|youda|zeus|zmeu|zoominfobot|zune|zyborg" 14;

}

map $http_referer $bad_referer_ng {

default 0;

"~*order(?:\s|%20)by(?:\s|%20)1--" 1;
"~*@unlink|assert\(|print_r\(|\\\x00|%00|xbshell" 2;
"~*100dollars|best-seo|blue\spill|cocaine|ejaculat|erectile|erections|hoodia|huronriveracres|impotence|levitra|libido|lipitor|mopub\.com|phentermin" 3;
"~*pornhelm|pro[sz]ac|sandyauer|semalt\.com|social-buttions|todaperfeita|tramadol|troyhamby|ultram|unicauca|valium|viagra|vicodin|xanax|ypxaieo" 4;

}

map $http_cookie $bad_cookie_ng {

default 0;

"~*<|>|\'|%0A|%0D|%27|%00" 1;

}

map $request_method $not_allowed_method_ng {

default 0;

"~*^(?:connect|debug|move|trace|track)" 1;

}
36 changes: 36 additions & 0 deletions nginx/common/8g.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# 8G FIREWALL v1.5 20251006
# https://perishablepress.com/8g-firewall/
# nginx version by Tonkünstler-on-the-Bund

# Addon available: Block AI Bots
# https://perishablepress.com/ultimate-ai-block-list/

if ($bad_cookie_ng) {
set $ng_reason "${ng_reason}:bad_cookie_${bad_cookie_ng}:";
return 403;
}

if ($not_allowed_method_ng) {
set $ng_reason "${ng_reason}:not_allowed_method_${not_allowed_method_ng}:";
return 405;
}

if ($bad_referer_ng) {
set $ng_reason "${ng_reason}:bad_referer_${bad_referer_ng}:";
return 403;
}

if ($bad_bot_ng) {
set $ng_reason "${ng_reason}:bad_bot_${bad_bot_ng}:";
return 403;
}

if ($bad_querystring_ng) {
set $ng_reason "${ng_reason}:bad_querystring_${bad_querystring_ng}:";
return 403;
}

if ($bad_request_ng) {
set $ng_reason "${ng_reason}:bad_request_${bad_request_ng}:";
return 403;
}
116 changes: 116 additions & 0 deletions scripts/update-8g-firewall.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
#!/usr/bin/env bash
set -euo pipefail

# update-8g-firewall.sh
# Template script to download and update one or more files.
# Automated mode: uses built-in variables at the top of the file (no CLI args)

# Needs to be run from site_dir/config directory, not from the scripts/ folder

PROG_NAME="$(basename "$0")"

# Configuration (built-in variables for automated execution)
# Edit the variables below to configure the run. This script does NOT accept CLI args.
# DEST_PREFIX: partial destination path to prepend (e.g. ./custom or /etc/nginx/common)
# BASE_URL: base URL to download from (filename appended)
# BACKUP_DIR: where to store backups
# DRY_RUN: set to true to only simulate actions
# FILES: array of filenames (or relative paths) to download and update

DEST_PREFIX="./nginx/common"
BASE_URL="https://raw.githubusercontent.com/t18d/nG-SetEnvIf/develop"
BACKUP_DIR="./config_backup"
DRY_RUN=false

# Example: FILES=(8g.conf cloudflare.conf)
FILES=(8g.conf 8g-firewall.conf)

# Ensure DEST_PREFIX and BASE_URL have no trailing slash
DEST_PREFIX="${DEST_PREFIX%/}"
BASE_URL="${BASE_URL%/}"

mkdir -p "$BACKUP_DIR"

summary_added=0
summary_updated=0
summary_skipped=0
summary_failed=0

for filename in "${FILES[@]}"; do
# allow passing path-like filenames too
dest_path="$DEST_PREFIX/$filename"
url="$BASE_URL/$filename"
tmpfile="/tmp/${PROG_NAME}.$(date +%s%3N).$$.$(basename "$filename")"

echo "----"
echo "Processing: $filename"
echo "Destination: $dest_path"
echo "URL: $url"

if [ "$DRY_RUN" = true ]; then
echo "DRY RUN: would download $url to $tmpfile and compare/replace $dest_path"
summary_skipped=$((summary_skipped+1))
continue
fi

# Download to tmpfile
if ! curl -fsSL --connect-timeout 10 -o "$tmpfile" "$url"; then
echo "Failed to download $url" >&2
summary_failed=$((summary_failed+1))
rm -f "$tmpfile" || true
continue
fi

if [ -f "$dest_path" ]; then
# Compare files (binary-safe)
if cmp -s "$tmpfile" "$dest_path"; then
echo "No changes for $filename; skipping"
summary_skipped=$((summary_skipped+1))
rm -f "$tmpfile"
continue
else
# backup old file
timestamp=$(date +%Y%m%dT%H%M%S)
backup_path="$BACKUP_DIR/$(basename "$dest_path").$timestamp"
mkdir -p "$(dirname "$backup_path")"
echo "Backing up existing file to $backup_path"
if ! cp -p "$dest_path" "$backup_path"; then
echo "Failed to backup $dest_path" >&2
summary_failed=$((summary_failed+1))
rm -f "$tmpfile"
continue
fi
# Replace
mkdir -p "$(dirname "$dest_path")"
if mv "$tmpfile" "$dest_path"; then
echo "Updated $dest_path"
summary_updated=$((summary_updated+1))
else
echo "Failed to replace $dest_path" >&2
summary_failed=$((summary_failed+1))
rm -f "$tmpfile"
continue
fi
fi
else
# New file — ensure directory exists then move
mkdir -p "$(dirname "$dest_path")"
if mv "$tmpfile" "$dest_path"; then
echo "Added new file $dest_path"
summary_added=$((summary_added+1))
else
echo "Failed to move $tmpfile to $dest_path" >&2
summary_failed=$((summary_failed+1))
rm -f "$tmpfile"
continue
fi
fi

# Optional: set safe permissions
chmod 0644 "$dest_path" 2>/dev/null || true
done

echo "----\nSummary: added=$summary_added updated=$summary_updated skipped=$summary_skipped failed=$summary_failed"

exit 0