package cloudflare import ( "encoding/base64" "encoding/json" "errors" "io" "net/url" "os" "regexp" "strings" "gopkg.in/yaml.v3" ) const ( credentialsKey = "credentials.json" configKey = "config.yml" credentialsFile = "/etc/cloudflared/credentials.json" ) var hostnamePattern = regexp.MustCompile(`(?i)^(?:\*\.)?(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$`) type Credentials struct { AccountTag string `json:"AccountTag"` TunnelSecret string `json:"TunnelSecret"` TunnelID string `json:"TunnelID"` } type Route struct { Hostname string `yaml:"hostname,omitempty"` Service string `yaml:"service"` } type Config struct { Tunnel string `yaml:"tunnel"` CredentialsFile string `yaml:"credentials-file"` Ingress []Route `yaml:"ingress"` } type StoredTunnel struct { Credentials Credentials Config Config } func NewConfig(tunnelID string) Config { return Config{Tunnel: tunnelID, CredentialsFile: credentialsFile, Ingress: []Route{{Service: "http_status:404"}}} } func IsLegacyRunTokenState(values map[string]string) bool { return len(values) == 1 && strings.TrimSpace(values["token"]) != "" } func ReadCredentialsFile(path string) (Credentials, error) { contents, err := os.ReadFile(path) if err != nil { return Credentials{}, errors.New("invalid Cloudflare credentials file") } if credentials, err := parseCredentials(contents); err == nil { return credentials, nil } credentials, err := credentialsFromSecret(contents) if err != nil { return Credentials{}, errors.New("invalid Cloudflare credentials file") } return credentials, nil } func credentialsFromSecret(contents []byte) (Credentials, error) { var secret struct { APIVersion string `yaml:"apiVersion"` Kind string `yaml:"kind"` StringData map[string]string `yaml:"stringData"` Data map[string]string `yaml:"data"` } decoder := yaml.NewDecoder(strings.NewReader(string(contents))) if err := decoder.Decode(&secret); err != nil || decoder.Decode(&struct{}{}) != io.EOF || secret.APIVersion != "v1" || secret.Kind != "Secret" { return Credentials{}, errors.New("invalid Secret") } plaintext, inStringData := secret.StringData[credentialsKey] encoded, inData := secret.Data[credentialsKey] if inStringData == inData { return Credentials{}, errors.New("missing Secret credentials") } if inStringData { return parseCredentials([]byte(plaintext)) } decoded, err := base64.StdEncoding.DecodeString(encoded) if err != nil { return Credentials{}, errors.New("invalid Secret credentials") } return parseCredentials(decoded) } func NewRoute(hostname, service string) (Route, error) { hostname = strings.ToLower(strings.TrimSuffix(strings.TrimSpace(hostname), ".")) if len(hostname) > 253 || !hostnamePattern.MatchString(hostname) { return Route{}, errors.New("hostname must be a valid public DNS hostname") } parsed, err := url.ParseRequestURI(service) if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" || parsed.User != nil || parsed.Fragment != "" { return Route{}, errors.New("service must be an absolute http or https URL") } return Route{Hostname: hostname, Service: service}, nil } func ParseStoredTunnel(values map[string]string) (StoredTunnel, bool, error) { credentialsJSON, hasCredentials := values[credentialsKey] configYAML, hasConfig := values[configKey] if !hasCredentials && !hasConfig { if len(values) == 0 { return StoredTunnel{}, false, nil } return StoredTunnel{}, false, errors.New("Cloudflare tunnel operational state is ambiguous; expected credentials.json and config.yml") } if !hasCredentials || !hasConfig || len(values) != 2 { return StoredTunnel{}, false, errors.New("Cloudflare tunnel operational state is ambiguous; expected only credentials.json and config.yml") } credentials, err := parseCredentials([]byte(credentialsJSON)) if err != nil { return StoredTunnel{}, false, err } var config Config yamlDecoder := yaml.NewDecoder(strings.NewReader(configYAML)) yamlDecoder.KnownFields(true) if err := yamlDecoder.Decode(&config); err != nil || yamlDecoder.Decode(&struct{}{}) != io.EOF || !validConfig(config) || config.Tunnel != credentials.TunnelID { return StoredTunnel{}, false, errors.New("Cloudflare tunnel config is invalid") } return StoredTunnel{Credentials: credentials, Config: config}, true, nil } func parseCredentials(contents []byte) (Credentials, error) { var credentials Credentials decoder := json.NewDecoder(strings.NewReader(string(contents))) decoder.DisallowUnknownFields() if err := decoder.Decode(&credentials); err != nil || decoder.Decode(&struct{}{}) != io.EOF || credentials.AccountTag == "" || credentials.TunnelSecret == "" || credentials.TunnelID == "" { return Credentials{}, errors.New("Cloudflare tunnel credentials are invalid") } return credentials, nil } func (s StoredTunnel) Values() (map[string]string, error) { credentials, err := json.Marshal(s.Credentials) if err != nil { return nil, errors.New("encode Cloudflare tunnel credentials") } config, err := yaml.Marshal(s.Config) if err != nil { return nil, errors.New("encode Cloudflare tunnel config") } return map[string]string{credentialsKey: string(credentials), configKey: string(config)}, nil } func (c *Config) AddRoute(route Route) (bool, error) { if !validConfig(*c) { return false, errors.New("Cloudflare tunnel config is invalid") } for _, existing := range c.Ingress[:len(c.Ingress)-1] { if existing.Hostname == route.Hostname { if existing.Service == route.Service { return false, nil } return false, errors.New("hostname already has a different Cloudflare tunnel route") } } terminal := c.Ingress[len(c.Ingress)-1] c.Ingress = append(c.Ingress[:len(c.Ingress)-1], route, terminal) return true, nil } func (c *Config) RemoveRoute(route Route) (bool, error) { if !validConfig(*c) { return false, errors.New("Cloudflare tunnel config is invalid") } for index, existing := range c.Ingress[:len(c.Ingress)-1] { if existing.Hostname != route.Hostname { continue } if existing.Service != route.Service { return false, errors.New("hostname does not match the requested Cloudflare tunnel service") } c.Ingress = append(c.Ingress[:index], c.Ingress[index+1:]...) return true, nil } return false, nil } func validConfig(config Config) bool { if config.Tunnel == "" || config.CredentialsFile != credentialsFile || len(config.Ingress) == 0 { return false } last := len(config.Ingress) - 1 if config.Ingress[last].Hostname != "" || config.Ingress[last].Service != "http_status:404" { return false } seen := map[string]bool{} for _, route := range config.Ingress[:last] { normalized, err := NewRoute(route.Hostname, route.Service) if err != nil || normalized != route || seen[route.Hostname] { return false } seen[route.Hostname] = true } return true }