159 lines
5.2 KiB
Go
159 lines
5.2 KiB
Go
package cloudflare
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"net/url"
|
|
"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 TunnelName(clusterID string) string {
|
|
return "maidn-" + clusterID
|
|
}
|
|
|
|
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 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")
|
|
}
|
|
var credentials Credentials
|
|
decoder := json.NewDecoder(strings.NewReader(credentialsJSON))
|
|
decoder.DisallowUnknownFields()
|
|
if err := decoder.Decode(&credentials); err != nil || decoder.Decode(&struct{}{}) != io.EOF || credentials.AccountTag == "" || credentials.TunnelSecret == "" || credentials.TunnelID == "" {
|
|
return StoredTunnel{}, false, errors.New("Cloudflare tunnel credentials are invalid")
|
|
}
|
|
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 (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
|
|
}
|