9 Commits

Author SHA1 Message Date
will 5a13468942 fix: download binaries from JP01 :3000
CI / Test (push) Has been cancelled
CI / Build (amd64, linux) (push) Has been cancelled
CI / Build (arm64, linux) (push) Has been cancelled
CI / Build & Push Docker Image (push) Has been cancelled
CI / Release (push) Has been cancelled
2026-10-22 18:36:05 +09:00
will 8722fd2658 fix: track UDP connections in connMap for force-close support
CI / Test (push) Has been cancelled
CI / Build (amd64, linux) (push) Has been cancelled
CI / Build (arm64, linux) (push) Has been cancelled
CI / Build & Push Docker Image (push) Has been cancelled
CI / Release (push) Has been cancelled
connMap type changed from map[string]net.Conn to map[string]io.Closer.
RoutedPacketConnection now registers in connMap + uuidConns, so
CloseByUUID and CloseByID tear down both TCP and UDP.
2026-08-14 11:08:19 +09:00
will 4bab53f20f fix: implement CloseByUUID and move v1 token from URL to POST body
CI / Test (push) Has been cancelled
CI / Build (amd64, linux) (push) Has been cancelled
CI / Build (arm64, linux) (push) Has been cancelled
CI / Build & Push Docker Image (push) Has been cancelled
CI / Release (push) Has been cancelled
CloseByUUID: add UUID→connIDs reverse index so ban-user actually
tears down existing TCP connections immediately instead of no-op.

v1 token: change config/user fetches from GET (token in URL, leaks
to logs) to POST (token in JSON body). Panel routes updated to any().
2026-08-14 10:59:05 +09:00
will 0e1f97ee03 fix(security): remove hardcoded Gitea token from xbctl binary
CI / Test (push) Has been cancelled
CI / Build (amd64, linux) (push) Has been cancelled
CI / Build (arm64, linux) (push) Has been cancelled
CI / Build & Push Docker Image (push) Has been cancelled
CI / Release (push) Has been cancelled
Token now read from credentials.env (GITEA_TOKEN key) at runtime,
with fallback to GITEA_TOKEN environment variable.
2026-08-13 15:32:40 +09:00
will 34125823df fix: use gitea.fp-will.xyz instead of raw IP:3000
CI / Test (push) Has been cancelled
CI / Build (amd64, linux) (push) Has been cancelled
CI / Build (arm64, linux) (push) Has been cancelled
CI / Build & Push Docker Image (push) Has been cancelled
CI / Release (push) Has been cancelled
2026-07-01 18:10:27 +09:00
will 4927383faa fix: compile structured custom_routes with match+action into sing-box format
CI / Test (push) Has been cancelled
CI / Build (amd64, linux) (push) Has been cancelled
CI / Build (arm64, linux) (push) Has been cancelled
CI / Build & Push Docker Image (push) Has been cancelled
CI / Release (push) Has been cancelled
The panel sends structured route rules via custom_routes (not custom_route_rules).
Previously these were passed raw to sing-box, causing unmarshal errors when the
action field is an object. Now detect and compile them through the same path as
custom_route_rules.
2026-06-30 20:34:07 +09:00
will fd26f8de88 docs: add CLAUDE.md with patch history and ops runbook
CI / Test (push) Has been cancelled
CI / Build (amd64, linux) (push) Has been cancelled
CI / Build (arm64, linux) (push) Has been cancelled
CI / Build & Push Docker Image (push) Has been cancelled
CI / Release (push) Has been cancelled
2026-06-24 18:56:35 +09:00
will 1924a56b13 fix: install.sh downloads binary from JP01 Gitea releases
CI / Test (push) Has been cancelled
CI / Build (amd64, linux) (push) Has been cancelled
CI / Build (arm64, linux) (push) Has been cancelled
CI / Build & Push Docker Image (push) Has been cancelled
CI / Release (push) Has been cancelled
2026-06-18 15:34:01 +09:00
will fce60b8ad3 fix: force-close user connections on removal, fix UDP upload/download counters, use self-hosted releases
CI / Test (push) Has been cancelled
CI / Build (amd64, linux) (push) Has been cancelled
CI / Build (arm64, linux) (push) Has been cancelled
CI / Build & Push Docker Image (push) Has been cancelled
CI / Release (push) Has been cancelled
- service.go: call CloseUserConnections() before RemoveUsers() so banned/reset
  users are disconnected immediately instead of waiting for natural timeout
  (upstream issue #32, PR pending 4+ months)

- conntracker.go: swap UnwrapPacketReader/Writer upload/download counters for
  UDP PacketConn path — Reader=upload, Writer=download to match TCP path
  (upstream issue #55)

- xbctl/main.go: change downloadBase to JP01 Gitea so xbctl upgrade pulls
  self-hosted patched releases instead of upstream
2026-06-18 15:14:09 +09:00
7 changed files with 289 additions and 32 deletions
+106
View File
@@ -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
View File
@@ -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
View File
@@ -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
+83 -1
View File
@@ -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":
+53 -16
View File
@@ -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 }
+6 -2
View File
@@ -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)
}
+12
View File
@@ -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 {