302 lines
8.0 KiB
Go
302 lines
8.0 KiB
Go
package core
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/BurntSushi/toml"
|
|
)
|
|
|
|
// DefaultConfigURL is the default URL for fetching config.
|
|
// Set via build flags: go build -ldflags "-X tslink/core.DefaultConfigURL=https://..."
|
|
var DefaultConfigURL string
|
|
|
|
type ForwardRule struct {
|
|
Protocol string `toml:"protocol"`
|
|
TailscalePort int `toml:"tailscale_port"`
|
|
LocalAddr string `toml:"local_addr"`
|
|
}
|
|
|
|
type ConnectRule struct {
|
|
Protocol string `toml:"protocol"`
|
|
LocalPort int `toml:"local_port"`
|
|
LocalAddr string `toml:"local_addr"`
|
|
DstAddr string `toml:"dst_addr"`
|
|
LanEnable *bool `toml:"lan_enable"`
|
|
LanMotd string `toml:"lan_motd"`
|
|
}
|
|
|
|
type Core struct {
|
|
AuthKey string `toml:"auth_key"`
|
|
ControlURL string `toml:"control_url"`
|
|
Hostname string `toml:"hostname"`
|
|
Ephemeral bool `toml:"ephemeral"`
|
|
AcceptRoutes bool `toml:"accept_routes"`
|
|
}
|
|
|
|
// DNS holds resolver options. DoHServers are DNS-over-HTTPS endpoints (RFC 8484)
|
|
// queried as a fallback when the tailnet resolver cannot resolve a dial
|
|
// destination. An empty list disables the fallback.
|
|
type DNS struct {
|
|
DoHServers []string `toml:"doh_servers"`
|
|
}
|
|
|
|
func (r ConnectRule) LANEnabled() bool {
|
|
if r.LanEnable != nil {
|
|
return *r.LanEnable
|
|
}
|
|
return r.Protocol == "minecraft"
|
|
}
|
|
|
|
func (r ConnectRule) LANMotdOr(def string) string {
|
|
if r.LanMotd != "" {
|
|
return r.LanMotd
|
|
}
|
|
return def
|
|
}
|
|
|
|
func (r ConnectRule) BindIP() string {
|
|
if r.LANEnabled() {
|
|
return "0.0.0.0"
|
|
}
|
|
if r.LocalAddr != "" {
|
|
return r.LocalAddr
|
|
}
|
|
return "127.0.0.1"
|
|
}
|
|
|
|
type Config struct {
|
|
Core Core `toml:"core"`
|
|
DNS DNS `toml:"dns"`
|
|
Forward map[string][]ForwardRule `toml:"forward"`
|
|
Connect map[string][]ConnectRule `toml:"connect"`
|
|
}
|
|
|
|
func (cfg *Config) ApplyDefaults() {
|
|
if cfg.Forward == nil {
|
|
cfg.Forward = make(map[string][]ForwardRule)
|
|
}
|
|
if cfg.Connect == nil {
|
|
cfg.Connect = make(map[string][]ConnectRule)
|
|
}
|
|
if cfg.Core.Hostname == "" {
|
|
hostname, err := os.Hostname()
|
|
if err != nil {
|
|
hostname = "unknown"
|
|
}
|
|
cfg.Core.Hostname = hostname
|
|
}
|
|
}
|
|
|
|
func (cfg *Config) Validate() error {
|
|
var errs []error
|
|
|
|
if strings.TrimSpace(cfg.Core.AuthKey) == "" {
|
|
errs = append(errs, errors.New("core.auth_key is required"))
|
|
}
|
|
|
|
for i, server := range cfg.DNS.DoHServers {
|
|
u, err := url.Parse(strings.TrimSpace(server))
|
|
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
|
|
errs = append(errs, fmt.Errorf("dns.doh_servers[%d] must be a valid http(s) URL", i))
|
|
}
|
|
}
|
|
|
|
usedForwardListeners := make(map[string]string)
|
|
usedConnectListeners := make(map[string]string)
|
|
|
|
for tag, rules := range cfg.Forward {
|
|
for i, rule := range rules {
|
|
path := fmt.Sprintf("forward.%s[%d]", tag, i)
|
|
if rule.Protocol != "tcp" && rule.Protocol != "udp" {
|
|
errs = append(errs, fmt.Errorf("%s.protocol must be tcp or udp", path))
|
|
}
|
|
if !validPort(rule.TailscalePort) {
|
|
errs = append(errs, fmt.Errorf("%s.tailscale_port must be between 1 and 65535", path))
|
|
} else {
|
|
key := fmt.Sprintf("%s:%d", rule.Protocol, rule.TailscalePort)
|
|
if prev, ok := usedForwardListeners[key]; ok {
|
|
errs = append(errs, fmt.Errorf("%s.tailscale_port duplicates %s", path, prev))
|
|
} else {
|
|
usedForwardListeners[key] = path
|
|
}
|
|
}
|
|
if strings.TrimSpace(rule.LocalAddr) == "" {
|
|
errs = append(errs, fmt.Errorf("%s.local_addr is required", path))
|
|
} else if err := validateHostPort(rule.LocalAddr); err != nil {
|
|
errs = append(errs, fmt.Errorf("%s.local_addr invalid: %w", path, err))
|
|
}
|
|
}
|
|
}
|
|
|
|
for tag, rules := range cfg.Connect {
|
|
for i, rule := range rules {
|
|
path := fmt.Sprintf("connect.%s[%d]", tag, i)
|
|
if rule.Protocol != "tcp" && rule.Protocol != "udp" && rule.Protocol != "minecraft" {
|
|
errs = append(errs, fmt.Errorf("%s.protocol must be tcp, udp, or minecraft", path))
|
|
}
|
|
if !validPort(rule.LocalPort) {
|
|
errs = append(errs, fmt.Errorf("%s.local_port must be between 1 and 65535", path))
|
|
}
|
|
if strings.TrimSpace(rule.DstAddr) == "" {
|
|
errs = append(errs, fmt.Errorf("%s.dst_addr is required", path))
|
|
} else if err := validateHostPort(rule.DstAddr); err != nil {
|
|
errs = append(errs, fmt.Errorf("%s.dst_addr invalid: %w", path, err))
|
|
}
|
|
if rule.LocalAddr != "" && net.ParseIP(rule.LocalAddr) == nil {
|
|
errs = append(errs, fmt.Errorf("%s.local_addr must be an IP address", path))
|
|
}
|
|
if validPort(rule.LocalPort) && (rule.Protocol == "tcp" || rule.Protocol == "udp" || rule.Protocol == "minecraft") {
|
|
network := rule.Protocol
|
|
if network == "minecraft" {
|
|
network = "tcp"
|
|
}
|
|
if prev, ok := conflictingListener(usedConnectListeners, network, rule.BindIP(), rule.LocalPort); ok {
|
|
errs = append(errs, fmt.Errorf("%s local listener duplicates %s", path, prev))
|
|
}
|
|
usedConnectListeners[listenerKey(network, rule.BindIP(), rule.LocalPort)] = path
|
|
}
|
|
}
|
|
}
|
|
|
|
return errors.Join(errs...)
|
|
}
|
|
|
|
func validPort(port int) bool {
|
|
return port > 0 && port <= 65535
|
|
}
|
|
|
|
func validateHostPort(addr string) error {
|
|
host, port, err := net.SplitHostPort(addr)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if strings.TrimSpace(host) == "" {
|
|
return errors.New("host is required")
|
|
}
|
|
if strings.TrimSpace(port) == "" {
|
|
return errors.New("port is required")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func listenerKey(network, ip string, port int) string {
|
|
return fmt.Sprintf("%s/%s", network, net.JoinHostPort(ip, fmt.Sprintf("%d", port)))
|
|
}
|
|
|
|
func conflictingListener(used map[string]string, network, ip string, port int) (string, bool) {
|
|
candidates := []string{
|
|
listenerKey(network, ip, port),
|
|
}
|
|
if ip == "0.0.0.0" {
|
|
for key, path := range used {
|
|
prefix := network + "/"
|
|
_, usedPort, err := net.SplitHostPort(strings.TrimPrefix(key, prefix))
|
|
if strings.HasPrefix(key, prefix) && err == nil && usedPort == fmt.Sprintf("%d", port) {
|
|
return path, true
|
|
}
|
|
}
|
|
} else {
|
|
candidates = append(candidates, listenerKey(network, "0.0.0.0", port))
|
|
}
|
|
for _, key := range candidates {
|
|
if prev, ok := used[key]; ok {
|
|
return prev, true
|
|
}
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
// LoadConfig loads configuration from a file path or URL.
|
|
// If path starts with "http://" or "https://", it fetches the config from the URL.
|
|
// Otherwise, it reads from the local file system.
|
|
func LoadConfig(path string) (*Config, error) {
|
|
// Detect URL
|
|
if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") {
|
|
return loadConfigFromURL(path)
|
|
}
|
|
|
|
// File-based loading
|
|
cfg := &Config{
|
|
Core: Core{
|
|
Hostname: "",
|
|
Ephemeral: true,
|
|
AcceptRoutes: true,
|
|
},
|
|
Forward: make(map[string][]ForwardRule),
|
|
Connect: make(map[string][]ConnectRule),
|
|
}
|
|
if _, err := os.Stat(path); err != nil {
|
|
// write a basic config
|
|
buf := new(bytes.Buffer)
|
|
err = toml.NewEncoder(buf).Encode(cfg)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
err = os.WriteFile(path, buf.Bytes(), 0644)
|
|
}
|
|
_, err := toml.DecodeFile(path, cfg)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
cfg.ApplyDefaults()
|
|
if err := cfg.Validate(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return cfg, nil
|
|
}
|
|
|
|
// loadConfigFromURL fetches a TOML config from the given URL and decodes it.
|
|
func loadConfigFromURL(url string) (*Config, error) {
|
|
client := &http.Client{
|
|
Timeout: 30 * time.Second,
|
|
}
|
|
|
|
resp, err := client.Get(url)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to fetch config from URL %s: %w", url, err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("failed to fetch config from URL %s: unexpected status %d", url, resp.StatusCode)
|
|
}
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read response body from %s: %w", url, err)
|
|
}
|
|
|
|
cfg := &Config{
|
|
Core: Core{
|
|
Hostname: "",
|
|
Ephemeral: true,
|
|
AcceptRoutes: true,
|
|
},
|
|
Forward: make(map[string][]ForwardRule),
|
|
Connect: make(map[string][]ConnectRule),
|
|
}
|
|
|
|
err = toml.Unmarshal(body, cfg)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to decode TOML config from %s: %w", url, err)
|
|
}
|
|
|
|
cfg.ApplyDefaults()
|
|
if err := cfg.Validate(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return cfg, nil
|
|
}
|