Skip to content

Commit 2d78897

Browse files
committed
Merge branch 'dev'
2 parents 1d96136 + 9a5bc82 commit 2d78897

File tree

28 files changed

+3324
-159
lines changed

28 files changed

+3324
-159
lines changed

.github/workflows/docker.yml

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,43 @@ env:
1010
IMAGE_NAME: clickdevtech/hysteria-panel
1111

1212
jobs:
13+
build-agent:
14+
runs-on: ubuntu-latest
15+
steps:
16+
- name: Checkout
17+
uses: actions/checkout@v4
18+
19+
- name: Set up Go
20+
uses: actions/setup-go@v5
21+
with:
22+
go-version: '1.22'
23+
cache-dependency-path: cc-agent/go.sum
24+
25+
- name: Build cc-agent
26+
working-directory: cc-agent
27+
run: |
28+
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o cc-agent-linux-amd64 .
29+
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="-s -w" -o cc-agent-linux-arm64 .
30+
31+
- name: Upload agent binaries as artifact
32+
uses: actions/upload-artifact@v4
33+
with:
34+
name: cc-agent-binaries
35+
path: |
36+
cc-agent/cc-agent-linux-amd64
37+
cc-agent/cc-agent-linux-arm64
38+
39+
- name: Publish to GitHub Release (on tag only)
40+
if: startsWith(github.ref, 'refs/tags/')
41+
uses: softprops/action-gh-release@v2
42+
with:
43+
files: |
44+
cc-agent/cc-agent-linux-amd64
45+
cc-agent/cc-agent-linux-arm64
46+
1347
build-and-push:
1448
runs-on: ubuntu-latest
49+
needs: build-agent
1550

1651
steps:
1752
- name: Checkout

.gitignore

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,4 +35,9 @@ Thumbs.db
3535
*.swo
3636

3737
# Docker volumes (local dev)
38-
mongo_data/
38+
mongo_data/
39+
40+
# CC Agent binaries (large files, deploy separately)
41+
public/downloads/
42+
cc-agent/cc-agent-linux-amd64
43+
cc-agent/cc-agent-linux-arm64

cc-agent/api.go

Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"log"
8+
"net/http"
9+
"os/exec"
10+
"strings"
11+
"time"
12+
)
13+
14+
// API holds dependencies for the HTTP handler layer
15+
type API struct {
16+
cfg *Config
17+
userStore *UserStore
18+
statsStore *StatsStore
19+
xrayClient *XrayClient
20+
}
21+
22+
func (a *API) RegisterRoutes(mux *http.ServeMux) {
23+
mux.HandleFunc("GET /health", a.auth(a.handleHealth))
24+
mux.HandleFunc("GET /info", a.auth(a.handleInfo))
25+
mux.HandleFunc("POST /connect", a.auth(a.handleConnect))
26+
mux.HandleFunc("POST /sync", a.auth(a.handleSync))
27+
mux.HandleFunc("POST /users", a.auth(a.handleAddUser))
28+
mux.HandleFunc("DELETE /users/{email}", a.auth(a.handleRemoveUser))
29+
mux.HandleFunc("GET /stats", a.auth(a.handleStats))
30+
mux.HandleFunc("POST /restart", a.auth(a.handleRestart))
31+
}
32+
33+
// auth middleware validates the Bearer token
34+
func (a *API) auth(next http.HandlerFunc) http.HandlerFunc {
35+
return func(w http.ResponseWriter, r *http.Request) {
36+
auth := r.Header.Get("Authorization")
37+
token := strings.TrimPrefix(auth, "Bearer ")
38+
if token == "" || token == auth || token != a.cfg.Token {
39+
http.Error(w, "Unauthorized", http.StatusUnauthorized)
40+
return
41+
}
42+
next(w, r)
43+
}
44+
}
45+
46+
func jsonOK(w http.ResponseWriter, data any) {
47+
w.Header().Set("Content-Type", "application/json")
48+
json.NewEncoder(w).Encode(data)
49+
}
50+
51+
func jsonErr(w http.ResponseWriter, code int, msg string) {
52+
w.Header().Set("Content-Type", "application/json")
53+
w.WriteHeader(code)
54+
json.NewEncoder(w).Encode(map[string]string{"error": msg})
55+
}
56+
57+
// GET /health — simple liveness probe
58+
func (a *API) handleHealth(w http.ResponseWriter, r *http.Request) {
59+
jsonOK(w, map[string]string{"status": "ok"})
60+
}
61+
62+
// GET /info — version, uptime, user count
63+
func (a *API) handleInfo(w http.ResponseWriter, r *http.Request) {
64+
jsonOK(w, map[string]any{
65+
"agent_version": Version,
66+
"xray_version": getXrayVersion(),
67+
"uptime_seconds": int(time.Since(startTime).Seconds()),
68+
"users_count": a.userStore.Count(),
69+
"last_sync": a.userStore.GetLastSync(),
70+
})
71+
}
72+
73+
// POST /connect — handshake; panel calls this to verify connectivity
74+
func (a *API) handleConnect(w http.ResponseWriter, r *http.Request) {
75+
jsonOK(w, map[string]any{
76+
"status": "connected",
77+
"agent_version": Version,
78+
"xray_version": getXrayVersion(),
79+
})
80+
}
81+
82+
// POST /sync — full user sync (replace all users)
83+
func (a *API) handleSync(w http.ResponseWriter, r *http.Request) {
84+
var req struct {
85+
Users []*User `json:"users"`
86+
}
87+
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
88+
jsonErr(w, http.StatusBadRequest, "Bad request: "+err.Error())
89+
return
90+
}
91+
92+
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
93+
defer cancel()
94+
95+
current := a.userStore.List()
96+
currentMap := make(map[string]*User, len(current))
97+
for _, u := range current {
98+
currentMap[u.Email] = u
99+
}
100+
101+
newMap := make(map[string]*User, len(req.Users))
102+
for _, u := range req.Users {
103+
newMap[u.Email] = u
104+
}
105+
106+
added, removed, errors := 0, 0, 0
107+
108+
for email := range currentMap {
109+
if _, exists := newMap[email]; !exists {
110+
if err := a.xrayClient.RemoveUser(ctx, email); err != nil {
111+
log.Printf("[sync] Remove %s: %v", email, err)
112+
errors++
113+
} else {
114+
removed++
115+
}
116+
}
117+
}
118+
119+
for email, u := range newMap {
120+
if _, exists := currentMap[email]; !exists {
121+
if err := a.xrayClient.AddUser(ctx, u); err != nil {
122+
log.Printf("[sync] Add %s: %v", email, err)
123+
errors++
124+
} else {
125+
added++
126+
}
127+
}
128+
}
129+
130+
a.userStore.Sync(req.Users)
131+
go func() {
132+
if err := a.userStore.Save(); err != nil {
133+
log.Printf("[store] Save: %v", err)
134+
}
135+
}()
136+
137+
log.Printf("[sync] Done: +%d -%d errors=%d total=%d", added, removed, errors, len(req.Users))
138+
jsonOK(w, map[string]any{
139+
"added": added,
140+
"removed": removed,
141+
"errors": errors,
142+
"total": len(req.Users),
143+
})
144+
}
145+
146+
// POST /users — add a single user
147+
func (a *API) handleAddUser(w http.ResponseWriter, r *http.Request) {
148+
var u User
149+
if err := json.NewDecoder(r.Body).Decode(&u); err != nil {
150+
jsonErr(w, http.StatusBadRequest, "Bad request: "+err.Error())
151+
return
152+
}
153+
if u.ID == "" || u.Email == "" {
154+
jsonErr(w, http.StatusBadRequest, "id and email are required")
155+
return
156+
}
157+
158+
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
159+
defer cancel()
160+
161+
if err := a.xrayClient.AddUser(ctx, &u); err != nil {
162+
log.Printf("[api] AddUser %s: %v", u.Email, err)
163+
jsonErr(w, http.StatusInternalServerError, err.Error())
164+
return
165+
}
166+
167+
a.userStore.Add(&u)
168+
go func() { _ = a.userStore.Save() }()
169+
170+
jsonOK(w, map[string]string{"status": "ok"})
171+
}
172+
173+
// DELETE /users/{email} — remove a single user
174+
func (a *API) handleRemoveUser(w http.ResponseWriter, r *http.Request) {
175+
email := r.PathValue("email")
176+
if email == "" {
177+
jsonErr(w, http.StatusBadRequest, "email is required")
178+
return
179+
}
180+
181+
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
182+
defer cancel()
183+
184+
if err := a.xrayClient.RemoveUser(ctx, email); err != nil {
185+
// Log but don't fail — user may not exist in Xray (e.g. after restart)
186+
log.Printf("[api] RemoveUser %s: %v", email, err)
187+
}
188+
189+
a.userStore.Remove(email)
190+
go func() { _ = a.userStore.Save() }()
191+
192+
jsonOK(w, map[string]string{"status": "ok"})
193+
}
194+
195+
// GET /stats — collect fresh stats from Xray, return accumulated totals, then reset
196+
func (a *API) handleStats(w http.ResponseWriter, r *http.Request) {
197+
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
198+
defer cancel()
199+
200+
if err := a.statsStore.CollectFromXray(ctx, a.xrayClient); err != nil {
201+
log.Printf("[stats] CollectFromXray: %v", err)
202+
}
203+
204+
raw := a.statsStore.GetAndReset()
205+
206+
result := make(map[string]map[string]int64, len(raw))
207+
for email, t := range raw {
208+
result[email] = map[string]int64{"tx": t.Tx, "rx": t.Rx}
209+
}
210+
211+
jsonOK(w, result)
212+
}
213+
214+
// POST /restart — restart Xray service, then restore all users (Xray loses state on restart)
215+
func (a *API) handleRestart(w http.ResponseWriter, r *http.Request) {
216+
out, err := exec.Command("systemctl", "restart", "xray").CombinedOutput()
217+
if err != nil {
218+
log.Printf("[api] Restart xray error: %v, output: %s", err, out)
219+
jsonErr(w, http.StatusInternalServerError, err.Error())
220+
return
221+
}
222+
223+
// Wait for Xray to fully start before restoring users
224+
time.Sleep(2 * time.Second)
225+
226+
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
227+
defer cancel()
228+
229+
count, err := a.userStore.RestoreToXray(ctx, a.xrayClient)
230+
if err != nil {
231+
log.Printf("[api] RestoreToXray after restart: %v", err)
232+
} else {
233+
log.Printf("[api] Xray restarted + restored %d users", count)
234+
}
235+
236+
jsonOK(w, map[string]string{"status": "ok", "users_restored": fmt.Sprintf("%d", count)})
237+
}
238+
239+
// getXrayVersion reads the installed Xray version by running `xray version`
240+
func getXrayVersion() string {
241+
out, err := exec.Command("xray", "version").Output()
242+
if err != nil {
243+
return "unknown"
244+
}
245+
lines := strings.SplitN(string(out), "\n", 2)
246+
if len(lines) == 0 {
247+
return "unknown"
248+
}
249+
// "Xray 1.8.24 (Xray, Penetrates Everything.) ..."
250+
parts := strings.Fields(lines[0])
251+
if len(parts) >= 2 {
252+
return parts[1]
253+
}
254+
return strings.TrimSpace(lines[0])
255+
}

cc-agent/build.sh

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
#!/bin/bash
2+
# Build cc-agent for Linux AMD64 (typical VPS)
3+
# Run: bash build.sh
4+
set -e
5+
6+
echo "=== Building cc-agent ==="
7+
8+
# Fetch dependencies
9+
go mod tidy
10+
11+
# Build for Linux AMD64
12+
GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o cc-agent-linux-amd64 .
13+
14+
echo "Done: cc-agent-linux-amd64 built ($(du -sh cc-agent-linux-amd64 | cut -f1))"
15+
16+
# Optional: build for Linux ARM64
17+
# GOOS=linux GOARCH=arm64 go build -ldflags="-s -w" -o cc-agent-linux-arm64 .

cc-agent/config.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package main
2+
3+
import (
4+
"encoding/json"
5+
"os"
6+
)
7+
8+
type TLSConfig struct {
9+
Enabled bool `json:"enabled"`
10+
Cert string `json:"cert"`
11+
Key string `json:"key"`
12+
}
13+
14+
type Config struct {
15+
Listen string `json:"listen"`
16+
Token string `json:"token"`
17+
XrayAPI string `json:"xray_api"`
18+
InboundTag string `json:"inbound_tag"`
19+
DataDir string `json:"data_dir"`
20+
TLS TLSConfig `json:"tls"`
21+
}
22+
23+
func LoadConfig(path string) (*Config, error) {
24+
data, err := os.ReadFile(path)
25+
if err != nil {
26+
return nil, err
27+
}
28+
29+
cfg := &Config{
30+
Listen: "0.0.0.0:62080",
31+
XrayAPI: "127.0.0.1:61000",
32+
InboundTag: "vless-in",
33+
DataDir: "/var/lib/cc-agent",
34+
}
35+
36+
if err := json.Unmarshal(data, cfg); err != nil {
37+
return nil, err
38+
}
39+
40+
return cfg, nil
41+
}

0 commit comments

Comments
 (0)