206 lines
6.8 KiB
Go
206 lines
6.8 KiB
Go
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
|
|
}
|