4 Achegas ed6bc1d898 ... 8e13f8b172

Autor SHA1 Mensaxe Data
  BlindMaster24 8e13f8b172 fix(tgbot): suppress 'message not modified' warnings in Telegram edit calls (#6340) hai 9 horas
  ilyusha fc05249e0c fix(geofile): verify downloaded geo databases against published digests (#6404) hai 9 horas
  Atirna 4e355edc15 fix(sub): skip AmneziaWG JSON entries (#6420) hai 9 horas
  Farhan Zare 0f6e1ae8d7 fix(sub): bind JSON local inbounds to 127.0.0.1 and keep mux.cool off Vision outbounds (#6418) hai 9 horas

+ 1 - 1
docs/content/docs/en/config/subscription.mdx

@@ -57,7 +57,7 @@ page instead of the raw body.
 
 The **Base64** body is just the newline-joined share links, standard-base64
 encoded (toggle with `subEncrypt`). The **JSON** body wraps each client in a
-complete Xray client config — a fixed skeleton (local SOCKS/HTTP inbounds, DNS,
+complete Xray client config — a fixed skeleton (local SOCKS/HTTP inbounds bound to 127.0.0.1, DNS,
 routing, policy) plus a `proxy` outbound pointing at the inbound. 3x-ui emits a
 **single config object for one client and an array for several**, uses the flat
 outbound `settings` form (`address`/`port`/`id`, `level: 8`), and strips

+ 1 - 1
docs/content/docs/fa/config/subscription.mdx

@@ -58,7 +58,7 @@ https://<sub-host>:<sub-port>/<sub-path>/<sub-id>
 بدنه‌ی **Base64** صرفاً همان لینک‌های اشتراک‌گذاری است که با خط جدید به هم پیوسته و
 با standard-base64 رمزگذاری شده‌اند (با `subEncrypt` قابل تغییر است). بدنه‌ی **JSON**
 هر کلاینت را در یک پیکربندی کامل کلاینت Xray می‌پیچد — یک اسکلت ثابت (ورودی‌های محلی
-SOCKS/HTTP، DNS، مسیریابی، policy) به‌علاوه‌ی یک outbound از نوع `proxy` که به ورودی
+SOCKS/HTTP روی 127.0.0.1، DNS، مسیریابی، policy) به‌علاوه‌ی یک outbound از نوع `proxy` که به ورودی
 اشاره می‌کند. 3x-ui **برای یک کلاینت یک شیء پیکربندی واحد و برای چند کلاینت یک آرایه**
 تولید می‌کند، از فرم تخت `settings` در outbound استفاده می‌کند
 (`address`/`port`/`id`، `level: 8`) و `sockopt` را از `streamSettings` حذف می‌کند.

+ 1 - 1
docs/content/docs/ru/config/subscription.mdx

@@ -59,7 +59,7 @@ https://<sub-host>:<sub-port>/<sub-path>/<sub-id>
 Тело **Base64** — это просто ссылки для обмена, объединённые через перевод
 строки и закодированные в стандартный base64 (переключается через `subEncrypt`).
 Тело **JSON** оборачивает каждого клиента в полную клиентскую конфигурацию
-Xray — фиксированный каркас (локальные входящие SOCKS/HTTP, DNS, маршрутизация,
+Xray — фиксированный каркас (локальные входящие SOCKS/HTTP на 127.0.0.1, DNS, маршрутизация,
 policy) плюс исходящее соединение `proxy`, указывающее на входящее. 3x-ui
 выдаёт **единый объект конфигурации для одного клиента и массив для
 нескольких**, использует плоскую форму `settings` исходящего соединения

+ 1 - 1
docs/content/docs/zh/config/subscription.mdx

@@ -46,7 +46,7 @@ https://<sub-host>:<sub-port>/<sub-path>/<sub-id>
 
 ### Base64 与 JSON
 
-**Base64** 内容只是用换行符连接的分享链接,经标准 base64 编码(通过 `subEncrypt` 开关控制)。**JSON** 内容则将每个客户端包装为一份完整的 Xray 客户端配置 —— 一套固定的骨架(本地 SOCKS/HTTP 入站、DNS、路由、策略)加上一个指向该入站的 `proxy` 出站。3x-ui **对单个客户端输出单个配置对象,对多个客户端输出数组**,使用扁平的出站 `settings` 形式(`address`/`port`/`id`,`level: 8`),并从 `streamSettings` 中剥离 `sockopt`。
+**Base64** 内容只是用换行符连接的分享链接,经标准 base64 编码(通过 `subEncrypt` 开关控制)。**JSON** 内容则将每个客户端包装为一份完整的 Xray 客户端配置 —— 一套固定的骨架(绑定到 127.0.0.1 的本地 SOCKS/HTTP 入站、DNS、路由、策略)加上一个指向该入站的 `proxy` 出站。3x-ui **对单个客户端输出单个配置对象,对多个客户端输出数组**,使用扁平的出站 `settings` 形式(`address`/`port`/`id`,`level: 8`),并从 `streamSettings` 中剥离 `sockopt`。
 
 ## 响应头
 

+ 2 - 1
docs/lib/xray/subscription.test.ts

@@ -126,11 +126,12 @@ describe('buildJsonSubscription', () => {
     const http = cfg.inbounds.find((inbound: { port: number }) => inbound.port === 10809);
 
     expect(socks).toMatchObject({
+      listen: '127.0.0.1',
       protocol: 'socks',
       tag: 'mixed',
       settings: { udp: true },
     });
-    expect(http).toMatchObject({ protocol: 'http' });
+    expect(http).toMatchObject({ listen: '127.0.0.1', protocol: 'http' });
   });
 
   it('trojan uses servers[] with a password and no method', () => {

+ 8 - 1
docs/lib/xray/subscription.ts

@@ -145,13 +145,20 @@ function subJsonSkeleton(): Record<string, unknown> {
     },
     inbounds: [
       {
+        listen: '127.0.0.1',
         port: 10808,
         protocol: 'socks',
         settings: { auth: 'noauth', udp: true, userLevel: 8 },
         sniffing: { destOverride: ['http', 'tls', 'quic', 'fakedns'], enabled: true },
         tag: 'mixed',
       },
-      { port: 10809, protocol: 'http', settings: { userLevel: 8 }, tag: 'http' },
+      {
+        listen: '127.0.0.1',
+        port: 10809,
+        protocol: 'http',
+        settings: { userLevel: 8 },
+        tag: 'http',
+      },
     ],
     log: { loglevel: 'warning' },
     policy: {

+ 2 - 0
internal/sub/default.json

@@ -12,6 +12,7 @@
   },
   "inbounds": [
     {
+      "listen": "127.0.0.1",
       "port": 10808,
       "protocol": "socks",
       "settings": {
@@ -31,6 +32,7 @@
       "tag": "mixed"
     },
     {
+      "listen": "127.0.0.1",
       "port": 10809,
       "protocol": "http",
       "settings": {

+ 21 - 0
internal/sub/json_service.go

@@ -589,6 +589,8 @@ func (s *SubJsonService) getConfig(subReq *SubService, inbound *model.Inbound, c
 				continue
 			}
 			newOutbounds = append(newOutbounds, wgOutbound)
+		case "amneziawg":
+			continue
 		}
 
 		newOutbounds = append(newOutbounds, s.defaultOutbounds...)
@@ -788,12 +790,31 @@ func (s *SubJsonService) genVless(subReq *SubService, inbound *model.Inbound, st
 	}
 	if client.Flow != "" && !inbound.DisableFlow {
 		settings["flow"] = client.Flow
+		outbound.Mux = muxWithoutTCP(mux)
 	}
 	outbound.Settings = settings
 	result, _ := json.MarshalIndent(outbound, "", "  ")
 	return result
 }
 
+// XTLS flows reject TCP mux.cool ("unexpected network TCP"); concurrency -1
+// turns only that off and keeps the XUDP keys (Xray reads them under enabled).
+func muxWithoutTCP(mux string) json_util.RawMessage {
+	if mux == "" {
+		return nil
+	}
+	var m map[string]any
+	if err := json.Unmarshal([]byte(mux), &m); err != nil || m == nil {
+		return nil
+	}
+	m["concurrency"] = -1
+	out, err := json.Marshal(m)
+	if err != nil {
+		return nil
+	}
+	return json_util.RawMessage(out)
+}
+
 func (s *SubJsonService) genServer(subReq *SubService, inbound *model.Inbound, streamSettings json_util.RawMessage, client model.Client, mux string) json_util.RawMessage {
 	outbound := Outbound{}
 

+ 50 - 0
internal/sub/json_service_test.go

@@ -66,11 +66,55 @@ func TestDefaultJSONUsesCompatibleLocalInbounds(t *testing.T) {
 	if settings == nil || settings["udp"] != true {
 		t.Fatalf("port 10808 settings = %#v, want udp enabled", socks["settings"])
 	}
+	if socks["listen"] != "127.0.0.1" {
+		t.Fatalf("port 10808 listen = %#v, want 127.0.0.1 (an unbound local inbound is exposed to the LAN and is not reachable by iOS packet tunnels)", socks["listen"])
+	}
 
 	http := byPort[10809]
 	if http == nil || http["protocol"] != "http" {
 		t.Fatalf("port 10809 inbound = %#v, want http protocol", http)
 	}
+	if http["listen"] != "127.0.0.1" {
+		t.Fatalf("port 10809 listen = %#v, want 127.0.0.1", http["listen"])
+	}
+}
+
+func TestSubJsonServiceVisionFlowDisablesTCPMuxOnly(t *testing.T) {
+	globalMux := `{"enabled":true,"concurrency":8,"xudpConcurrency":16,"xudpProxyUDP443":"reject"}`
+	svc := NewSubJsonService(globalMux, "", "", nil)
+	inbound := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.VLESS, Settings: `{"encryption":"none"}`}
+
+	decode := func(raw []byte) map[string]any {
+		t.Helper()
+		var ob map[string]any
+		if err := json.Unmarshal(raw, &ob); err != nil {
+			t.Fatalf("unmarshal outbound: %v", err)
+		}
+		return ob
+	}
+
+	vision := decode([]byte(svc.genVless(&SubService{}, inbound, nil, model.Client{ID: "uuid-1", Flow: "xtls-rprx-vision"}, globalMux)))
+	mux, _ := vision["mux"].(map[string]any)
+	if mux == nil {
+		t.Fatalf("vision outbound must keep its mux object for the XUDP keys, got %#v", vision["mux"])
+	}
+	if mux["concurrency"] != float64(-1) {
+		t.Fatalf("vision outbound mux.concurrency = %v, want -1 (TCP mux.cool is rejected by XTLS flows)", mux["concurrency"])
+	}
+	if mux["enabled"] != true || mux["xudpConcurrency"] != float64(16) || mux["xudpProxyUDP443"] != "reject" {
+		t.Fatalf("vision outbound lost its XUDP settings: %#v", mux)
+	}
+
+	plain := decode([]byte(svc.genVless(&SubService{}, inbound, nil, model.Client{ID: "uuid-1"}, globalMux)))
+	mux, _ = plain["mux"].(map[string]any)
+	if mux == nil || mux["concurrency"] != float64(8) {
+		t.Fatalf("flow-less outbound must keep the global mux unchanged, got %#v", plain["mux"])
+	}
+
+	noMux := decode([]byte(svc.genVless(&SubService{}, inbound, nil, model.Client{ID: "uuid-1", Flow: "xtls-rprx-vision"}, "")))
+	if _, has := noMux["mux"]; has {
+		t.Fatalf("no global mux must still mean no mux key, got %#v", noMux["mux"])
+	}
 }
 
 func TestSubJsonServiceInjectsGlobalFinalMask(t *testing.T) {
@@ -470,3 +514,9 @@ func TestSubJsonServiceWireguardNoKey(t *testing.T) {
 		t.Fatalf("genWireguard = %s, want nil for a keyless wireguard client", raw)
 	}
 }
+
+func TestSubJsonServiceSkipsAmneziaWG(t *testing.T) {
+	if got := NewSubJsonService("", "", "", nil).getConfig(&SubService{address: "sub.example.com"}, &model.Inbound{Listen: "203.0.113.8", Port: 51820, Protocol: model.AmneziaWG}, model.Client{}, "sub.example.com"); len(got) != 0 {
+		t.Fatalf("getConfig emitted %d unsupported AmneziaWG Xray config(s)", len(got))
+	}
+}

+ 247 - 80
internal/web/service/server.go

@@ -14,6 +14,7 @@ import (
 	"errors"
 	"fmt"
 	"io"
+	"maps"
 	"math"
 	"mime/multipart"
 	stdnet "net"
@@ -21,6 +22,7 @@ import (
 	"net/url"
 	"os"
 	"os/exec"
+	"path"
 	"path/filepath"
 	"regexp"
 	"runtime"
@@ -2181,20 +2183,42 @@ func (s *ServerService) IsValidGeofileName(filename string) bool {
 	return matched
 }
 
-func (s *ServerService) UpdateGeofile(fileName string) error {
-	type geofileEntry struct {
-		URL      string
-		FileName string
-	}
-	geofileAllowlist := map[string]geofileEntry{
-		"geoip.dat":      {"https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geoip.dat", "geoip.dat"},
-		"geosite.dat":    {"https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geosite.dat", "geosite.dat"},
-		"geoip_IR.dat":   {"https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geoip.dat", "geoip_IR.dat"},
-		"geosite_IR.dat": {"https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geosite.dat", "geosite_IR.dat"},
-		"geoip_RU.dat":   {"https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geoip.dat", "geoip_RU.dat"},
-		"geosite_RU.dat": {"https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geosite.dat", "geosite_RU.dat"},
-	}
+// Repo is the upstream release base and Asset the name it publishes under; all
+// three publish "geoip.dat", so only FileName tells the local copies apart.
+type geofileEntry struct {
+	Repo     string
+	Asset    string
+	FileName string
+}
+
+var geofileAllowlist = map[string]geofileEntry{
+	"geoip.dat":      {"https://github.com/Loyalsoldier/v2ray-rules-dat", "geoip.dat", "geoip.dat"},
+	"geosite.dat":    {"https://github.com/Loyalsoldier/v2ray-rules-dat", "geosite.dat", "geosite.dat"},
+	"geoip_IR.dat":   {"https://github.com/chocolate4u/Iran-v2ray-rules", "geoip.dat", "geoip_IR.dat"},
+	"geosite_IR.dat": {"https://github.com/chocolate4u/Iran-v2ray-rules", "geosite.dat", "geosite_IR.dat"},
+	"geoip_RU.dat":   {"https://github.com/runetfreedom/russia-v2ray-rules-dat", "geoip.dat", "geoip_RU.dat"},
+	"geosite_RU.dat": {"https://github.com/runetfreedom/russia-v2ray-rules-dat", "geosite.dat", "geosite_RU.dat"},
+}
+
+func (entry geofileEntry) latestURL() string {
+	return entry.Repo + "/releases/latest/download/" + entry.Asset
+}
 
+func (entry geofileEntry) taggedURL(tag string) string {
+	return entry.Repo + "/releases/download/" + tag + "/" + entry.Asset
+}
+
+// stagedGeofile is a verified download waiting to be moved into the asset folder.
+type stagedGeofile struct {
+	destPath  string
+	stagePath string
+}
+
+// restartXrayAfterGeofileUpdate is a seam: tests assert that an update which
+// installed nothing also restarted nothing.
+var restartXrayAfterGeofileUpdate = (*ServerService).RestartXrayService
+
+func (s *ServerService) UpdateGeofile(fileName string) error {
 	// Strict allowlist check to avoid writing uncontrolled files
 	if fileName != "" {
 		if _, ok := geofileAllowlist[fileName]; !ok {
@@ -2202,103 +2226,246 @@ func (s *ServerService) UpdateGeofile(fileName string) error {
 		}
 	}
 
+	wanted := geofileAllowlist
+	if fileName != "" {
+		wanted = map[string]geofileEntry{fileName: geofileAllowlist[fileName]}
+	}
+
+	// Atomic per upstream, not across all six: one release's databases belong
+	// together, but a failing repo must not discard another repo's good files.
+	byRepo := make(map[string][]geofileEntry, len(wanted))
+	for _, entry := range wanted {
+		byRepo[entry.Repo] = append(byRepo[entry.Repo], entry)
+	}
+	repos := slices.Sorted(maps.Keys(byRepo))
+
+	binFolder := config.GetBinFolderPath()
+	stageDir, err := os.MkdirTemp(binFolder, "geofile-")
+	if err != nil {
+		return common.NewErrorf("Failed to create staging folder for Geofiles: %v", err)
+	}
+	defer os.RemoveAll(stageDir)
+
 	client := s.settingService.NewProxiedHTTPClient(0)
 
-	downloadFile := func(url, destPath string) error {
-		var req *http.Request
-		req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
+	var errorMessages []string
+	installed := 0
+	for _, repo := range repos {
+		entries := byRepo[repo]
+		slices.SortFunc(entries, func(a, b geofileEntry) int { return strings.Compare(a.FileName, b.FileName) })
+
+		staged, err := s.stageGeofileRelease(client, entries, binFolder, stageDir)
 		if err != nil {
-			return common.NewErrorf("Failed to create HTTP request for %s: %v", url, err)
+			errorMessages = append(errorMessages, err.Error())
+			continue
 		}
-
-		var localFileModTime time.Time
-		if fileInfo, err := os.Stat(destPath); err == nil {
-			localFileModTime = fileInfo.ModTime()
-			if !localFileModTime.IsZero() {
-				req.Header.Set("If-Modified-Since", localFileModTime.UTC().Format(http.TimeFormat))
+		for _, file := range staged {
+			if err := os.Rename(file.stagePath, file.destPath); err != nil {
+				errorMessages = append(errorMessages, fmt.Sprintf("Failed to install Geofile %s: %v", file.destPath, err))
+				continue
 			}
+			installed++
 		}
+	}
 
-		resp, err := client.Do(req)
-		if err != nil {
-			return common.NewErrorf("Failed to download Geofile from %s: %v", url, err)
+	// Nothing changed, so there is no reason to restart the core and drop every
+	// client connection.
+	if installed > 0 {
+		if err := restartXrayAfterGeofileUpdate(s); err != nil {
+			errorMessages = append(errorMessages, fmt.Sprintf("Updated Geofiles but Failed to start Xray: %v", err))
 		}
-		defer resp.Body.Close()
+	}
 
-		// Parse Last-Modified header from server
-		var serverModTime time.Time
-		serverModTimeStr := resp.Header.Get("Last-Modified")
-		if serverModTimeStr != "" {
-			parsedTime, err := time.Parse(http.TimeFormat, serverModTimeStr)
-			if err != nil {
-				logger.Warningf("Failed to parse Last-Modified header for %s: %v", url, err)
-			} else {
-				serverModTime = parsedTime
-			}
-		}
+	if len(errorMessages) > 0 {
+		return common.NewErrorf("%s", strings.Join(errorMessages, "\r\n"))
+	}
 
-		// Function to update local file's modification time
-		updateFileModTime := func() {
-			if !serverModTime.IsZero() {
-				if err := os.Chtimes(destPath, serverModTime, serverModTime); err != nil {
-					logger.Warningf("Failed to update modification time for %s: %v", destPath, err)
-				}
-			}
-		}
+	return nil
+}
 
-		// Handle 304 Not Modified
-		if resp.StatusCode == http.StatusNotModified {
-			updateFileModTime()
-			return nil
+// stageGeofileRelease downloads one upstream's databases and verifies each
+// against a digest from the same release, staging all of them or none.
+func (s *ServerService) stageGeofileRelease(client *http.Client, entries []geofileEntry, binFolder, stageDir string) ([]stagedGeofile, error) {
+	// Resolve "latest" once. These upstreams publish several times a day, and a
+	// release landing mid-batch would check one release's digest against another's bytes.
+	tag, err := resolveGeofileTag(client, entries[0].latestURL())
+	if err != nil {
+		return nil, common.NewErrorf("Error resolving Geofile release from %s: %v", entries[0].Repo, err)
+	}
+
+	var staged []stagedGeofile
+	for _, entry := range entries {
+		destPath := filepath.Join(binFolder, entry.FileName)
+		stagePath := filepath.Join(stageDir, entry.FileName)
+		changed, err := s.stageGeofile(client, entry, tag, destPath, stagePath)
+		if err != nil {
+			return nil, common.NewErrorf("Error downloading Geofile '%s': %v", entry.FileName, err)
 		}
+		if changed {
+			staged = append(staged, stagedGeofile{destPath: destPath, stagePath: stagePath})
+		}
+	}
+	return staged, nil
+}
+
+// resolveGeofileTag reads the immutable release tag a `latest` download
+// redirects to, so the asset and its digest cannot come from two releases.
+func resolveGeofileTag(client *http.Client, latestURL string) (string, error) {
+	pinned := *client
+	pinned.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
+
+	req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, latestURL, nil)
+	if err != nil {
+		return "", err
+	}
+	resp, err := pinned.Do(req)
+	if err != nil {
+		return "", err
+	}
+	defer resp.Body.Close()
+	_, _ = io.Copy(io.Discard, resp.Body)
 
-		if resp.StatusCode != http.StatusOK {
-			return common.NewErrorf("Failed to download Geofile from %s: received status code %d", url, resp.StatusCode)
+	location := resp.Header.Get("Location")
+	if location == "" {
+		return "", common.NewErrorf("expected a redirect to a tagged release, got HTTP %d", resp.StatusCode)
+	}
+	return geofileTagFromLocation(location)
+}
+
+// geofileTagFromLocation pulls <tag> out of a .../releases/download/<tag>/<asset>
+// redirect target.
+func geofileTagFromLocation(location string) (string, error) {
+	const marker = "/releases/download/"
+	idx := strings.Index(location, marker)
+	if idx < 0 {
+		return "", common.NewErrorf("unexpected release redirect %q", location)
+	}
+	tag, _, found := strings.Cut(location[idx+len(marker):], "/")
+	if !found || tag == "" {
+		return "", common.NewErrorf("unexpected release redirect %q", location)
+	}
+	return tag, nil
+}
+
+// stageGeofile downloads one database into stagePath and checks it against the
+// SHA-256 its upstream publishes. It reports false on 304, staging nothing.
+func (s *ServerService) stageGeofile(client *http.Client, entry geofileEntry, tag, destPath, stagePath string) (bool, error) {
+	assetURL := entry.taggedURL(tag)
+	req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, assetURL, nil)
+	if err != nil {
+		return false, common.NewErrorf("Failed to create HTTP request for %s: %v", assetURL, err)
+	}
+
+	if fileInfo, err := os.Stat(destPath); err == nil {
+		if localFileModTime := fileInfo.ModTime(); !localFileModTime.IsZero() {
+			req.Header.Set("If-Modified-Since", localFileModTime.UTC().Format(http.TimeFormat))
 		}
+	}
 
-		file, err := os.Create(destPath)
+	resp, err := client.Do(req)
+	if err != nil {
+		return false, common.NewErrorf("Failed to download Geofile from %s: %v", assetURL, err)
+	}
+	defer resp.Body.Close()
+
+	// Parse Last-Modified header from server
+	var serverModTime time.Time
+	if serverModTimeStr := resp.Header.Get("Last-Modified"); serverModTimeStr != "" {
+		parsedTime, err := time.Parse(http.TimeFormat, serverModTimeStr)
 		if err != nil {
-			return common.NewErrorf("Failed to create Geofile %s: %v", destPath, err)
+			logger.Warningf("Failed to parse Last-Modified header for %s: %v", assetURL, err)
+		} else {
+			serverModTime = parsedTime
 		}
-		defer file.Close()
+	}
 
-		_, err = io.Copy(file, resp.Body)
-		if err != nil {
-			return common.NewErrorf("Failed to save Geofile %s: %v", destPath, err)
+	// The conditional GET above reads this back, so it must survive the rename.
+	setModTime := func(target string) {
+		if !serverModTime.IsZero() {
+			if err := os.Chtimes(target, serverModTime, serverModTime); err != nil {
+				logger.Warningf("Failed to update modification time for %s: %v", target, err)
+			}
 		}
+	}
 
-		updateFileModTime()
-		return nil
+	// Handle 304 Not Modified
+	if resp.StatusCode == http.StatusNotModified {
+		setModTime(destPath)
+		return false, nil
 	}
 
-	var errorMessages []string
+	if resp.StatusCode != http.StatusOK {
+		return false, common.NewErrorf("Failed to download Geofile from %s: received status code %d", assetURL, resp.StatusCode)
+	}
 
-	if fileName == "" {
-		// Download all geofiles
-		for _, entry := range geofileAllowlist {
-			destPath := filepath.Join(config.GetBinFolderPath(), entry.FileName)
-			if err := downloadFile(entry.URL, destPath); err != nil {
-				errorMessages = append(errorMessages, fmt.Sprintf("Error downloading Geofile '%s': %v", entry.FileName, err))
-			}
-		}
-	} else {
-		entry := geofileAllowlist[fileName]
-		destPath := filepath.Join(config.GetBinFolderPath(), entry.FileName)
-		if err := downloadFile(entry.URL, destPath); err != nil {
-			errorMessages = append(errorMessages, fmt.Sprintf("Error downloading Geofile '%s': %v", entry.FileName, err))
-		}
+	file, err := os.Create(stagePath)
+	if err != nil {
+		return false, common.NewErrorf("Failed to create Geofile %s: %v", stagePath, err)
+	}
+	hasher := sha256.New()
+	if _, err := io.Copy(io.MultiWriter(file, hasher), resp.Body); err != nil {
+		file.Close()
+		return false, common.NewErrorf("Failed to save Geofile %s: %v", stagePath, err)
+	}
+	if err := file.Close(); err != nil {
+		return false, common.NewErrorf("Failed to save Geofile %s: %v", stagePath, err)
 	}
 
-	err := s.RestartXrayService()
+	// TLS protects the transport, not the artifact. Xray parses these databases
+	// when it builds its routing matchers, so a bad one takes the core down.
+	want, err := s.fetchGeofileDigest(client, assetURL+".sha256sum", entry.Asset)
 	if err != nil {
-		errorMessages = append(errorMessages, fmt.Sprintf("Updated Geofile '%s' but Failed to start Xray: %v", fileName, err))
+		return false, err
+	}
+	if got := hex.EncodeToString(hasher.Sum(nil)); !strings.EqualFold(got, want) {
+		return false, common.NewErrorf("does not match the published SHA-256 checksum, so the download is corrupted or has been tampered with (expected %s, got %s)", want, got)
 	}
 
-	if len(errorMessages) > 0 {
-		return common.NewErrorf("%s", strings.Join(errorMessages, "\r\n"))
+	setModTime(stagePath)
+	return true, nil
+}
+
+// fetchGeofileDigest downloads the .sha256sum sidecar published beside a geo
+// database and returns the digest it lists for assetName.
+func (s *ServerService) fetchGeofileDigest(client *http.Client, sumsURL, assetName string) (string, error) {
+	req, reqErr := http.NewRequestWithContext(context.Background(), http.MethodGet, sumsURL, nil)
+	if reqErr != nil {
+		return "", fmt.Errorf("download geofile checksum: %w", reqErr)
+	}
+	resp, err := client.Do(req)
+	if err != nil {
+		return "", fmt.Errorf("download geofile checksum: %w", err)
 	}
+	defer resp.Body.Close()
+	if resp.StatusCode != http.StatusOK {
+		return "", fmt.Errorf("download geofile checksum: unexpected HTTP %d", resp.StatusCode)
+	}
+	raw, err := io.ReadAll(io.LimitReader(resp.Body, maxXrayDigestBytes))
+	if err != nil {
+		return "", fmt.Errorf("download geofile checksum: %w", err)
+	}
+	return parseGeofileDigest(raw, assetName)
+}
 
-	return nil
+// parseGeofileDigest returns the SHA-256 hex a sidecar lists for assetName,
+// matching on base name since upstreams record "geoip.dat" or "release/geoip.dat".
+func parseGeofileDigest(sums []byte, assetName string) (string, error) {
+	for line := range strings.SplitSeq(string(sums), "\n") {
+		fields := strings.Fields(line)
+		if len(fields) != 2 {
+			continue
+		}
+		// A leading "*" is sha256sum's own binary-mode marker, not part of the name.
+		if path.Base(strings.TrimPrefix(fields[1], "*")) != assetName {
+			continue
+		}
+		digest := strings.ToLower(fields[0])
+		if _, err := hex.DecodeString(digest); err != nil || len(digest) != sha256.Size*2 {
+			return "", fmt.Errorf("geofile checksum: malformed SHA-256 entry for %s", assetName)
+		}
+		return digest, nil
+	}
+	return "", fmt.Errorf("geofile checksum: no SHA-256 entry for %s", assetName)
 }
 
 // parseXrayKeyPairOutput reads the two-line "Label: value" output that xray's

+ 394 - 0
internal/web/service/server_geofile_test.go

@@ -0,0 +1,394 @@
+package service
+
+import (
+	"crypto/sha256"
+	"encoding/hex"
+	"fmt"
+	"net/http"
+	"net/http/httptest"
+	"os"
+	"path/filepath"
+	"strings"
+	"sync"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+)
+
+// Loyalsoldier and runetfreedom write "<hash>  geoip.dat"; chocolate4u writes
+// "<hash>  release/geoip.dat", the path from its own build.
+func TestParseGeofileDigest(t *testing.T) {
+	const digest = "0d5d2ba0c5a5c58027fd1347a6afd57c9470799b6bb3cbc274fd4657ed8de382"
+
+	for _, tc := range []struct {
+		name  string
+		sums  string
+		asset string
+		want  string
+	}{
+		{"bare-name", digest + "  geoip.dat\n", "geoip.dat", digest},
+		{"build-path", digest + "  release/geoip.dat\n", "geoip.dat", digest},
+		{"binary-mode-marker", digest + "  *geoip.dat\n", "geoip.dat", digest},
+		{"uppercase-digest", strings.ToUpper(digest) + "  geoip.dat\n", "geoip.dat", digest},
+		{"picks-matching-line", "aaaa  geosite.dat\n" + digest + "  geoip.dat\n", "geoip.dat", digest},
+	} {
+		t.Run(tc.name, func(t *testing.T) {
+			got, err := parseGeofileDigest([]byte(tc.sums), tc.asset)
+			if err != nil {
+				t.Fatalf("parse: %v", err)
+			}
+			if got != tc.want {
+				t.Fatalf("digest = %q, want %q", got, tc.want)
+			}
+		})
+	}
+}
+
+func TestParseGeofileDigest_Errors(t *testing.T) {
+	const digest = "0d5d2ba0c5a5c58027fd1347a6afd57c9470799b6bb3cbc274fd4657ed8de382"
+
+	for _, tc := range []struct {
+		name    string
+		sums    string
+		asset   string
+		wantErr string
+	}{
+		// Accepting this would verify geoip.dat against geosite.dat's digest.
+		{"names-another-asset", digest + "  geosite.dat\n", "geoip.dat", "no SHA-256 entry for geoip.dat"},
+		{"empty", "", "geoip.dat", "no SHA-256 entry for geoip.dat"},
+		{"malformed-short", "deadbeef  geoip.dat\n", "geoip.dat", "malformed SHA-256 entry for geoip.dat"},
+		{"not-hex", strings.Repeat("z", 64) + "  geoip.dat\n", "geoip.dat", "malformed SHA-256 entry for geoip.dat"},
+	} {
+		t.Run(tc.name, func(t *testing.T) {
+			_, err := parseGeofileDigest([]byte(tc.sums), tc.asset)
+			if err == nil {
+				t.Fatalf("%s: expected an error", tc.name)
+			}
+			if !strings.Contains(err.Error(), tc.wantErr) {
+				t.Fatalf("error = %q, want it to contain %q", err, tc.wantErr)
+			}
+		})
+	}
+}
+
+func TestGeofileTagFromLocation(t *testing.T) {
+	got, err := geofileTagFromLocation("https://github.com/o/r/releases/download/202609022346/geoip.dat")
+	if err != nil {
+		t.Fatalf("parse: %v", err)
+	}
+	if got != "202609022346" {
+		t.Fatalf("tag = %q, want 202609022346", got)
+	}
+	for _, bad := range []string{
+		"https://github.com/o/r/releases/latest/download/geoip.dat",
+		"https://github.com/o/r/releases/download/202609022346",
+		"",
+	} {
+		if _, err := geofileTagFromLocation(bad); err == nil {
+			t.Fatalf("expected an error for %q", bad)
+		}
+	}
+}
+
+// fakeUpstream serves one repo's release: a `latest` download redirecting to a
+// tagged asset, the asset itself, and its .sha256sum sidecar.
+type fakeUpstream struct {
+	repo    string
+	assets  map[string]string
+	corrupt map[string]bool
+}
+
+// geofileServer mounts every upstream on one test server, mimicking GitHub's
+// `releases/latest/download` -> `releases/download/<tag>` redirect.
+func geofileServer(t *testing.T, ups []fakeUpstream) (*httptest.Server, *sync.Map) {
+	t.Helper()
+
+	hits := &sync.Map{}
+	mux := http.NewServeMux()
+	for _, up := range ups {
+		for asset, body := range up.assets {
+			tagged := "/" + up.repo + "/releases/download/v1/" + asset
+
+			mux.HandleFunc("/"+up.repo+"/releases/latest/download/"+asset, func(w http.ResponseWriter, r *http.Request) {
+				hits.Store("latest:"+up.repo, true)
+				http.Redirect(w, r, tagged, http.StatusFound)
+			})
+			mux.HandleFunc(tagged, func(w http.ResponseWriter, r *http.Request) {
+				hits.Store("body:"+up.repo+"/"+asset, true)
+				_, _ = w.Write([]byte(body))
+			})
+
+			payload := body
+			if up.corrupt[asset] {
+				payload = body + " tampered"
+			}
+			sum := sha256.Sum256([]byte(payload))
+			line := fmt.Sprintf("%s  %s\n", hex.EncodeToString(sum[:]), asset)
+			mux.HandleFunc(tagged+".sha256sum", func(w http.ResponseWriter, r *http.Request) {
+				_, _ = w.Write([]byte(line))
+			})
+		}
+	}
+
+	srv := httptest.NewServer(mux)
+	t.Cleanup(srv.Close)
+	return srv, hits
+}
+
+// geofileTestEnv points the service at a temp asset folder and a throwaway DB.
+func geofileTestEnv(t *testing.T, entries map[string]geofileEntry) string {
+	t.Helper()
+
+	dbDir := t.TempDir()
+	t.Setenv("XUI_DB_FOLDER", dbDir)
+	if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
+		t.Fatalf("InitDB: %v", err)
+	}
+	t.Cleanup(func() { _ = database.CloseDB() })
+
+	binFolder := t.TempDir()
+	t.Setenv("XUI_BIN_FOLDER", binFolder)
+
+	originalAllowlist := geofileAllowlist
+	geofileAllowlist = entries
+	t.Cleanup(func() { geofileAllowlist = originalAllowlist })
+
+	return binFolder
+}
+
+func restartStub(t *testing.T, called *bool) {
+	t.Helper()
+	original := restartXrayAfterGeofileUpdate
+	restartXrayAfterGeofileUpdate = func(*ServerService) error {
+		*called = true
+		return nil
+	}
+	t.Cleanup(func() { restartXrayAfterGeofileUpdate = original })
+}
+
+func TestUpdateGeofileInstallsVerifiedFile(t *testing.T) {
+	srv, _ := geofileServer(t, []fakeUpstream{{repo: "a", assets: map[string]string{"geoip.dat": "good geoip payload"}}})
+	binFolder := geofileTestEnv(t, map[string]geofileEntry{
+		"geoip.dat": {srv.URL + "/a", "geoip.dat", "geoip.dat"},
+	})
+
+	var restarted bool
+	restartStub(t, &restarted)
+
+	if err := (&ServerService{}).UpdateGeofile(""); err != nil {
+		t.Fatalf("UpdateGeofile: %v", err)
+	}
+
+	got, err := os.ReadFile(filepath.Join(binFolder, "geoip.dat"))
+	if err != nil {
+		t.Fatalf("read installed geofile: %v", err)
+	}
+	if string(got) != "good geoip payload" {
+		t.Fatalf("installed content = %q, want %q", got, "good geoip payload")
+	}
+	if !restarted {
+		t.Fatal("a file was installed, so xray should have been restarted")
+	}
+}
+
+func TestUpdateGeofileRejectsDigestMismatch(t *testing.T) {
+	srv, _ := geofileServer(t, []fakeUpstream{{
+		repo:    "a",
+		assets:  map[string]string{"geoip.dat": "good geoip payload"},
+		corrupt: map[string]bool{"geoip.dat": true},
+	}})
+	binFolder := geofileTestEnv(t, map[string]geofileEntry{
+		"geoip.dat": {srv.URL + "/a", "geoip.dat", "geoip.dat"},
+	})
+
+	var restarted bool
+	restartStub(t, &restarted)
+
+	err := (&ServerService{}).UpdateGeofile("")
+	if err == nil {
+		t.Fatal("expected an error when the download does not match its published digest")
+	}
+	if !strings.Contains(err.Error(), "does not match the published SHA-256 checksum") {
+		t.Fatalf("error = %q, want it to name the checksum mismatch", err)
+	}
+
+	if _, statErr := os.Stat(filepath.Join(binFolder, "geoip.dat")); !os.IsNotExist(statErr) {
+		t.Fatalf("a file failing verification must not be installed (stat: %v)", statErr)
+	}
+	if restarted {
+		t.Fatal("nothing was installed, so xray must not be restarted")
+	}
+}
+
+// Within one upstream the pair installs together. geoip sorts before geosite
+// and is staged first, so a trivially-passing "abort before download" is ruled out.
+func TestUpdateGeofileInstallsNeitherFileOfAFailedUpstream(t *testing.T) {
+	srv, hits := geofileServer(t, []fakeUpstream{{
+		repo:    "a",
+		assets:  map[string]string{"geoip.dat": "good geoip", "geosite.dat": "good geosite"},
+		corrupt: map[string]bool{"geosite.dat": true},
+	}})
+	binFolder := geofileTestEnv(t, map[string]geofileEntry{
+		"geoip.dat":   {srv.URL + "/a", "geoip.dat", "geoip.dat"},
+		"geosite.dat": {srv.URL + "/a", "geosite.dat", "geosite.dat"},
+	})
+
+	var restarted bool
+	restartStub(t, &restarted)
+
+	if err := (&ServerService{}).UpdateGeofile(""); err == nil {
+		t.Fatal("expected an error when one of the databases fails verification")
+	}
+
+	if _, ok := hits.Load("body:a/geoip.dat"); !ok {
+		t.Fatal("geoip.dat was never downloaded, so this run never exercised staging")
+	}
+	for _, name := range []string{"geoip.dat", "geosite.dat"} {
+		if _, statErr := os.Stat(filepath.Join(binFolder, name)); !os.IsNotExist(statErr) {
+			t.Fatalf("%s was installed even though its sibling failed verification", name)
+		}
+	}
+	if restarted {
+		t.Fatal("nothing was installed, so xray must not be restarted")
+	}
+}
+
+// A broken upstream must not discard a healthy one's verified download.
+func TestUpdateGeofileKeepsGoodUpstreamWhenAnotherFails(t *testing.T) {
+	srv, _ := geofileServer(t, []fakeUpstream{
+		{repo: "aaa", assets: map[string]string{"geoip.dat": "healthy payload"}},
+		{
+			repo:    "zzz",
+			assets:  map[string]string{"geoip.dat": "broken payload"},
+			corrupt: map[string]bool{"geoip.dat": true},
+		},
+	})
+	binFolder := geofileTestEnv(t, map[string]geofileEntry{
+		"geoip.dat":    {srv.URL + "/aaa", "geoip.dat", "geoip.dat"},
+		"geoip_RU.dat": {srv.URL + "/zzz", "geoip.dat", "geoip_RU.dat"},
+	})
+
+	var restarted bool
+	restartStub(t, &restarted)
+
+	err := (&ServerService{}).UpdateGeofile("")
+	if err == nil {
+		t.Fatal("expected an error naming the failing upstream")
+	}
+	if !strings.Contains(err.Error(), "geoip_RU.dat") {
+		t.Fatalf("error = %q, want it to name geoip_RU.dat", err)
+	}
+
+	got, readErr := os.ReadFile(filepath.Join(binFolder, "geoip.dat"))
+	if readErr != nil {
+		t.Fatalf("the healthy upstream's file must still be installed: %v", readErr)
+	}
+	if string(got) != "healthy payload" {
+		t.Fatalf("installed content = %q, want %q", got, "healthy payload")
+	}
+	if _, statErr := os.Stat(filepath.Join(binFolder, "geoip_RU.dat")); !os.IsNotExist(statErr) {
+		t.Fatal("the failing upstream's file must not be installed")
+	}
+	if !restarted {
+		t.Fatal("a file was installed, so xray should have been restarted")
+	}
+}
+
+// The upstreams publish several times a day. Once `latest` is resolved, the
+// asset and its digest must both come from that release, not from a newer one.
+func TestUpdateGeofileSurvivesReleaseRotation(t *testing.T) {
+	const oldBody = "release one payload"
+	oldSum := sha256.Sum256([]byte(oldBody))
+	newSum := sha256.Sum256([]byte("release two payload"))
+
+	mux := http.NewServeMux()
+	mux.HandleFunc("/a/releases/latest/download/geoip.dat", func(w http.ResponseWriter, r *http.Request) {
+		http.Redirect(w, r, "/a/releases/download/v1/geoip.dat", http.StatusFound)
+	})
+	mux.HandleFunc("/a/releases/download/v1/geoip.dat", func(w http.ResponseWriter, r *http.Request) {
+		_, _ = w.Write([]byte(oldBody))
+	})
+	mux.HandleFunc("/a/releases/download/v1/geoip.dat.sha256sum", func(w http.ResponseWriter, r *http.Request) {
+		_, _ = w.Write(fmt.Appendf(nil, "%s  geoip.dat\n", hex.EncodeToString(oldSum[:])))
+	})
+	// "latest" has already moved on to v2. Anything still resolving it gets a
+	// digest for bytes we never downloaded.
+	mux.HandleFunc("/a/releases/latest/download/geoip.dat.sha256sum", func(w http.ResponseWriter, r *http.Request) {
+		_, _ = w.Write(fmt.Appendf(nil, "%s  geoip.dat\n", hex.EncodeToString(newSum[:])))
+	})
+	srv := httptest.NewServer(mux)
+	t.Cleanup(srv.Close)
+
+	binFolder := geofileTestEnv(t, map[string]geofileEntry{
+		"geoip.dat": {srv.URL + "/a", "geoip.dat", "geoip.dat"},
+	})
+
+	var restarted bool
+	restartStub(t, &restarted)
+
+	if err := (&ServerService{}).UpdateGeofile(""); err != nil {
+		t.Fatalf("a release landing mid-batch must not look like tampering: %v", err)
+	}
+	if got, err := os.ReadFile(filepath.Join(binFolder, "geoip.dat")); err != nil || string(got) != oldBody {
+		t.Fatalf("installed = %q (err %v), want the pinned release's bytes", got, err)
+	}
+}
+
+func TestUpdateGeofileSkipsRestartWhenNotModified(t *testing.T) {
+	mux := http.NewServeMux()
+	mux.HandleFunc("/a/releases/latest/download/geoip.dat", func(w http.ResponseWriter, r *http.Request) {
+		http.Redirect(w, r, "/a/releases/download/v1/geoip.dat", http.StatusFound)
+	})
+	mux.HandleFunc("/a/releases/download/v1/geoip.dat", func(w http.ResponseWriter, r *http.Request) {
+		if r.Header.Get("If-Modified-Since") == "" {
+			t.Errorf("expected a conditional GET carrying If-Modified-Since")
+		}
+		w.WriteHeader(http.StatusNotModified)
+	})
+	mux.HandleFunc("/a/releases/download/v1/geoip.dat.sha256sum", func(w http.ResponseWriter, r *http.Request) {
+		t.Error("the sidecar must not be fetched when the asset is unchanged")
+	})
+	srv := httptest.NewServer(mux)
+	t.Cleanup(srv.Close)
+
+	binFolder := geofileTestEnv(t, map[string]geofileEntry{
+		"geoip.dat": {srv.URL + "/a", "geoip.dat", "geoip.dat"},
+	})
+
+	existing := filepath.Join(binFolder, "geoip.dat")
+	if err := os.WriteFile(existing, []byte("already current"), 0o644); err != nil {
+		t.Fatalf("seed existing geofile: %v", err)
+	}
+
+	var restarted bool
+	restartStub(t, &restarted)
+
+	if err := (&ServerService{}).UpdateGeofile(""); err != nil {
+		t.Fatalf("UpdateGeofile: %v", err)
+	}
+
+	if restarted {
+		t.Fatal("a 304 from every upstream must not restart xray and drop client connections")
+	}
+	got, err := os.ReadFile(existing)
+	if err != nil {
+		t.Fatalf("read existing geofile: %v", err)
+	}
+	if string(got) != "already current" {
+		t.Fatalf("existing content = %q, want it left alone", got)
+	}
+}
+
+func TestUpdateGeofileRejectsNameOutsideAllowlist(t *testing.T) {
+	geofileTestEnv(t, map[string]geofileEntry{
+		"geoip.dat": {"https://example.invalid", "geoip.dat", "geoip.dat"},
+	})
+
+	err := (&ServerService{}).UpdateGeofile("../../etc/passwd")
+	if err == nil {
+		t.Fatal("expected an error for a name outside the allowlist")
+	}
+	if !strings.Contains(err.Error(), "not in allowlist") {
+		t.Fatalf("error = %q, want it to name the allowlist", err)
+	}
+}

+ 19 - 0
internal/web/service/tgbot/tgbot_send.go

@@ -210,6 +210,10 @@ func (t *Tgbot) editMessageCallbackTgBot(chatId int64, messageID int, inlineKeyb
 		ReplyMarkup: inlineKeyboard,
 	}
 	if _, err := bot.EditMessageReplyMarkup(context.Background(), &params); err != nil {
+		if isTelegramNotModifiedError(err) {
+			logger.Debug("Telegram reply markup unchanged, skipping edit")
+			return
+		}
 		logger.Warning(err)
 	}
 }
@@ -226,10 +230,25 @@ func (t *Tgbot) editMessageTgBot(chatId int64, messageID int, text string, inlin
 		params.ReplyMarkup = inlineKeyboard[0]
 	}
 	if _, err := bot.EditMessageText(context.Background(), &params); err != nil {
+		if isTelegramNotModifiedError(err) {
+			logger.Debug("Telegram message text unchanged, skipping edit")
+			return
+		}
 		logger.Warning(err)
 	}
 }
 
+// Telegram answers a no-op edit with a 400 whose description carries this text;
+// a refresh tap that changed nothing is not an operator-visible failure.
+func isTelegramNotModifiedError(err error) bool {
+	if err == nil {
+		return false
+	}
+	errStr := err.Error()
+	return strings.Contains(errStr, "not modified") ||
+		strings.Contains(errStr, "No fields to modify")
+}
+
 // SendMsgToTgbotDeleteAfter sends a message and deletes it after a specified delay.
 func (t *Tgbot) SendMsgToTgbotDeleteAfter(chatId int64, msg string, delayInSeconds int, replyMarkup ...telego.ReplyMarkup) {
 	// Determine if replyMarkup was passed; otherwise, set it to nil

+ 100 - 0
internal/web/service/tgbot/tgbot_send_test.go

@@ -1,10 +1,110 @@
 package tgbot
 
 import (
+	"encoding/json"
+	"errors"
+	"net/http"
+	"net/http/httptest"
 	"strings"
 	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/logger"
+
+	"github.com/mymmrac/telego"
+	tu "github.com/mymmrac/telego/telegoutil"
 )
 
+func TestIsTelegramNotModifiedError(t *testing.T) {
+	tests := []struct {
+		name string
+		err  error
+		want bool
+	}{
+		{"nil error", nil, false},
+		{"not modified", errors.New("Bad Request: message is not modified"), true},
+		{"No fields to modify", errors.New("Bad Request: No fields to modify"), true},
+		{"unrelated error", errors.New("Bad Request: message to edit not found"), false},
+		{"network error", errors.New("connection reset"), false},
+		{"empty string", errors.New(""), false},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got := isTelegramNotModifiedError(tt.err)
+			if got != tt.want {
+				t.Errorf("isTelegramNotModifiedError(%v) = %v, want %v", tt.err, got, tt.want)
+			}
+		})
+	}
+}
+
+func TestEditMessageTgBotSkipsNotModified(t *testing.T) {
+	// Mock Telegram API that always returns "message is not modified".
+	mock := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Set("Content-Type", "application/json")
+		json.NewEncoder(w).Encode(map[string]any{
+			"ok":          false,
+			"error_code":  400,
+			"description": "Bad Request: message is not modified: specified new message content and reply markup are exactly the same as a current content and reply markup of the message.",
+		})
+	}))
+	defer mock.Close()
+
+	// Point the package-level bot at the mock.
+	origBot := bot
+	t.Cleanup(func() { bot = origBot })
+	var err error
+	bot, err = telego.NewBot("test-token", telego.WithAPIServer(mock.URL))
+	if err != nil {
+		t.Fatalf("NewBot: %v", err)
+	}
+
+	// Snapshot warning count before the edit call.
+	before := logger.GetLogs(100, "warning")
+
+	tb := &Tgbot{}
+	tb.editMessageTgBot(123, 456, "<b>hello</b>")
+
+	after := logger.GetLogs(100, "warning")
+	if len(after) > len(before) {
+		t.Errorf("editMessageTgBot logged %d new warnings, want 0; new entries: %v",
+			len(after)-len(before), after[len(before):])
+	}
+}
+
+func TestEditMessageCallbackTgBotSkipsNotModified(t *testing.T) {
+	mock := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Set("Content-Type", "application/json")
+		json.NewEncoder(w).Encode(map[string]any{
+			"ok":          false,
+			"error_code":  400,
+			"description": "Bad Request: message is not modified",
+		})
+	}))
+	defer mock.Close()
+
+	origBot := bot
+	t.Cleanup(func() { bot = origBot })
+	var err error
+	bot, err = telego.NewBot("test-token", telego.WithAPIServer(mock.URL))
+	if err != nil {
+		t.Fatalf("NewBot: %v", err)
+	}
+
+	before := logger.GetLogs(100, "warning")
+
+	tb := &Tgbot{}
+	kb := tu.InlineKeyboard(tu.InlineKeyboardRow(
+		tu.InlineKeyboardButton("btn").WithCallbackData("test"),
+	))
+	tb.editMessageCallbackTgBot(123, 456, kb)
+
+	after := logger.GetLogs(100, "warning")
+	if len(after) > len(before) {
+		t.Errorf("editMessageCallbackTgBot logged %d new warnings, want 0; new entries: %v",
+			len(after)-len(before), after[len(before):])
+	}
+}
+
 func TestPageMessageSplitsLinkListWithoutBlankLines(t *testing.T) {
 	var message strings.Builder
 	message.WriteString("Individual links:\r\n")