main
go 396 lines 12 KB
Raw
1 package agent
2
3 import (
4 "errors"
5 "fmt"
6 "os"
7 "path/filepath"
8 "strings"
9 "unicode"
10
11 "github.com/knadh/koanf/parsers/toml/v2"
12 "github.com/knadh/koanf/providers/file"
13 "github.com/knadh/koanf/v2"
14
15 "github.com/gosuda/portal-tunnel/v2/cmd/portal-tunnel/agent/service"
16 "github.com/gosuda/portal-tunnel/v2/utils"
17 )
18
19 const (
20 DefaultControlAddr = "127.0.0.1:4018"
21 DefaultServiceName = "portal-agent"
22
23 defaultIdentityFilename = "identity.json"
24 defaultTargetAddr = "127.0.0.1:3000"
25 agentPathInvalidChars = `<>:"/\|?*`
26 )
27
28 type Config struct {
29 sourcePath string
30 Agent AgentConfig `koanf:"agent"`
31 Tunnels []TunnelConfig `koanf:"tunnels"`
32 }
33
34 type AgentConfig struct {
35 StateDir string `koanf:"state_dir"`
36 ControlAddr string `koanf:"control_addr"`
37 ServiceName string `koanf:"service_name"`
38 AllowedWallets []string `koanf:"allowed_wallets"`
39 }
40
41 type TunnelConfig struct {
42 ID string `koanf:"id"`
43 Name string `koanf:"name"`
44 TargetAddr string `koanf:"target"`
45 HTTPRoutes []HTTPRouteConfig `koanf:"http_routes"`
46 RelayURLs []string `koanf:"relays"`
47 Discovery *bool `koanf:"discovery"`
48 IdentityPath string `koanf:"identity_path"`
49 IdentityJSON string `koanf:"identity_json"`
50 UDPEnabled bool `koanf:"udp"`
51 UDPAddr string `koanf:"udp_addr"`
52 TCPEnabled bool `koanf:"tcp"`
53 MultiHop []string `koanf:"multi_hop"`
54 MultiHopDepth int `koanf:"multi_hop_depth"`
55 BanMITM *bool `koanf:"ban_mitm"`
56 MaxActiveRelays int `koanf:"max_active_relays"`
57 Description string `koanf:"description"`
58 Tags []string `koanf:"tags"`
59 Owner string `koanf:"owner"`
60 Thumbnail string `koanf:"thumbnail"`
61 Hide bool `koanf:"hide"`
62 X402PayTo string `koanf:"x402_pay_to"`
63 X402Testnet bool `koanf:"x402_testnet"`
64 }
65
66 type HTTPRouteConfig struct {
67 Prefix string `koanf:"prefix"`
68 Upstream string `koanf:"upstream"`
69 Methods []string `koanf:"methods"`
70 Amount string `koanf:"amount"`
71 }
72
73 func LoadExistingConfig(path string) (Config, error) {
74 path = strings.TrimSpace(path)
75 if path == "" {
76 path = service.DefaultConfigPath()
77 }
78 absPath, err := filepath.Abs(path)
79 if err != nil {
80 return Config{}, err
81 }
82 if _, err := os.Stat(absPath); err != nil {
83 if errors.Is(err, os.ErrNotExist) {
84 return Config{}, fmt.Errorf("agent config %q does not exist", absPath)
85 }
86 return Config{}, err
87 }
88 cfg, _, err := readConfigDocument(absPath)
89 if err != nil {
90 return Config{}, err
91 }
92 return cfg, nil
93 }
94
95 func loadConfigDocument(path string) (Config, string, os.FileMode, error) {
96 path = strings.TrimSpace(path)
97 if path == "" {
98 path = service.DefaultConfigPath()
99 }
100 absPath, err := filepath.Abs(path)
101 if err != nil {
102 return Config{}, "", 0, err
103 }
104 cfg, mode, err := readConfigDocument(absPath)
105 if err != nil {
106 return Config{}, "", 0, err
107 }
108 return cfg, absPath, mode, nil
109 }
110
111 func readConfigDocument(absPath string) (Config, os.FileMode, error) {
112 info, err := os.Stat(absPath)
113 if err != nil {
114 return Config{}, 0, err
115 }
116 data, err := os.ReadFile(absPath)
117 if err != nil {
118 return Config{}, 0, err
119 }
120
121 var cfg Config
122 if strings.TrimSpace(string(data)) != "" {
123 k := koanf.New(".")
124 if err := k.Load(file.Provider(absPath), toml.Parser()); err != nil {
125 return Config{}, 0, err
126 }
127 if err := k.Unmarshal("", &cfg); err != nil {
128 return Config{}, 0, err
129 }
130 }
131 cfg.sourcePath = absPath
132 if err := cfg.ApplyDefaults(absPath); err != nil {
133 return Config{}, 0, err
134 }
135 if err := cfg.Validate(); err != nil {
136 return Config{}, 0, err
137 }
138 return cfg, info.Mode().Perm(), nil
139 }
140
141 func writeConfigDocument(path string, mode os.FileMode, cfg Config) error {
142 data, err := toml.Parser().Marshal(configMap(cfg))
143 if err != nil {
144 return err
145 }
146 if mode == 0 {
147 mode = 0o644
148 }
149 return os.WriteFile(path, data, mode)
150 }
151
152 func configMap(cfg Config) map[string]any {
153 agent := make(map[string]any)
154 addStringDocumentField(agent, "state_dir", cfg.Agent.StateDir)
155 addStringDocumentField(agent, "control_addr", cfg.Agent.ControlAddr)
156 addStringDocumentField(agent, "service_name", cfg.Agent.ServiceName)
157 addStringSliceDocumentField(agent, "allowed_wallets", cfg.Agent.AllowedWallets)
158
159 tunnels := make([]map[string]any, 0, len(cfg.Tunnels))
160 for _, tunnel := range cfg.Tunnels {
161 tunnels = append(tunnels, tunnelConfigDocumentMap(tunnel))
162 }
163
164 out := map[string]any{
165 "tunnels": tunnels,
166 }
167 if len(agent) > 0 {
168 out["agent"] = agent
169 }
170 return out
171 }
172
173 func tunnelConfigDocumentMap(cfg TunnelConfig) map[string]any {
174 out := make(map[string]any)
175 addStringDocumentField(out, "id", cfg.ID)
176 addStringDocumentField(out, "name", cfg.Name)
177 addStringDocumentField(out, "target", cfg.TargetAddr)
178 if len(cfg.HTTPRoutes) > 0 {
179 routes := make([]map[string]any, 0, len(cfg.HTTPRoutes))
180 for _, route := range cfg.HTTPRoutes {
181 routeMap := make(map[string]any)
182 addStringDocumentField(routeMap, "prefix", route.Prefix)
183 addStringDocumentField(routeMap, "upstream", route.Upstream)
184 addStringSliceDocumentField(routeMap, "methods", route.Methods)
185 addStringDocumentField(routeMap, "amount", route.Amount)
186 routes = append(routes, routeMap)
187 }
188 out["http_routes"] = routes
189 }
190 addStringSliceDocumentField(out, "relays", cfg.RelayURLs)
191 if cfg.Discovery != nil {
192 out["discovery"] = *cfg.Discovery
193 }
194 addStringDocumentField(out, "identity_path", cfg.IdentityPath)
195 addStringDocumentField(out, "identity_json", cfg.IdentityJSON)
196 if cfg.UDPEnabled {
197 out["udp"] = cfg.UDPEnabled
198 }
199 addStringDocumentField(out, "udp_addr", cfg.UDPAddr)
200 if cfg.TCPEnabled {
201 out["tcp"] = cfg.TCPEnabled
202 }
203 addStringSliceDocumentField(out, "multi_hop", cfg.MultiHop)
204 if cfg.MultiHopDepth != 0 {
205 out["multi_hop_depth"] = cfg.MultiHopDepth
206 }
207 if cfg.BanMITM != nil {
208 out["ban_mitm"] = *cfg.BanMITM
209 }
210 if cfg.MaxActiveRelays != 0 {
211 out["max_active_relays"] = cfg.MaxActiveRelays
212 }
213 addStringDocumentField(out, "description", cfg.Description)
214 addStringSliceDocumentField(out, "tags", cfg.Tags)
215 addStringDocumentField(out, "owner", cfg.Owner)
216 addStringDocumentField(out, "thumbnail", cfg.Thumbnail)
217 if cfg.Hide {
218 out["hide"] = cfg.Hide
219 }
220 addStringDocumentField(out, "x402_pay_to", cfg.X402PayTo)
221 if cfg.X402Testnet {
222 out["x402_testnet"] = cfg.X402Testnet
223 }
224 return out
225 }
226
227 func addStringDocumentField(out map[string]any, key, value string) {
228 if strings.TrimSpace(value) != "" {
229 out[key] = value
230 }
231 }
232
233 func addStringSliceDocumentField(out map[string]any, key string, value []string) {
234 if len(value) > 0 {
235 out[key] = append([]string(nil), value...)
236 }
237 }
238
239 func (cfg *Config) ApplyDefaults(configPath string) error {
240 configDir := "."
241 if absConfig, err := filepath.Abs(strings.TrimSpace(configPath)); err == nil {
242 configDir = filepath.Dir(absConfig)
243 }
244
245 cfg.Agent.StateDir = strings.TrimSpace(cfg.Agent.StateDir)
246 cfg.Agent.ControlAddr = strings.TrimSpace(cfg.Agent.ControlAddr)
247 cfg.Agent.ServiceName = strings.TrimSpace(cfg.Agent.ServiceName)
248 allowedWallets := cfg.Agent.AllowedWallets[:0]
249 for _, wallet := range cfg.Agent.AllowedWallets {
250 if wallet = strings.TrimSpace(wallet); wallet != "" {
251 allowedWallets = append(allowedWallets, wallet)
252 }
253 }
254 cfg.Agent.AllowedWallets = allowedWallets
255 if strings.TrimSpace(cfg.Agent.StateDir) == "" {
256 cfg.Agent.StateDir = service.DefaultDataDir()
257 } else if !filepath.IsAbs(cfg.Agent.StateDir) {
258 cfg.Agent.StateDir = filepath.Join(configDir, cfg.Agent.StateDir)
259 }
260 if strings.TrimSpace(cfg.Agent.ControlAddr) == "" {
261 cfg.Agent.ControlAddr = DefaultControlAddr
262 }
263 if strings.TrimSpace(cfg.Agent.ServiceName) == "" {
264 cfg.Agent.ServiceName = DefaultServiceName
265 }
266
267 for i := range cfg.Tunnels {
268 t := &cfg.Tunnels[i]
269 t.ID = strings.TrimSpace(t.ID)
270 t.Name = strings.TrimSpace(t.Name)
271 if t.ID == "" {
272 t.ID = t.Name
273 }
274 if t.ID == "" {
275 t.ID = fmt.Sprintf("tunnel-%d", i+1)
276 }
277 if t.IdentityPath == "" {
278 if len(cfg.Tunnels) <= 1 {
279 t.IdentityPath = filepath.Join(cfg.Agent.StateDir, defaultIdentityFilename)
280 } else {
281 t.IdentityPath = filepath.Join(cfg.Agent.StateDir, t.ID, defaultIdentityFilename)
282 }
283 } else if !filepath.IsAbs(t.IdentityPath) {
284 t.IdentityPath = filepath.Join(configDir, t.IdentityPath)
285 }
286 if t.MaxActiveRelays == 0 {
287 t.MaxActiveRelays = 3
288 }
289 if len(t.RelayURLs) > 0 {
290 relays, err := utils.NormalizeRelayURLs(t.RelayURLs...)
291 if err != nil {
292 return fmt.Errorf("tunnel %q relays: %w", t.ID, err)
293 }
294 t.RelayURLs = relays
295 }
296 for idx, relayURL := range t.MultiHop {
297 normalized, err := utils.NormalizeRelayURL(relayURL)
298 if err != nil {
299 return fmt.Errorf("tunnel %q multi_hop: %w", t.ID, err)
300 }
301 t.MultiHop[idx] = normalized
302 }
303 }
304 return nil
305 }
306
307 func (cfg Config) Validate() error {
308 if strings.TrimSpace(cfg.Agent.StateDir) == "" {
309 return errors.New("agent.state_dir is required")
310 }
311 if strings.TrimSpace(cfg.Agent.ControlAddr) == "" {
312 return errors.New("agent.control_addr is required")
313 }
314 if err := validateAgentPathComponent("agent.service_name", cfg.Agent.ServiceName); err != nil {
315 return err
316 }
317 seen := make(map[string]struct{}, len(cfg.Tunnels))
318 for _, tunnel := range cfg.Tunnels {
319 if err := tunnel.Validate(); err != nil {
320 return err
321 }
322 if _, ok := seen[tunnel.ID]; ok {
323 return fmt.Errorf("duplicate tunnel id %q", tunnel.ID)
324 }
325 seen[tunnel.ID] = struct{}{}
326 }
327 return nil
328 }
329
330 func (cfg TunnelConfig) Validate() error {
331 if err := validateAgentPathComponent("tunnel id", cfg.ID); err != nil {
332 return err
333 }
334 if strings.TrimSpace(cfg.TargetAddr) == "" && len(cfg.HTTPRoutes) == 0 {
335 return fmt.Errorf("tunnel %q requires target or http_routes", cfg.ID)
336 }
337 if strings.TrimSpace(cfg.TargetAddr) != "" && len(cfg.HTTPRoutes) > 0 {
338 return fmt.Errorf("tunnel %q cannot combine target and http_routes", cfg.ID)
339 }
340 if len(cfg.HTTPRoutes) > 0 && cfg.UDPEnabled {
341 return fmt.Errorf("tunnel %q cannot combine udp and http_routes", cfg.ID)
342 }
343 if cfg.MultiHopDepth < 0 {
344 return fmt.Errorf("tunnel %q multi_hop_depth cannot be negative", cfg.ID)
345 }
346 if len(cfg.MultiHop) == 1 {
347 return fmt.Errorf("tunnel %q multi_hop requires at least entry and exit relays", cfg.ID)
348 }
349 if len(cfg.MultiHop) > 0 && cfg.MultiHopDepth > 1 {
350 return fmt.Errorf("tunnel %q cannot combine multi_hop and multi_hop_depth", cfg.ID)
351 }
352 if (len(cfg.MultiHop) > 0 || cfg.MultiHopDepth > 1) && (cfg.UDPEnabled || cfg.TCPEnabled) {
353 return fmt.Errorf("tunnel %q multi-hop supports only the default stream transport", cfg.ID)
354 }
355 if len(cfg.MultiHop) > 0 {
356 uniqueMultiHop, err := utils.NormalizeRelayURLs(cfg.MultiHop...)
357 if err != nil {
358 return fmt.Errorf("tunnel %q multi_hop: %w", cfg.ID, err)
359 }
360 if len(uniqueMultiHop) != len(cfg.MultiHop) {
361 return fmt.Errorf("tunnel %q multi_hop relay repeated", cfg.ID)
362 }
363 }
364 for _, route := range cfg.HTTPRoutes {
365 if strings.TrimSpace(route.Prefix) == "" || strings.TrimSpace(route.Upstream) == "" {
366 return fmt.Errorf("tunnel %q http_routes require prefix and upstream", cfg.ID)
367 }
368 if strings.TrimSpace(route.Amount) != "" && strings.TrimSpace(cfg.X402PayTo) == "" {
369 return fmt.Errorf("tunnel %q http route %q amount requires x402_pay_to", cfg.ID, strings.TrimSpace(route.Prefix))
370 }
371 if strings.TrimSpace(route.Amount) == "" && len(route.Methods) > 0 {
372 return fmt.Errorf("tunnel %q http route %q methods require amount", cfg.ID, strings.TrimSpace(route.Prefix))
373 }
374 }
375 return nil
376 }
377
378 func validateAgentPathComponent(name, value string) error {
379 value = strings.TrimSpace(value)
380 if value == "" {
381 return fmt.Errorf("%s is required", name)
382 }
383 if value == "." || value == ".." {
384 return fmt.Errorf("%s cannot be %q", name, value)
385 }
386 for _, r := range value {
387 if invalidAgentPathComponentRune(r) {
388 return fmt.Errorf("%s contains invalid character %q", name, r)
389 }
390 }
391 return nil
392 }
393
394 func invalidAgentPathComponentRune(r rune) bool {
395 return unicode.IsSpace(r) || r < 0x20 || r == 0x7f || strings.ContainsRune(agentPathInvalidChars, r)
396 }