1
0

5 کامیت‌ها 0054e671f8 ... 05eb06f333

نویسنده SHA1 پیام تاریخ
  MHSanaei 05eb06f333 fix(tuic): wait for both traffic counters in the relay E2E tests 9 ساعت پیش
  MHSanaei 3cd4bf504c v3.9.0 9 ساعت پیش
  MHSanaei ede275e4dc fix(server): apply the outbound address policy to remote cert pinning 10 ساعت پیش
  MHSanaei d31465e37b fix(database): keep the dump restore inside its own database file 10 ساعت پیش
  MHSanaei 7d232a76c9 style(sponsor): stack the banner's sponsor tag above Visit 10 ساعت پیش

+ 4 - 0
docs/public/openapi.json

@@ -7797,6 +7797,10 @@
                   "server": {
                     "type": "string",
                     "description": "Remote server as domain or domain:port (default port 443), e.g. cloudflare-dns.com."
+                  },
+                  "allowPrivate": {
+                    "type": "boolean",
+                    "description": "Ping a private/internal/loopback server (LAN, Docker service name). Default false (SSRF guard blocks it and the error response sets obj.privateTarget=true)."
                   }
                 },
                 "required": [

+ 4 - 0
frontend/public/openapi.json

@@ -7797,6 +7797,10 @@
                   "server": {
                     "type": "string",
                     "description": "Remote server as domain or domain:port (default port 443), e.g. cloudflare-dns.com."
+                  },
+                  "allowPrivate": {
+                    "type": "boolean",
+                    "description": "Ping a private/internal/loopback server (LAN, Docker service name). Default false (SSRF guard blocks it and the error response sets obj.privateTarget=true)."
                   }
                 },
                 "required": [

+ 11 - 1
frontend/src/components/sponsor/SponsorCard.css

@@ -95,6 +95,14 @@
   white-space: nowrap;
 }
 
+.sponsor-aside {
+  display: flex;
+  flex: 0 0 auto;
+  flex-direction: column;
+  align-items: flex-end;
+  gap: 6px;
+}
+
 .sponsor-close {
   flex: 0 0 auto;
   align-self: flex-start;
@@ -197,8 +205,10 @@
     padding: 10px 12px;
   }
 
-  .sponsor-card-banner .sponsor-visit {
+  .sponsor-card-banner .sponsor-aside {
     flex-basis: 100%;
+    flex-direction: row;
+    align-items: center;
     padding-inline-start: 52px;
   }
 }

+ 10 - 2
frontend/src/components/sponsor/SponsorCard.tsx

@@ -74,12 +74,20 @@ export default function SponsorCard({
         <SponsorLogo sponsor={sponsor} />
         <span className="sponsor-body" dir="auto">
           <span className="sponsor-head">
-            <span className="sponsor-tag">{tag}</span>
+            {variant !== 'banner' && <span className="sponsor-tag">{tag}</span>}
             <span className="sponsor-title">{title}</span>
           </span>
           {text && <span className="sponsor-text">{text}</span>}
         </span>
-        {variant !== 'compact' && (
+        {variant === 'banner' && (
+          <span className="sponsor-aside">
+            <span className="sponsor-tag">{tag}</span>
+            <span className="sponsor-visit">
+              {t('pages.sponsors.visit')} <ExportOutlined />
+            </span>
+          </span>
+        )}
+        {variant === 'card' && (
           <span className="sponsor-visit">
             {t('pages.sponsors.visit')} <ExportOutlined />
           </span>

+ 7 - 0
frontend/src/pages/api-docs/endpoints.ts

@@ -876,6 +876,13 @@ export const sections: readonly Section[] = [
             type: 'string',
             desc: 'Remote server as domain or domain:port (default port 443), e.g. cloudflare-dns.com.',
           },
+          {
+            name: 'allowPrivate',
+            in: 'body (form)',
+            type: 'boolean',
+            optional: true,
+            desc: 'Ping a private/internal/loopback server (LAN, Docker service name). Default false (SSRF guard blocks it and the error response sets obj.privateTarget=true).',
+          },
         ],
         body: 'server=cloudflare-dns.com',
         response: '{\n  "success": true,\n  "obj": [\n    "e8e2d3..."\n  ]\n}',

+ 1 - 1
frontend/src/pages/inbounds/form/security/tls.tsx

@@ -398,7 +398,7 @@ export default function TlsForm({
           />
           <Button
             icon={<CloudDownloadOutlined />}
-            onClick={pinFromRemote}
+            onClick={() => pinFromRemote()}
             loading={saving}
             title={t('pages.inbounds.form.pinFromRemote')}
           />

+ 18 - 2
frontend/src/pages/inbounds/form/useSecurityActions.ts

@@ -251,7 +251,7 @@ export function useSecurityActions({
    * remote certificate hash via `xray tls ping`. Useful when the panel doesn't
    * hold the cert file (a CDN front / external endpoint).
    */
-  const pinFromRemote = async () => {
+  const pinFromRemote = async (allowPrivate = false) => {
     const server = (
       (getValues('streamSettings.tlsSettings.serverName') as string | undefined) ?? ''
     ).trim();
@@ -268,7 +268,23 @@ export function useSecurityActions({
     const target = /:\d+$/.test(server) || !port ? server : `${server}:${port}`;
     setSaving(true);
     try {
-      const msg = await HttpUtil.post('/panel/api/server/getRemoteCertHash', { server: target });
+      const msg = await HttpUtil.post(
+        '/panel/api/server/getRemoteCertHash',
+        { server: target, allowPrivate },
+        { silent: true },
+      );
+      // The SSRF guard refuses a LAN/loopback endpoint until the operator confirms it.
+      const blocked = (msg?.obj as { privateTarget?: boolean } | null | undefined)?.privateTarget;
+      if (!msg?.success && blocked && !allowPrivate) {
+        modal.confirm({
+          title: t('pages.inbounds.form.scanPrivateConfirmTitle'),
+          content: t('pages.inbounds.form.scanPrivateConfirmContent', { target }),
+          okText: t('confirm'),
+          cancelText: t('cancel'),
+          onOk: () => pinFromRemote(true),
+        });
+        return;
+      }
       if (!msg?.success) {
         messageApi.warning(msg?.msg || t('pages.inbounds.form.pinFromRemoteFailed'));
         return;

+ 1 - 1
internal/config/version

@@ -1 +1 @@
-3.8.5
+3.9.0

+ 27 - 2
internal/database/dump_sqlite.go

@@ -9,6 +9,7 @@ import (
 	"strings"
 	"unicode/utf8"
 
+	"github.com/mattn/go-sqlite3"
 	"gorm.io/driver/sqlite"
 	"gorm.io/gorm"
 	"gorm.io/gorm/logger"
@@ -127,8 +128,7 @@ func RestoreSQLite(dumpPath, dstPath string) error {
 		return err
 	}
 
-	// mattn/go-sqlite3 executes every statement in a multi-statement string.
-	if _, err := sqlDB.ExecContext(context.Background(), string(script)); err != nil {
+	if err := replayDump(sqlDB, string(script)); err != nil {
 		sqlDB.Close()
 		os.Remove(dstPath)
 		return fmt.Errorf("restore failed: %w", err)
@@ -136,6 +136,31 @@ func RestoreSQLite(dumpPath, dstPath string) error {
 	return sqlDB.Close()
 }
 
+// replayDump runs the script on one connection that cannot open a second database
+// file: ATTACH and VACUUM INTO both attach, and a dump only rebuilds its own tables.
+func replayDump(sqlDB *sql.DB, script string) error {
+	ctx := context.Background()
+	conn, err := sqlDB.Conn(ctx)
+	if err != nil {
+		return err
+	}
+	defer conn.Close()
+	err = conn.Raw(func(driverConn any) error {
+		sc, ok := driverConn.(*sqlite3.SQLiteConn)
+		if !ok {
+			return fmt.Errorf("unexpected sqlite driver connection %T", driverConn)
+		}
+		sc.SetLimit(sqlite3.SQLITE_LIMIT_ATTACHED, 0)
+		return nil
+	})
+	if err != nil {
+		return err
+	}
+	// mattn/go-sqlite3 executes every statement in a multi-statement string.
+	_, err = conn.ExecContext(ctx, script)
+	return err
+}
+
 // dumpTableData appends one INSERT statement per row of table to b.
 func dumpTableData(db *sql.DB, table string, b *strings.Builder) error {
 	rows, err := db.QueryContext(context.Background(), `SELECT * FROM "`+table+`"`)

+ 34 - 0
internal/database/dump_sqlite_test.go

@@ -1,8 +1,10 @@
 package database
 
 import (
+	"fmt"
 	"os"
 	"path/filepath"
+	"strings"
 	"testing"
 
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
@@ -135,3 +137,35 @@ func closeGorm(db *gorm.DB) {
 		s.Close()
 	}
 }
+
+func TestRestoreSQLiteWritesNoFileOutsideDestination(t *testing.T) {
+	cases := []struct {
+		name      string
+		statement string
+	}{
+		{"attach", "ATTACH DATABASE '%s' AS x; CREATE TABLE x.t (a); INSERT INTO x.t VALUES (1);"},
+		{"vacuum into", "CREATE TABLE t (a); VACUUM INTO '%s';"},
+	}
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			dir := t.TempDir()
+			outside := filepath.Join(t.TempDir(), "outside.db")
+			dumpPath := filepath.Join(dir, "in.dump")
+			script := "PRAGMA foreign_keys=OFF;\n" + fmt.Sprintf(tc.statement, outside) + "\n"
+			if err := os.WriteFile(dumpPath, []byte(script), 0o600); err != nil {
+				t.Fatalf("write dump: %v", err)
+			}
+
+			err := RestoreSQLite(dumpPath, filepath.Join(dir, "rebuilt.db"))
+			if err == nil {
+				t.Fatal("RestoreSQLite accepted a dump that writes another database file")
+			}
+			if !strings.Contains(err.Error(), "too many attached databases") {
+				t.Fatalf("restore failed for another reason: %v", err)
+			}
+			if _, statErr := os.Stat(outside); !os.IsNotExist(statErr) {
+				t.Fatalf("restore created %s (stat err %v)", outside, statErr)
+			}
+		})
+	}
+}

+ 25 - 13
internal/tuic/server_test.go

@@ -196,12 +196,30 @@ func testServerTCPConnectE2E(t *testing.T, controller string) {
 		t.Fatalf("expected active email [email protected], got %v", activeEmails)
 	}
 
-	deltas := server.CollectClientTraffic()
-	if len(deltas) == 0 {
-		t.Fatalf("expected traffic deltas, got none")
-	}
-	if deltas[0].Email != "[email protected]" || deltas[0].Up < int64(len(testMsg)) || deltas[0].Down < int64(len(testMsg)) {
-		t.Fatalf("unexpected traffic deltas: %+v", deltas[0])
+	waitForClientTraffic(t, server, "[email protected]", int64(len(testMsg)))
+}
+
+// waitForClientTraffic accumulates drained deltas because the up and down counters are
+// bumped on different relay goroutines, so the echo can arrive before the upload is counted.
+func waitForClientTraffic(t *testing.T, server *Server, email string, minBytes int64) {
+	t.Helper()
+	var up, down int64
+	deadline := time.Now().Add(4 * time.Second)
+	for {
+		for _, delta := range server.CollectClientTraffic() {
+			if delta.Email != email {
+				t.Fatalf("unexpected traffic delta for %q: %+v", delta.Email, delta)
+			}
+			up += delta.Up
+			down += delta.Down
+		}
+		if up >= minBytes && down >= minBytes {
+			return
+		}
+		if time.Now().After(deadline) {
+			t.Fatalf("traffic for %s = up %d, down %d; want both >= %d", email, up, down, minBytes)
+		}
+		time.Sleep(5 * time.Millisecond)
 	}
 }
 
@@ -356,13 +374,7 @@ func testServerUDPDatagramE2E(t *testing.T, controller string) {
 	}
 
 	// 4. Verify traffic
-	deltas := server.CollectClientTraffic()
-	if len(deltas) == 0 {
-		t.Fatalf("expected traffic deltas, got none")
-	}
-	if deltas[0].Email != "[email protected]" || deltas[0].Up < int64(len(udpMsg)) || deltas[0].Down < int64(len(udpMsg)) {
-		t.Fatalf("unexpected traffic deltas: %+v", deltas[0])
-	}
+	waitForClientTraffic(t, server, "[email protected]", int64(len(udpMsg)))
 }
 
 func TestServerUDPStreamE2E(t *testing.T) {

+ 8 - 1
internal/web/controller/server.go

@@ -1,6 +1,7 @@
 package controller
 
 import (
+	"errors"
 	"fmt"
 	"net/http"
 	"regexp"
@@ -10,6 +11,7 @@ import (
 
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 	"github.com/mhsanaei/3x-ui/v3/internal/logger"
+	"github.com/mhsanaei/3x-ui/v3/internal/util/netsafe"
 	"github.com/mhsanaei/3x-ui/v3/internal/web/entity"
 	"github.com/mhsanaei/3x-ui/v3/internal/web/global"
 	"github.com/mhsanaei/3x-ui/v3/internal/web/service"
@@ -467,7 +469,12 @@ func (a *ServerController) getCertHash(c *gin.Context) {
 // getRemoteCertHash runs `xray tls ping` against the given server and returns
 // its live certificate SHA-256 hash(es) for pinning.
 func (a *ServerController) getRemoteCertHash(c *gin.Context) {
-	hashes, err := a.serverService.GetRemoteCertHash(c.PostForm("server"))
+	allowPrivate := c.PostForm("allowPrivate") == "true"
+	hashes, err := a.serverService.GetRemoteCertHash(c.PostForm("server"), allowPrivate)
+	if errors.Is(err, netsafe.ErrPrivateAddressBlocked) {
+		jsonMsgObj(c, "get remote cert hash", gin.H{"privateTarget": true}, err)
+		return
+	}
 	if err != nil {
 		jsonMsg(c, "get remote cert hash", err)
 		return

+ 7 - 4
internal/web/service/server.go

@@ -39,6 +39,7 @@ import (
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 	"github.com/mhsanaei/3x-ui/v3/internal/logger"
 	"github.com/mhsanaei/3x-ui/v3/internal/util/common"
+	"github.com/mhsanaei/3x-ui/v3/internal/util/netsafe"
 	"github.com/mhsanaei/3x-ui/v3/internal/util/sys"
 	"github.com/mhsanaei/3x-ui/v3/internal/xray"
 
@@ -2757,7 +2758,8 @@ func walkCertFiles(node any, out []string) []string {
 // proxy). A native handshake replaces the old `xray tls ping` subprocess so the
 // real dial/handshake failure (connection refused, timeout, …) surfaces
 // verbatim. `server` may be host or host:port; the port defaults to 443.
-func (s *ServerService) GetRemoteCertHash(server string) ([]string, error) {
+// allowPrivate lifts the SSRF guard for this one probe (the panel's confirmed opt-in).
+func (s *ServerService) GetRemoteCertHash(server string, allowPrivate bool) ([]string, error) {
 	server = strings.TrimSpace(server)
 	if server == "" {
 		return nil, common.NewError("no server provided")
@@ -2768,10 +2770,11 @@ func (s *ServerService) GetRemoteCertHash(server string) ([]string, error) {
 		host, port = h, p
 	}
 
-	dialer := stdnet.Dialer{Timeout: 10 * time.Second}
-	tcpConn, err := dialer.Dial("tcp", stdnet.JoinHostPort(host, port))
+	ctx, cancel := context.WithTimeout(netsafe.ContextWithAllowPrivate(context.Background(), allowPrivate), 10*time.Second)
+	defer cancel()
+	tcpConn, err := netsafe.SSRFGuardedDialContext(ctx, "tcp", stdnet.JoinHostPort(host, port))
 	if err != nil {
-		return nil, common.NewErrorf("failed to dial %s: %s", stdnet.JoinHostPort(host, port), err)
+		return nil, fmt.Errorf("failed to dial %s: %w", stdnet.JoinHostPort(host, port), err)
 	}
 	defer tcpConn.Close()
 	_ = tcpConn.SetDeadline(time.Now().Add(15 * time.Second))

+ 38 - 0
internal/web/service/server_remote_cert_hash_test.go

@@ -0,0 +1,38 @@
+package service
+
+import (
+	"crypto/sha256"
+	"encoding/hex"
+	"errors"
+	"net/http"
+	"net/http/httptest"
+	"strings"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/util/netsafe"
+)
+
+func TestGetRemoteCertHashGuardsPrivateTargets(t *testing.T) {
+	srv := httptest.NewTLSServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
+	defer srv.Close()
+	target := strings.TrimPrefix(srv.URL, "https://")
+	sum := sha256.Sum256(srv.Certificate().Raw)
+	want := hex.EncodeToString(sum[:])
+
+	t.Run("loopback refused without opt-in", func(t *testing.T) {
+		hashes, err := (&ServerService{}).GetRemoteCertHash(target, false)
+		if !errors.Is(err, netsafe.ErrPrivateAddressBlocked) {
+			t.Fatalf("GetRemoteCertHash(%s) = %v, %v; want ErrPrivateAddressBlocked", target, hashes, err)
+		}
+	})
+
+	t.Run("loopback read with opt-in", func(t *testing.T) {
+		hashes, err := (&ServerService{}).GetRemoteCertHash(target, true)
+		if err != nil {
+			t.Fatalf("GetRemoteCertHash(%s, allowPrivate): %v", target, err)
+		}
+		if len(hashes) != 1 || hashes[0] != want {
+			t.Fatalf("hashes = %v, want [%s]", hashes, want)
+		}
+	})
+}