diff --git a/database/dialect.go b/database/dialect.go index bdf4486c..48aa2de2 100644 --- a/database/dialect.go +++ b/database/dialect.go @@ -22,7 +22,31 @@ 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) } + +// ClientTrafficEnableMergeExpr returns the SQL expression used in the +// node traffic merge to update client_traffics.enable. +// +// The intent is: only allow the remote node to *disable* a client +// (never re-enable one that the central panel has disabled). +// +// We use a dialect-specific expression because: +// - On PostgreSQL we want strict boolean typing and casts to avoid +// "CASE types boolean and integer cannot be matched" errors +// (and similar internal expansions of AND/GREATEST). +// - On SQLite, enable is stored with INTEGER affinity (0/1), there is +// no :: cast syntax, and we must produce a numeric-compatible result. +// +// The expression must be valid SQL for tx.Exec with a boolean parameter +// as the first ?. +func ClientTrafficEnableMergeExpr() string { + if IsPostgres() { + return "CASE WHEN ?::boolean THEN enable::boolean ELSE false END" + } + // SQLite: no :: casts. Use numeric CASE. 1/0 work as true/false + // thanks to SQLite's affinity and how GORM/drivers bind bools. + return "CASE WHEN ? THEN enable ELSE 0 END" +} diff --git a/web/service/inbound.go b/web/service/inbound.go index 0e70cb95..c5c5c8ac 100644 --- a/web/service/inbound.go +++ b/web/service/inbound.go @@ -1841,7 +1841,14 @@ 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 ?" + // + // We use a dialect-aware expression (see database.ClientTrafficEnableMergeExpr) + // because the old "enable AND ?" form (and naive CASE with :: casts) + // caused type mismatches on PostgreSQL after public API inbound updates + // (which go through updateClientTraffics + SyncInbound and can touch + // client_traffics rows) and would also break on SQLite due to PG-only + // ::boolean syntax. + enableExpr := database.ClientTrafficEnableMergeExpr() if err := tx.Exec( fmt.Sprintf( `UPDATE client_traffics @@ -3556,6 +3563,40 @@ 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) can leave the column as integer or with mixed + // interpretation. This (combined with the dialect-aware + // ClientTrafficEnableMergeExpr) prevents type problems in the node traffic + // sync merge (SetRemoteTraffic) and makes the sync robust even when + // inbounds are updated via the public API (incl. ones carrying + // externalProxy in streamSettings). The same expression is also safe on + // SQLite (no PG :: casts). + 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