fix(postgres): make node traffic sync robust after public API inbound updates

The background NodeTrafficSyncJob (every 5s) started failing after a
successful POST /panel/api/inbounds/update/{id} (including flows that
inject streamSettings.externalProxy) with:

  node traffic sync: merge for <node> failed:
  ERROR: CASE types boolean and integer cannot be matched (SQLSTATE 42804)

Root cause:
- The merge lives in setRemoteTrafficLocked (called from SetRemoteTraffic).
- The client_traffics delta path used a dialect-sensitive expression:
    enable = enable AND ?
    last_online = GREATEST(last_online, ?)
- On PostgreSQL, GREATEST / AND / COALESCE are implemented with internal
  CASE expressions. When "enable" columns (client_traffics, inbounds, ...)
  were INTEGER (common after SQLite → PG data migrations, older
  AutoMigrate, or mixed write paths) and the right-hand side was a
  boolean parameter (from snapshot ClientStats or form-bound API payload),
  PG rejected the expression at plan time.
- The public API update path (unlike the internal remote wire path)
  always runs updateClientTraffics + UpdateClientStat + SyncInbound.
  This touches client_traffics.enable rows for any inbound that has
  clients.
- SQLite tolerated 0/1 numeric bools; PG is strict.

Fix:
- Use an explicit CASE with ::boolean casts in the critical enable
  expression so the result type is always boolean.
- Make GreatestExpr emit safe casts on Postgres.
- Add a one-time normalization step in MigrationRequirements (runs on
  startup + xray restarts) that forces the relevant enable/enabled
  columns to boolean on Postgres using an idempotent DO block + USING
  cast. This cleans up pre-existing skew without a full re-migration.

This branch is based on upstream/main (original mhsanaei/3x-ui main).

The node traffic sync now survives arbitrary public-API inbound
updates on PostgreSQL.
This commit is contained in:
Rqzbeh
2026-06-07 08:17:13 +03:30
parent 483952cfa0
commit 91643f6888
2 changed files with 39 additions and 2 deletions

View File

@@ -22,7 +22,7 @@ func JSONFieldText(expr, key string) string {
func GreatestExpr(a, b string) string {
if IsPostgres() {
return fmt.Sprintf("GREATEST(%s, %s)", a, b)
return fmt.Sprintf("GREATEST(%s::bigint, %s::bigint)", a, b)
}
return fmt.Sprintf("MAX(%s, %s)", a, b)
}

View File

@@ -1809,7 +1809,11 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
// from the node arriving after a central disable would otherwise
// overwrite enable=false back to true, letting the client accumulate
// far more traffic than their limit before being disabled again.
enableExpr := "enable AND ?"
// Use explicit CASE + boolean cast so the expression type is consistent
// on PostgreSQL (GREATEST/AND/CASE can surface "types boolean and integer"
// mismatches) even if the row was last touched via the public API update
// path or externalProxy-bearing inbounds.
enableExpr := "CASE WHEN ?::boolean THEN enable::boolean ELSE false END"
if err := tx.Exec(
fmt.Sprintf(
`UPDATE client_traffics
@@ -3524,6 +3528,39 @@ func (s *InboundService) MigrationRequirements() {
}
}
// Normalize "enable" columns to boolean on Postgres. Legacy SQLite data
// (0/1 integers), partial migrations, or mixed write paths (public API
// inbound updates that flow through UpdateClientStat + client syncs, plus
// node traffic merge deltas using enable AND/CASE) can leave the column as
// integer or with mixed interpretation. This prevents "CASE types boolean
// and integer cannot be matched" (internal for GREATEST/COALESCE/AND) in
// the node traffic sync merge (SetRemoteTraffic) and makes the sync robust
// even when inbounds are updated via /panel/api/inbounds/update (incl. ones
// carrying externalProxy in streamSettings).
if database.IsPostgres() {
// Use DO block so it is idempotent and doesn't fail if already boolean.
normalizeBool := func(table, col string) {
_, _ = tx.Exec(fmt.Sprintf(`
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = '%s' AND column_name = '%s'
AND data_type <> 'boolean'
) THEN
ALTER TABLE %s ALTER COLUMN %s
TYPE boolean USING (CASE WHEN %s::text IN ('1','true','t','yes') THEN true ELSE false END);
END IF;
END $$;`, table, col, table, col, col))
}
normalizeBool("inbounds", "enable")
normalizeBool("client_traffics", "enable")
normalizeBool("nodes", "enable")
normalizeBool("clients", "enable")
normalizeBool("api_tokens", "enabled")
normalizeBool("outbound_subscriptions", "enabled")
}
// Fix inbounds based problems
var inbounds []*model.Inbound
err = tx.Model(model.Inbound{}).Where("protocol IN (?)", []string{"vmess", "vless", "trojan", "shadowsocks", "hysteria"}).Find(&inbounds).Error