Skip to content

Commit ee27244

Browse files
committed
feat(frp): add runtime log API and stop endpoint
1 parent 2f903c4 commit ee27244

File tree

4 files changed

+394
-0
lines changed

4 files changed

+394
-0
lines changed

internal/bootstrap/frp.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package bootstrap
2+
3+
import (
4+
"github.com/alist-org/alist/v3/internal/conf"
5+
"github.com/alist-org/alist/v3/internal/frp"
6+
"github.com/alist-org/alist/v3/internal/setting"
7+
"github.com/alist-org/alist/v3/pkg/utils"
8+
)
9+
10+
func InitFRP() {
11+
frp.Instance = frp.Init()
12+
if setting.GetBool(conf.FRPEnabled) {
13+
if err := frp.Instance.Start(); err != nil {
14+
utils.Log.Warnf("failed to start frp client: %v", err)
15+
} else {
16+
utils.Log.Info("frp client started")
17+
}
18+
}
19+
}

internal/frp/frp.go

Lines changed: 335 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,335 @@
1+
package frp
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"fmt"
7+
"io"
8+
"os"
9+
"path/filepath"
10+
"strings"
11+
"sync"
12+
"time"
13+
14+
"github.com/alist-org/alist/v3/cmd/flags"
15+
frpclient "github.com/fatedier/frp/client"
16+
"github.com/fatedier/frp/pkg/config/source"
17+
v1 "github.com/fatedier/frp/pkg/config/v1"
18+
frplog "github.com/fatedier/frp/pkg/util/log"
19+
log "github.com/sirupsen/logrus"
20+
21+
"github.com/alist-org/alist/v3/internal/conf"
22+
"github.com/alist-org/alist/v3/internal/setting"
23+
)
24+
25+
// Instance is the global FRP manager.
26+
var Instance *Manager
27+
28+
// Manager controls the lifecycle of the embedded FRP client.
29+
type Manager struct {
30+
mu sync.Mutex
31+
cancel context.CancelFunc
32+
wg sync.WaitGroup
33+
status string
34+
logs []string
35+
logPath string
36+
}
37+
38+
const maxLogEntries = 300
39+
const maxTailBytes = 512 * 1024
40+
41+
// RuntimeInfo contains FRP runtime status and recent logs.
42+
type RuntimeInfo struct {
43+
Status string `json:"status"`
44+
Logs []string `json:"logs"`
45+
}
46+
47+
// Init creates and returns a new Manager.
48+
func Init() *Manager {
49+
m := &Manager{
50+
status: "stopped",
51+
logPath: filepath.Join(flags.DataDir, "log", "frp.log"),
52+
}
53+
m.logs = append(m.logs, fmt.Sprintf("[%s] initialized", time.Now().Format(time.RFC3339)))
54+
return m
55+
}
56+
57+
// Start builds the FRP config from settings and starts the client.
58+
func (m *Manager) Start() error {
59+
m.mu.Lock()
60+
defer m.mu.Unlock()
61+
62+
if m.cancel != nil {
63+
m.appendLogLocked("start skipped: already running")
64+
return nil // already running
65+
}
66+
67+
cfg, proxyCfgs, err := buildConfig()
68+
if err != nil {
69+
m.status = "error: " + err.Error()
70+
m.appendLogLocked("start failed: %s", err.Error())
71+
return err
72+
}
73+
cfg.Log.To = m.logPath
74+
cfg.Log.Level = "info"
75+
cfg.Log.MaxDays = 7
76+
if err := os.MkdirAll(filepath.Dir(m.logPath), 0o755); err != nil {
77+
m.status = "error: " + err.Error()
78+
m.appendLogLocked("init log dir failed: %s", err.Error())
79+
return err
80+
}
81+
frplog.InitLogger(cfg.Log.To, cfg.Log.Level, int(cfg.Log.MaxDays), true)
82+
83+
configSource := source.NewConfigSource()
84+
if err := configSource.ReplaceAll(proxyCfgs, nil); err != nil {
85+
m.status = "error: " + err.Error()
86+
m.appendLogLocked("replace config failed: %s", err.Error())
87+
return err
88+
}
89+
aggregator := source.NewAggregator(configSource)
90+
91+
svr, err := frpclient.NewService(frpclient.ServiceOptions{
92+
Common: cfg,
93+
ConfigSourceAggregator: aggregator,
94+
})
95+
if err != nil {
96+
m.status = "error: " + err.Error()
97+
m.appendLogLocked("create service failed: %s", err.Error())
98+
return err
99+
}
100+
101+
ctx, cancel := context.WithCancel(context.Background())
102+
m.cancel = cancel
103+
m.status = "running"
104+
m.appendLogLocked("service started")
105+
m.wg.Add(1)
106+
107+
go func() {
108+
defer m.wg.Done()
109+
if err := svr.Run(ctx); err != nil && ctx.Err() == nil {
110+
// Context was not cancelled, so this is an unexpected error.
111+
log.Warnf("frp client stopped unexpectedly: %v", err)
112+
m.mu.Lock()
113+
m.status = "error: " + err.Error()
114+
m.appendLogLocked("service stopped unexpectedly: %s", err.Error())
115+
m.cancel = nil
116+
m.mu.Unlock()
117+
}
118+
}()
119+
120+
return nil
121+
}
122+
123+
// Stop gracefully shuts down the FRP client.
124+
func (m *Manager) Stop() {
125+
m.mu.Lock()
126+
cancel := m.cancel
127+
m.cancel = nil
128+
m.mu.Unlock()
129+
130+
if cancel != nil {
131+
m.appendLog("stopping service")
132+
cancel()
133+
m.wg.Wait()
134+
}
135+
136+
m.mu.Lock()
137+
m.status = "stopped"
138+
m.appendLogLocked("service stopped")
139+
m.mu.Unlock()
140+
}
141+
142+
// Restart stops any running client and starts a fresh one with current settings.
143+
func (m *Manager) Restart() error {
144+
m.appendLog("restarting service")
145+
m.Stop()
146+
return m.Start()
147+
}
148+
149+
// Status returns the current status string: "running", "stopped", or "error: <msg>".
150+
func (m *Manager) Status() string {
151+
m.mu.Lock()
152+
defer m.mu.Unlock()
153+
return m.status
154+
}
155+
156+
// Runtime returns status and latest logs.
157+
func (m *Manager) Runtime(limit int) RuntimeInfo {
158+
m.mu.Lock()
159+
status := m.status
160+
logPath := m.logPath
161+
m.mu.Unlock()
162+
163+
logs, err := readLogTail(logPath, limit)
164+
if err != nil {
165+
m.mu.Lock()
166+
logs = m.copyLogsLocked(limit)
167+
m.mu.Unlock()
168+
}
169+
170+
return RuntimeInfo{
171+
Status: status,
172+
Logs: logs,
173+
}
174+
}
175+
176+
func (m *Manager) appendLog(format string, args ...interface{}) {
177+
m.mu.Lock()
178+
defer m.mu.Unlock()
179+
m.appendLogLocked(format, args...)
180+
}
181+
182+
func (m *Manager) appendLogLocked(format string, args ...interface{}) {
183+
line := fmt.Sprintf(format, args...)
184+
entry := fmt.Sprintf("[%s] %s", time.Now().Format(time.RFC3339), line)
185+
m.logs = append(m.logs, entry)
186+
if len(m.logs) > maxLogEntries {
187+
m.logs = m.logs[len(m.logs)-maxLogEntries:]
188+
}
189+
}
190+
191+
func (m *Manager) copyLogsLocked(limit int) []string {
192+
if limit <= 0 || limit > maxLogEntries {
193+
limit = maxLogEntries
194+
}
195+
total := len(m.logs)
196+
if total <= limit {
197+
return append([]string(nil), m.logs...)
198+
}
199+
return append([]string(nil), m.logs[total-limit:]...)
200+
}
201+
202+
func readLogTail(path string, limit int) ([]string, error) {
203+
if limit <= 0 || limit > maxLogEntries {
204+
limit = maxLogEntries
205+
}
206+
f, err := os.Open(path)
207+
if err != nil {
208+
return nil, err
209+
}
210+
defer f.Close()
211+
212+
info, err := f.Stat()
213+
if err != nil {
214+
return nil, err
215+
}
216+
217+
size := info.Size()
218+
start := int64(0)
219+
if size > maxTailBytes {
220+
start = size - maxTailBytes
221+
}
222+
if _, err = f.Seek(start, io.SeekStart); err != nil {
223+
return nil, err
224+
}
225+
buf, err := io.ReadAll(f)
226+
if err != nil {
227+
return nil, err
228+
}
229+
230+
if start > 0 {
231+
if idx := bytes.IndexByte(buf, '\n'); idx >= 0 && idx+1 < len(buf) {
232+
buf = buf[idx+1:]
233+
}
234+
}
235+
text := strings.TrimRight(string(buf), "\n")
236+
if text == "" {
237+
return []string{}, nil
238+
}
239+
lines := strings.Split(text, "\n")
240+
if len(lines) <= limit {
241+
return lines, nil
242+
}
243+
return lines[len(lines)-limit:], nil
244+
}
245+
246+
func buildConfig() (*v1.ClientCommonConfig, []v1.ProxyConfigurer, error) {
247+
serverAddr := setting.GetStr(conf.FRPServerAddr)
248+
if serverAddr == "" {
249+
return nil, nil, fmt.Errorf("frp server address is required")
250+
}
251+
252+
serverPort := setting.GetInt(conf.FRPServerPort, 7000)
253+
authToken := setting.GetStr(conf.FRPAuthToken)
254+
proxyName := setting.GetStr(conf.FRPProxyName, "alist")
255+
proxyType := setting.GetStr(conf.FRPProxyType, "http")
256+
customDomain := setting.GetStr(conf.FRPCustomDomain)
257+
subdomain := setting.GetStr(conf.FRPSubdomain)
258+
remotePort := setting.GetInt(conf.FRPRemotePort, 0)
259+
localPort := setting.GetInt(conf.FRPLocalPort, 5244)
260+
tlsEnable := setting.GetBool(conf.FRPTLSEnable)
261+
stcpSecretKey := setting.GetStr(conf.FRPSTCPSecretKey)
262+
263+
cfg := &v1.ClientCommonConfig{
264+
ServerAddr: serverAddr,
265+
ServerPort: serverPort,
266+
Auth: v1.AuthClientConfig{
267+
Method: v1.AuthMethodToken,
268+
Token: authToken,
269+
},
270+
}
271+
if tlsEnable {
272+
enabled := true
273+
cfg.Transport.TLS.Enable = &enabled
274+
}
275+
276+
backend := v1.ProxyBackend{
277+
LocalIP: "127.0.0.1",
278+
LocalPort: localPort,
279+
}
280+
281+
var proxyCfgs []v1.ProxyConfigurer
282+
283+
switch proxyType {
284+
case "http":
285+
p := &v1.HTTPProxyConfig{}
286+
p.Name = proxyName
287+
p.Type = "http"
288+
p.ProxyBackend = backend
289+
if customDomain != "" {
290+
p.CustomDomains = []string{customDomain}
291+
}
292+
if subdomain != "" {
293+
p.SubDomain = subdomain
294+
}
295+
proxyCfgs = append(proxyCfgs, p)
296+
297+
case "https":
298+
p := &v1.HTTPSProxyConfig{}
299+
p.Name = proxyName
300+
p.Type = "https"
301+
p.ProxyBackend = backend
302+
if customDomain != "" {
303+
p.CustomDomains = []string{customDomain}
304+
}
305+
if subdomain != "" {
306+
p.SubDomain = subdomain
307+
}
308+
proxyCfgs = append(proxyCfgs, p)
309+
310+
case "tcp":
311+
if remotePort <= 0 {
312+
return nil, nil, fmt.Errorf("remote_port is required for tcp proxy type")
313+
}
314+
p := &v1.TCPProxyConfig{}
315+
p.Name = proxyName
316+
p.Type = "tcp"
317+
p.ProxyBackend = backend
318+
p.RemotePort = remotePort
319+
proxyCfgs = append(proxyCfgs, p)
320+
321+
case "stcp":
322+
p := &v1.STCPProxyConfig{}
323+
p.Name = proxyName
324+
p.Type = "stcp"
325+
p.ProxyBackend = backend
326+
p.Secretkey = stcpSecretKey
327+
p.AllowUsers = []string{"*"}
328+
proxyCfgs = append(proxyCfgs, p)
329+
330+
default:
331+
return nil, nil, fmt.Errorf("unsupported proxy type: %s", proxyType)
332+
}
333+
334+
return cfg, proxyCfgs, nil
335+
}

0 commit comments

Comments
 (0)