Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5a13468942 | |||
| 8722fd2658 | |||
| 4bab53f20f | |||
| 0e1f97ee03 | |||
| 34125823df | |||
| 4927383faa | |||
| fd26f8de88 | |||
| 1924a56b13 | |||
| fce60b8ad3 |
@@ -0,0 +1,106 @@
|
||||
# xboard-node fork 运维手册
|
||||
|
||||
## 仓库说明
|
||||
|
||||
JP01 Gitea 自维护 fork,**不是上游镜像**,独立分支。
|
||||
|
||||
- 本仓库:`http://103.112.1.141:3000/will/xboard-node`(dev 分支)
|
||||
- 上游(仅供 cherry-pick 参考):`https://github.com/cedar2025/Xboard-Node`
|
||||
- 工作目录:`/tmp/xboard-node`(JP01)
|
||||
|
||||
---
|
||||
|
||||
## 已应用补丁
|
||||
|
||||
| 补丁 | 文件 | 说明 | 是否可移除 |
|
||||
|------|------|------|-----------|
|
||||
| #32 | `internal/service/service.go` | ban 用户前调 `CloseUserConnections()`,立即断连 | 待上游合并后移除 |
|
||||
| #55 | `internal/kernel/singbox/conntracker.go` | UDP PacketConn upload/download 计数对调 | 待上游合并后移除 |
|
||||
| 自改 | `cmd/xbctl/main.go` | `downloadBase` 指向 JP01 Gitea releases | 永久保留 |
|
||||
| 自改 | `cmd/xbctl/main.go` | `downloadToken` + `downloadFile` 加 Authorization header,支持 private 仓库下载 | 永久保留 |
|
||||
| 自改 | `install.sh` | binary 下载地址改为 JP01 Gitea releases | 永久保留 |
|
||||
|
||||
### xbctl private 仓库下载(2026-06-24)
|
||||
|
||||
**背景**:Gitea 仓库改为 private 后,`xbctl upgrade` 下载时 HTTP 401。
|
||||
|
||||
**改动位置**:`cmd/xbctl/main.go`
|
||||
|
||||
```go
|
||||
// 新增常量
|
||||
downloadToken = "cf6634b57d2c60c66030dbb29d77c4ed25370e94"
|
||||
|
||||
// downloadFile 改用 NewRequest,加 Authorization header
|
||||
func downloadFile(url, dest string) error {
|
||||
client := &http.Client{Timeout: 120 * time.Second}
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if downloadToken != "" {
|
||||
req.Header.Set("Authorization", "token "+downloadToken)
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
**注意**:token 是只读 `read:repository` 权限,泄露只能读仓库,不能写。
|
||||
|
||||
---
|
||||
|
||||
## 编译命令
|
||||
|
||||
```bash
|
||||
cd /tmp/xboard-node
|
||||
VERSION=$(git describe --tags --always --dirty)
|
||||
|
||||
# xboard-node(必须带这些 tags)
|
||||
CGO_ENABLED=0 /usr/local/go/bin/go build \
|
||||
-ldflags "-s -w -X main.version=${VERSION}" \
|
||||
-tags "with_quic with_utls with_wireguard with_acme with_clash_api" \
|
||||
-o xboard-node-linux-amd64 ./cmd/xboard-node/
|
||||
|
||||
# xbctl
|
||||
CGO_ENABLED=0 /usr/local/go/bin/go build \
|
||||
-ldflags "-s -w" \
|
||||
-o xbctl-linux-amd64 ./cmd/xbctl/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 发布流程
|
||||
|
||||
```bash
|
||||
TOKEN=cf6634b57d2c60c66030dbb29d77c4ed25370e94
|
||||
RELEASE_ID=14 # v1.13-patched
|
||||
|
||||
# 删旧 asset
|
||||
OLD_ID=$(curl -s "http://localhost:3000/api/v1/repos/will/xboard-node/releases/$RELEASE_ID/assets" \
|
||||
-H "Authorization: token $TOKEN" | python3 -c "
|
||||
import sys,json
|
||||
for a in json.load(sys.stdin):
|
||||
if a['name'] == 'xboard-node-linux-amd64': print(a['id'])")
|
||||
curl -s -X DELETE "http://localhost:3000/api/v1/repos/will/xboard-node/releases/$RELEASE_ID/assets/$OLD_ID" \
|
||||
-H "Authorization: token $TOKEN"
|
||||
|
||||
# 上传新 asset
|
||||
curl -s -X POST "http://localhost:3000/api/v1/repos/will/xboard-node/releases/$RELEASE_ID/assets?name=xboard-node-linux-amd64" \
|
||||
-H "Authorization: token $TOKEN" -H "Content-Type: application/octet-stream" \
|
||||
--data-binary @xboard-node-linux-amd64
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 节点部署
|
||||
|
||||
| 节点 | IP | MCP |
|
||||
|------|----|-----|
|
||||
| dmit-tyo | 154.31.113.157 | mcp__dmit-tyo__exec |
|
||||
| us-unlock | 103.152.113.30 | mcp__us-unlock__exec |
|
||||
| vmiss-us01 | 38.47.112.162 | mcp__vmiss-us04__exec |
|
||||
|
||||
节点上执行升级:
|
||||
```bash
|
||||
xbctl upgrade # 自动带 token,直接用
|
||||
```
|
||||
+25
-6
@@ -30,7 +30,7 @@ const (
|
||||
serviceName = "xboard-node.service"
|
||||
serviceFilePath = "/etc/systemd/system/xboard-node.service"
|
||||
defaultInstallRoot = "/etc/xboard-node"
|
||||
downloadBase = "https://github.com/cedar2025/xboard-node/releases"
|
||||
downloadBase = "http://64.204.66.12:3000/will/xboard-node"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -593,15 +593,34 @@ func ensureRoot(cmd string) error {
|
||||
}
|
||||
|
||||
func resolveDownloadURL(artifact, version string) string {
|
||||
if version == "latest" {
|
||||
return downloadBase + "/latest/download/" + artifact
|
||||
// Gitea release download URL: {base}/releases/download/{tag}/{file}
|
||||
return downloadBase + "/releases/download/" + version + "/" + artifact
|
||||
}
|
||||
|
||||
// readDownloadToken returns the Gitea API token for downloading release assets.
|
||||
// Lookup order: GITEA_TOKEN in credentials.env, then GITEA_TOKEN env var.
|
||||
func readDownloadToken() string {
|
||||
if data, err := os.ReadFile(defaultCredentialsPath); err == nil {
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if k, v, ok := strings.Cut(line, "="); ok && k == "GITEA_TOKEN" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
}
|
||||
return downloadBase + "/download/" + version + "/" + artifact
|
||||
return os.Getenv("GITEA_TOKEN")
|
||||
}
|
||||
|
||||
func downloadFile(url, dest string) error {
|
||||
client := &http.Client{Timeout: 120 * time.Second}
|
||||
resp, err := client.Get(url)
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tok := readDownloadToken(); tok != "" {
|
||||
req.Header.Set("Authorization", "token "+tok)
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1157,7 +1176,7 @@ func latestInstanceID(instances []*config.Config) string {
|
||||
func regenerateServiceFile() error {
|
||||
unit := fmt.Sprintf(`[Unit]
|
||||
Description=Xboard Node Backend
|
||||
Documentation=https://github.com/cedar2025/xboard-node
|
||||
Documentation=http://64.204.66.12:3000/will/xboard-node
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
|
||||
+4
-7
@@ -27,7 +27,7 @@ DEFAULT_ACTION="install"
|
||||
DEFAULT_RELEASE_VERSION="latest"
|
||||
DEFAULT_LOG_LEVEL="info"
|
||||
DEFAULT_KERNEL_LOG_LEVEL="warn"
|
||||
DEFAULT_DOWNLOAD_BASE="https://github.com/cedar2025/xboard-node/releases"
|
||||
DEFAULT_DOWNLOAD_BASE="http://64.204.66.12:3000/will/xboard-node/releases/download"
|
||||
|
||||
ACTION="${DEFAULT_ACTION}"
|
||||
MODE=""
|
||||
@@ -482,11 +482,8 @@ select_binary_source() {
|
||||
|
||||
resolve_download_url() {
|
||||
local artifact="$1"
|
||||
if [ "$RELEASE_VERSION" = "latest" ]; then
|
||||
DOWNLOAD_URL="${DEFAULT_DOWNLOAD_BASE}/latest/download/${artifact}"
|
||||
else
|
||||
DOWNLOAD_URL="${DEFAULT_DOWNLOAD_BASE}/download/${RELEASE_VERSION}/${artifact}"
|
||||
fi
|
||||
# Gitea release download URL: {base}/{tag}/{file}
|
||||
DOWNLOAD_URL="${DEFAULT_DOWNLOAD_BASE}/${RELEASE_VERSION}/${artifact}"
|
||||
}
|
||||
|
||||
stage_binary() {
|
||||
@@ -592,7 +589,7 @@ render_service() {
|
||||
cat >"$TMP_DIR/${SERVICE_NAME}" <<EOF_UNIT
|
||||
[Unit]
|
||||
Description=Xboard Node Backend
|
||||
Documentation=https://github.com/cedar2025/xboard-node
|
||||
Documentation=http://64.204.66.12:3000/will/xboard-node
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
|
||||
@@ -155,8 +155,15 @@ func buildRoutes(panelRoutes []model.RouteRule, customRules []model.CustomRouteR
|
||||
rules = append(rules, compileCustomRouteRule(rule)...)
|
||||
}
|
||||
|
||||
// Raw custom routes remain the escape hatch, but no longer outrank structured rules.
|
||||
// Raw custom routes: detect structured format (match+action) and compile them,
|
||||
// otherwise pass through as raw sing-box rules.
|
||||
for _, cr := range custom {
|
||||
if _, hasMatch := cr["match"]; hasMatch {
|
||||
if parsed, ok := tryParseAsCustomRouteRule(cr); ok {
|
||||
rules = append(rules, compileCustomRouteRule(parsed)...)
|
||||
continue
|
||||
}
|
||||
}
|
||||
rules = append(rules, M(cr))
|
||||
}
|
||||
|
||||
@@ -309,6 +316,81 @@ func compileCustomRouteRule(rule model.CustomRouteRule) []M {
|
||||
return compiled
|
||||
}
|
||||
|
||||
|
||||
// tryParseAsCustomRouteRule attempts to convert a raw map (from custom_routes)
|
||||
// into a structured CustomRouteRule. Returns false if the map doesn't match
|
||||
// the structured format.
|
||||
func tryParseAsCustomRouteRule(cr map[string]any) (model.CustomRouteRule, bool) {
|
||||
var rule model.CustomRouteRule
|
||||
|
||||
// Parse name
|
||||
if name, ok := cr["name"].(string); ok {
|
||||
rule.Name = name
|
||||
}
|
||||
if disabled, ok := cr["disabled"].(bool); ok {
|
||||
rule.Disabled = disabled
|
||||
}
|
||||
|
||||
// Parse match
|
||||
matchRaw, ok := cr["match"].(map[string]any)
|
||||
if !ok {
|
||||
return rule, false
|
||||
}
|
||||
rule.Match = parseRouteMatch(matchRaw)
|
||||
|
||||
// Parse action
|
||||
switch action := cr["action"].(type) {
|
||||
case string:
|
||||
rule.Action = model.RouteAction{Type: action}
|
||||
case map[string]any:
|
||||
if t, ok := action["type"].(string); ok {
|
||||
rule.Action.Type = t
|
||||
}
|
||||
if target, ok := action["target"].(string); ok {
|
||||
rule.Action.Target = target
|
||||
}
|
||||
default:
|
||||
return rule, false
|
||||
}
|
||||
|
||||
return rule, true
|
||||
}
|
||||
|
||||
func parseRouteMatch(m map[string]any) model.RouteMatch {
|
||||
var match model.RouteMatch
|
||||
if v, ok := m["domains"].([]any); ok {
|
||||
match.Domains = toStringSlice(v)
|
||||
}
|
||||
if v, ok := m["domain_suffixes"].([]any); ok {
|
||||
match.DomainSuffixes = toStringSlice(v)
|
||||
}
|
||||
if v, ok := m["ip_cidrs"].([]any); ok {
|
||||
match.IPCIDRs = toStringSlice(v)
|
||||
}
|
||||
if v, ok := m["ports"].([]any); ok {
|
||||
match.Ports = toStringSlice(v)
|
||||
}
|
||||
if v, ok := m["networks"].([]any); ok {
|
||||
match.Networks = toStringSlice(v)
|
||||
}
|
||||
if v, ok := m["source_cidrs"].([]any); ok {
|
||||
match.SourceCIDRs = toStringSlice(v)
|
||||
}
|
||||
if v, ok := m["source_ports"].([]any); ok {
|
||||
match.SourcePorts = toStringSlice(v)
|
||||
}
|
||||
return match
|
||||
}
|
||||
|
||||
func toStringSlice(arr []any) []string {
|
||||
result := make([]string, 0, len(arr))
|
||||
for _, v := range arr {
|
||||
if s, ok := v.(string); ok {
|
||||
result = append(result, s)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
func singboxOutboundForAction(action model.RouteAction) string {
|
||||
switch action.Type {
|
||||
case "direct":
|
||||
|
||||
@@ -125,7 +125,8 @@ type ConnTracker struct {
|
||||
usersMu sync.RWMutex
|
||||
users map[int]*userStats // userID → stats
|
||||
uuidMap map[string]int // UUID → userID (for lookup in RoutedConnection)
|
||||
connMap map[string]net.Conn // connID → conn (only for force-close support)
|
||||
connMap map[string]io.Closer // connID → conn (TCP + UDP, for force-close)
|
||||
uuidConns map[string]map[string]struct{} // UUID → set of connIDs (reverse index for CloseByUUID)
|
||||
|
||||
idCounter atomic.Int64
|
||||
|
||||
@@ -146,7 +147,8 @@ func NewConnTracker(_ int) *ConnTracker {
|
||||
return &ConnTracker{
|
||||
users: make(map[int]*userStats),
|
||||
uuidMap: make(map[string]int),
|
||||
connMap: make(map[string]net.Conn),
|
||||
connMap: make(map[string]io.Closer),
|
||||
uuidConns: make(map[string]map[string]struct{}),
|
||||
globalDevices: make(map[int]map[string]bool),
|
||||
}
|
||||
}
|
||||
@@ -239,6 +241,10 @@ func (t *ConnTracker) RoutedConnection(
|
||||
// Store conn reference for force-close support
|
||||
t.usersMu.Lock()
|
||||
t.connMap[connID] = conn
|
||||
if _, ok := t.uuidConns[uuid]; !ok {
|
||||
t.uuidConns[uuid] = make(map[string]struct{})
|
||||
}
|
||||
t.uuidConns[uuid][connID] = struct{}{}
|
||||
t.usersMu.Unlock()
|
||||
|
||||
var lim *rate.Limiter
|
||||
@@ -252,16 +258,14 @@ func (t *ConnTracker) RoutedConnection(
|
||||
us: us,
|
||||
userID: uid,
|
||||
connID: connID,
|
||||
uuid: uuid,
|
||||
sourceIP: sourceIP,
|
||||
limiter: lim,
|
||||
ctx: ctx,
|
||||
}
|
||||
}
|
||||
|
||||
// RoutedPacketConnection wraps UDP with per-user counting (UDP not in connMap).
|
||||
// Note: UDP connections are NOT stored in connMap because connMap is typed as
|
||||
// map[string]net.Conn, but PacketConn is a different interface. Force-close
|
||||
// for UDP connections is handled directly via trackedPacketConn.Close().
|
||||
// RoutedPacketConnection wraps UDP with per-user counting and force-close support.
|
||||
func (t *ConnTracker) RoutedPacketConnection(
|
||||
ctx context.Context, conn N.PacketConn,
|
||||
metadata adapter.InboundContext,
|
||||
@@ -293,6 +297,15 @@ func (t *ConnTracker) RoutedPacketConnection(
|
||||
|
||||
connID := t.nextID()
|
||||
|
||||
// Store for force-close support (same as TCP)
|
||||
t.usersMu.Lock()
|
||||
t.connMap[connID] = conn
|
||||
if _, ok := t.uuidConns[uuid]; !ok {
|
||||
t.uuidConns[uuid] = make(map[string]struct{})
|
||||
}
|
||||
t.uuidConns[uuid][connID] = struct{}{}
|
||||
t.usersMu.Unlock()
|
||||
|
||||
var lim *rate.Limiter
|
||||
if slf := t.speedLimitFunc.Load(); slf != nil {
|
||||
lim = (*slf)(uuid)
|
||||
@@ -304,6 +317,7 @@ func (t *ConnTracker) RoutedPacketConnection(
|
||||
us: us,
|
||||
userID: uid,
|
||||
connID: connID,
|
||||
uuid: uuid,
|
||||
sourceIP: sourceIP,
|
||||
limiter: lim,
|
||||
ctx: ctx,
|
||||
@@ -456,10 +470,24 @@ func (t *ConnTracker) CloseByID(id string) bool {
|
||||
|
||||
// CloseByUUID force-closes ALL connections for a given user UUID.
|
||||
func (t *ConnTracker) CloseByUUID(uuid string) int {
|
||||
// This is a no-op for now — sing-box doesn't expose per-user connection
|
||||
// kill easily. The kernel's RemoveUsers removes the inbound user which
|
||||
// prevents new connections, and existing connections will fail on next I/O.
|
||||
return 0
|
||||
t.usersMu.RLock()
|
||||
ids, ok := t.uuidConns[uuid]
|
||||
if !ok || len(ids) == 0 {
|
||||
t.usersMu.RUnlock()
|
||||
return 0
|
||||
}
|
||||
conns := make([]io.Closer, 0, len(ids))
|
||||
for id := range ids {
|
||||
if c, ok := t.connMap[id]; ok {
|
||||
conns = append(conns, c)
|
||||
}
|
||||
}
|
||||
t.usersMu.RUnlock()
|
||||
|
||||
for _, c := range conns {
|
||||
c.Close()
|
||||
}
|
||||
return len(conns)
|
||||
}
|
||||
|
||||
// ActiveCount returns the total number of active connections.
|
||||
@@ -475,10 +503,16 @@ func (t *ConnTracker) ActiveCount() int {
|
||||
return total
|
||||
}
|
||||
|
||||
// removeConnRef removes the connection reference from connMap on close.
|
||||
func (t *ConnTracker) removeConnRef(connID string) {
|
||||
// removeConnRef removes the connection reference from connMap and uuidConns on close.
|
||||
func (t *ConnTracker) removeConnRef(connID, uuid string) {
|
||||
t.usersMu.Lock()
|
||||
delete(t.connMap, connID)
|
||||
if ids, ok := t.uuidConns[uuid]; ok {
|
||||
delete(ids, connID)
|
||||
if len(ids) == 0 {
|
||||
delete(t.uuidConns, uuid)
|
||||
}
|
||||
}
|
||||
t.usersMu.Unlock()
|
||||
}
|
||||
|
||||
@@ -563,6 +597,7 @@ type trackedConn struct {
|
||||
us *userStats // per-user stats (upload/download atomics + IP tracking)
|
||||
userID int // user ID for device tracking
|
||||
connID string
|
||||
uuid string
|
||||
sourceIP string
|
||||
limiter *rate.Limiter
|
||||
ctx context.Context
|
||||
@@ -636,7 +671,7 @@ func (c *trackedConn) Close() error {
|
||||
if c.us != nil {
|
||||
c.us.removeConn(c.sourceIP)
|
||||
}
|
||||
c.tracker.removeConnRef(c.connID)
|
||||
c.tracker.removeConnRef(c.connID, c.uuid)
|
||||
}
|
||||
return c.Conn.Close()
|
||||
}
|
||||
@@ -692,6 +727,7 @@ type trackedPacketConn struct {
|
||||
us *userStats
|
||||
userID int
|
||||
connID string
|
||||
uuid string
|
||||
sourceIP string
|
||||
limiter *rate.Limiter
|
||||
ctx context.Context
|
||||
@@ -759,7 +795,7 @@ func (c *trackedPacketConn) Close() error {
|
||||
if c.us != nil {
|
||||
c.us.removeConn(c.sourceIP)
|
||||
}
|
||||
c.tracker.removeConnRef(c.connID)
|
||||
c.tracker.removeConnRef(c.connID, c.uuid)
|
||||
}
|
||||
return c.PacketConn.Close()
|
||||
}
|
||||
@@ -791,16 +827,17 @@ func (c *trackedPacketConn) UnwrapPacketReader() (N.PacketReader, []N.CountFunc)
|
||||
if c.us == nil {
|
||||
return c.PacketConn, nil
|
||||
}
|
||||
return c.PacketConn, []N.CountFunc{c.makeCountFunc(&c.us.download)}
|
||||
return c.PacketConn, []N.CountFunc{c.makeCountFunc(&c.us.upload)}
|
||||
}
|
||||
|
||||
func (c *trackedPacketConn) UnwrapPacketWriter() (N.PacketWriter, []N.CountFunc) {
|
||||
if c.us == nil {
|
||||
return c.PacketConn, nil
|
||||
}
|
||||
return c.PacketConn, []N.CountFunc{c.makeCountFunc(&c.us.upload)}
|
||||
return c.PacketConn, []N.CountFunc{c.makeCountFunc(&c.us.download)}
|
||||
}
|
||||
|
||||
func (c *trackedPacketConn) Upstream() any { return c.PacketConn }
|
||||
func (c *trackedPacketConn) ReaderReplaceable() bool { return true }
|
||||
func (c *trackedPacketConn) WriterReplaceable() bool { return true }
|
||||
|
||||
|
||||
@@ -200,6 +200,10 @@ func decodeWeakRaw(input map[string]interface{}, output interface{}) error {
|
||||
|
||||
// configPath returns the API path for config fetching.
|
||||
// Machine mode uses the v2 endpoint; legacy mode uses the v1 UniProxy endpoint.
|
||||
// fetchMethod returns POST for all API calls (token stays in body, not URL).
|
||||
// v2 already used POST; v1 legacy endpoints need panel route update to match.
|
||||
func (c *Client) fetchMethod() string { return "POST" }
|
||||
|
||||
func (c *Client) configPath() string {
|
||||
if c.machineID > 0 {
|
||||
return "/api/v2/server/config"
|
||||
@@ -217,7 +221,7 @@ func (c *Client) userPath() string {
|
||||
|
||||
// GetConfig fetches node configuration. Returns nil if not modified (304).
|
||||
func (c *Client) GetConfig() (*NodeConfig, error) {
|
||||
resp, err := c.doRequest("GET", c.configPath(), nil, c.configETag)
|
||||
resp, err := c.doRequest(c.fetchMethod(), c.configPath(), nil, c.configETag)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get config: %w", err)
|
||||
}
|
||||
@@ -255,7 +259,7 @@ func (c *Client) GetConfig() (*NodeConfig, error) {
|
||||
|
||||
// GetUsers fetches available users. Returns nil if not modified (304).
|
||||
func (c *Client) GetUsers() ([]User, error) {
|
||||
resp, err := c.doRequest("GET", c.userPath(), nil, c.userETag)
|
||||
resp, err := c.doRequest(c.fetchMethod(), c.userPath(), nil, c.userETag)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get users: %w", err)
|
||||
}
|
||||
|
||||
@@ -838,6 +838,18 @@ func (s *Service) applyUserDelta(ctx context.Context, action string, deltaUsers
|
||||
return
|
||||
}
|
||||
|
||||
// Force-close active connections for removed users before revoking
|
||||
// credentials. Without this, existing long-lived connections
|
||||
// (QUIC/TCP) persist until natural timeout — subscription resets
|
||||
// and bans would not take immediate effect.
|
||||
if s.kernel.Capabilities().ForceCloseUser {
|
||||
for _, u := range deltaUsers {
|
||||
if err := s.kernel.CloseUserConnections(ctx, u.UUID); err != nil {
|
||||
nlog.Core().Debug(fmt.Sprintf("CloseUserConnections(%s): %v", u.UUID, err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
prevUsers, prevHash := s.prepareUserState(filtered)
|
||||
removed, err := s.kernel.RemoveUsers(deltaUsers)
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user