maidn-cli/internal/cloudflare/tunnel.go

226 lines
7.8 KiB
Go

package cloudflare
import (
"encoding/base64"
"encoding/json"
"errors"
"io"
"net/url"
"os"
"regexp"
"strings"
"gopkg.in/yaml.v3"
)
const (
credentialsKey = "credentials"
configKey = "config"
legacyCredentialsKey = "credentials.json"
legacyConfigKey = "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[legacyCredentialsKey]
encoded, inData := secret.Data[legacyCredentialsKey]
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) {
stored, present, _, err := ParseStoredTunnelState(values)
return stored, present, err
}
// ParseStoredTunnelState reports whether state uses the legacy dotted keys.
func ParseStoredTunnelState(values map[string]string) (StoredTunnel, bool, bool, error) {
credentialsJSON, hasCredentials := values[credentialsKey]
configYAML, hasConfig := values[configKey]
legacyCredentialsJSON, hasLegacyCredentials := values[legacyCredentialsKey]
legacyConfigYAML, hasLegacyConfig := values[legacyConfigKey]
if hasCredentials || hasConfig {
if !hasCredentials || !hasConfig || len(values) != 2 {
return StoredTunnel{}, false, false, errors.New("Cloudflare tunnel operational state is ambiguous; expected only credentials and config")
}
return parseStoredTunnel(credentialsJSON, configYAML, false)
}
if hasLegacyCredentials || hasLegacyConfig {
if !hasLegacyCredentials || !hasLegacyConfig || len(values) != 2 {
return StoredTunnel{}, false, false, errors.New("Cloudflare tunnel operational state is ambiguous; expected only credentials and config")
}
return parseStoredTunnel(legacyCredentialsJSON, legacyConfigYAML, true)
}
if len(values) == 0 {
return StoredTunnel{}, false, false, nil
}
return StoredTunnel{}, false, false, errors.New("Cloudflare tunnel operational state is ambiguous; expected credentials and config")
}
func parseStoredTunnel(credentialsJSON, configYAML string, legacy bool) (StoredTunnel, bool, bool, error) {
credentials, err := parseCredentials([]byte(credentialsJSON))
if err != nil {
return StoredTunnel{}, false, 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, false, errors.New("Cloudflare tunnel config is invalid")
}
return StoredTunnel{Credentials: credentials, Config: config}, true, legacy, 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
}