🌱🏠😈 Common background service doing the heavy lifting for various user-facing greenhouse client applications https://greenhouse.server.garden/
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

706 lines
22 KiB

package main
import (
"bytes"
"crypto/rand"
"crypto/tls"
"encoding/json"
"fmt"
"io"
"io/ioutil"
"net"
"net/http"
"regexp"
"strings"
errors "git.sequentialread.com/forest/pkg-errors"
)
type ThresholdTunnelsConfig struct {
Listeners []ThresholdTunnel
ServiceToLocalAddrMap map[string]string
}
type CaddyApp struct {
//http app
HttpPort int `json:"http_port,omitempty"`
HttpsPort int `json:"https_port,omitempty"`
Servers map[string]*CaddyServer `json:"servers,omitempty"`
//tls app
Automation *CaddyTLSAutomation `json:"automation,omitempty"`
}
type CaddyAutomaticHTTPS struct {
Disable bool `json:"disable"`
3 years ago
DisableRedirects bool `json:"disable_redirects"`
}
type CaddyTLSAutomation struct {
Policies []CaddyTLSPolicy `json:"policies,omitempty"`
}
type CaddyTLSPolicy struct {
Subjects []string `json:"subjects,omitempty"`
Issuers []CaddyACMEIssuer `json:"issuers,omitempty"`
}
type CaddyACMEIssuer struct {
CA string `json:"ca"`
Email string `json:"email"`
Module string `json:"module"`
}
type CaddyServer struct {
AutomaticHTTPS *CaddyAutomaticHTTPS `json:"automatic_https,omitempty"`
Listen []string `json:"listen"`
ListenerWrappers []CaddyListenerWrapper `json:"listener_wrappers,omitempty"`
Routes []CaddyRoute `json:"routes"`
Logs *CaddyServerLogs `json:"logs,omitempty"`
}
type CaddyListenerWrapper struct {
Wrapper string `json:"wrapper"`
Timeout string `json:"timeout,omitempty"`
}
type CaddyServerLogs struct {
LoggerNames map[string]string `json:"logger_names"`
}
type CaddyRoute struct {
Handle []CaddyHandler `json:"handle,omitempty"`
Match []CaddyMatch `json:"match,omitempty"`
Terminal bool `json:"terminal,omitempty"`
}
// https://caddyserver.com/docs/json/apps/http/servers/routes/handle/
type CaddyHandler struct {
Handler string `json:"handler"`
//CaddySubrouteHandler
Routes []CaddyRoute `json:"routes,omitempty"`
//CaddyReverseProxyHandler
Upstreams []CaddyUpstream `json:"upstreams,omitempty"`
//CaddyStaticResponseHandler
Headers map[string][]string `json:"headers,omitempty"`
StatusCode int `json:"status_code,omitempty"`
//CaddyFileServerHandler
Root string `json:"root,omitempty"`
Passthrough bool `json:"pass_thru,omitempty"`
Browse *CaddyFileServerBrowse `json:"browse,omitempty"`
}
type CaddyFileServerBrowse struct {
TemplateFile string `json:"template_file,omitempty"`
}
// https://caddyserver.com/docs/json/apps/http/servers/routes/handle/reverse_proxy/
type CaddyUpstream struct {
Dial string `json:"dial"`
}
// https://caddyserver.com/docs/json/apps/http/servers/routes/match/
type CaddyMatch struct {
// match host
Host []string `json:"host,omitempty"`
// match vars regexp
VarsRegexp map[string]CaddyVarsRegexp `json:"vars_regexp,omitempty"`
// match host (sni) for layer4 plugin
TLS *CaddyMatchTLS `json:"tls,omitempty"`
}
type CaddyMatchTLS struct {
SNI []string `json:"sni,omitempty"`
}
type CaddyVarsRegexp struct {
Name string `json:"name,omitempty"`
Pattern string `json:"pattern,omitempty"`
}
type ConfigService struct {
ClientId string
ThresholdAdminBaseURL string
CaddyAdminBaseURL string
ThresholdClient *http.Client
CaddyAdminClient *http.Client
EmailAddress string
UseUnixSockets bool
TelemetryID string
}
// TODO make these configurable?
const caddyHTTPPort = 9575
const caddyHTTPSPort = 9576
const caddyTLSPort = 9577
const caddyHTTPSocketFile = "/var/run/greenhouse-daemon-caddy-http.sock"
const caddyHTTPSSocketFile = "/var/run/greenhouse-daemon-caddy-https.sock"
const caddyTLSSocketFile = "/var/run/greenhouse-daemon-caddy-tls.sock"
const caddyACMEIssuerURL = "https://acme-v02.api.letsencrypt.org/directory"
func (service *ConfigService) PrepareConfigs(tunnels []GUITunnel) (*ThresholdTunnelsConfig, *map[string]*CaddyApp, error) {
sourceURLs := map[string]int{}
tunnelsWithInvalidSubdomains := []string{}
// TODO: support wildcard subdomains "^([A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)|\\*$"
// Right now wildcard subdomains are blocked on Caddy requiring the dns acme challenge for them.
subdomainRegex := regexp.MustCompile("^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$")
for _, tunnel := range tunnels {
sourceURL := tunnel.GetStringDisplay(true)
sourceURLs[sourceURL]++
if tunnel.HasSubdomain && !subdomainRegex.MatchString(tunnel.Subdomain) {
tunnelsWithInvalidSubdomains = append(tunnelsWithInvalidSubdomains, sourceURL)
}
}
duplicatedSources := []string{}
for k, v := range sourceURLs {
if v > 1 {
duplicatedSources = append(duplicatedSources, k)
}
}
errorStrings := []string{}
if len(duplicatedSources) > 0 {
errorStrings = append(errorStrings, fmt.Sprintf("multiple tunnels exist with the URL(s) %s", strings.Join(duplicatedSources, ", ")))
}
if len(tunnelsWithInvalidSubdomains) > 0 {
errorStrings = append(errorStrings, fmt.Sprintf("the tunnel(s) %s have invalid subdomains", strings.Join(tunnelsWithInvalidSubdomains, ", ")))
}
if len(errorStrings) > 0 {
return nil, nil, errors.New(strings.Join(errorStrings, ", \n"))
}
thresholdConfig := ThresholdTunnelsConfig{
Listeners: []ThresholdTunnel{},
ServiceToLocalAddrMap: map[string]string{},
}
allHostnamesMap := map[string]bool{}
for _, tunnel := range tunnels {
allHostnamesMap[service.getPrototypeListenerConfig(tunnel).ListenHostnameGlob] = true
}
allHostnames := make([]string, len(allHostnamesMap))
i := 0
for k := range allHostnamesMap {
allHostnames[i] = k
i++
}
// TODO advanced option to allow listening on 0.0.0.0?
caddyTLSListen := fmt.Sprintf("127.0.0.1:%d", caddyTLSPort)
caddyHTTPSListen := fmt.Sprintf("127.0.0.1:%d", caddyHTTPSPort)
caddyHTTPListen := fmt.Sprintf("127.0.0.1:%d", caddyHTTPPort)
if service.UseUnixSockets {
caddyTLSListen = fmt.Sprintf("unix//%s", caddyTLSSocketFile)
caddyHTTPSListen = fmt.Sprintf("unix//%s", caddyHTTPSSocketFile)
caddyHTTPListen = fmt.Sprintf("unix//%s", caddyHTTPSocketFile)
}
caddyConfig := map[string]*CaddyApp{
"layer4": {
Servers: map[string]*CaddyServer{
"layer4_server_0": {
Listen: []string{caddyTLSListen},
Routes: []CaddyRoute{},
},
},
},
"http": {
Servers: map[string]*CaddyServer{
"https_server": {
AutomaticHTTPS: &CaddyAutomaticHTTPS{
DisableRedirects: true,
},
Listen: []string{caddyHTTPSListen},
ListenerWrappers: []CaddyListenerWrapper{
{
Wrapper: "proxy_protocol",
Timeout: "5s",
},
{
Wrapper: "tls",
},
},
// Logs: {
// LoggerNames: map[string]string{
// "*": "greenhouse-daemon-http",
// },
// },
Routes: []CaddyRoute{service.getFbclidRemovalRoute()},
},
"http_redirector": {
AutomaticHTTPS: &CaddyAutomaticHTTPS{
Disable: true,
},
Listen: []string{caddyHTTPListen},
// ListenerWrappers: []CaddyListenerWrapper{
// {
// Wrapper: "proxy_protocol",
// Timeout: "5s",
// },
// {
// Wrapper: "tls",
// },
// },
// // TODO disable logs for the redirector
// Logs: &CaddyServerLogs{
// LoggerNames: map[string]string{
// "*": "greenhouse-daemon-http",
// },
// },
Routes: []CaddyRoute{service.getHTTPSRedirectRoute()},
},
},
},
"tls": {
Automation: &CaddyTLSAutomation{
Policies: []CaddyTLSPolicy{
{
Subjects: allHostnames,
Issuers: []CaddyACMEIssuer{
{
CA: caddyACMEIssuerURL,
Email: service.EmailAddress,
Module: "acme",
},
},
},
},
},
},
}
if !service.UseUnixSockets {
caddyConfig["http"].HttpPort = caddyHTTPPort
caddyConfig["http"].HttpsPort = caddyHTTPSPort
}
jsonBytes, _ := json.MarshalIndent(tunnels, "", " ")
log.Printf("tunnels: %s\n\n", string(jsonBytes))
for _, tunnel := range tunnels {
if tunnel.DestinationType == "folder" && tunnel.Protocol != "https" {
return nil, nil, errors.Errorf("the 'folder' destination type is limted to https tunnels")
}
destination := fmt.Sprintf("127.0.0.1:%d", tunnel.DestinationPort)
if tunnel.DestinationType == "host_port" {
destination = fmt.Sprintf("%s:%d", tunnel.DestinationHostname, tunnel.DestinationPort)
}
if tunnel.Protocol == "https" {
tunnelListener := service.getPrototypeListenerConfig(tunnel)
tunnelListener.ListenPort = 80
tunnelListener.BackEndService = fmt.Sprintf("%s_80", tunnel.GetServiceId())
//It's not required because all http will be redirected before it is used.
//We probably won't log http requests, just https ones
//tunnelListener.HaProxyProxyProtocol = true
thresholdConfig.Listeners = append(thresholdConfig.Listeners, tunnelListener)
thresholdConfig.ServiceToLocalAddrMap[tunnelListener.BackEndService] = caddyHTTPListen
tunnelListener = service.getPrototypeListenerConfig(tunnel)
tunnelListener.ListenPort = 443
tunnelListener.BackEndService = fmt.Sprintf("%s_443", tunnel.GetServiceId())
tunnelListener.HaProxyProxyProtocol = true
thresholdConfig.Listeners = append(thresholdConfig.Listeners, tunnelListener)
thresholdConfig.ServiceToLocalAddrMap[tunnelListener.BackEndService] = caddyHTTPSListen
if tunnel.DestinationType == "folder" {
caddyConfig["http"].Servers["https_server"].Routes = append(
caddyConfig["http"].Servers["https_server"].Routes,
CaddyRoute{
Handle: []CaddyHandler{
{
Handler: "file_server",
Root: tunnel.DestinationFolderPath,
Passthrough: false,
Browse: &CaddyFileServerBrowse{},
},
},
Match: []CaddyMatch{
{
Host: []string{tunnelListener.ListenHostnameGlob},
},
},
Terminal: true,
},
)
} else {
caddyConfig["http"].Servers["https_server"].Routes = append(
caddyConfig["http"].Servers["https_server"].Routes,
CaddyRoute{
Handle: []CaddyHandler{
{
// I believe this handler sets X-Real-IP and X-Forwarded-For automatically :)
Handler: "reverse_proxy",
Upstreams: []CaddyUpstream{
{
Dial: destination,
},
},
},
},
Match: []CaddyMatch{
{
Host: []string{tunnelListener.ListenHostnameGlob},
},
},
Terminal: true,
},
)
}
}
if tunnel.Protocol == "tls" {
tunnelListener := service.getPrototypeListenerConfig(tunnel)
thresholdConfig.Listeners = append(thresholdConfig.Listeners, tunnelListener)
thresholdConfig.ServiceToLocalAddrMap[tunnelListener.BackEndService] = caddyTLSListen
// TODO it should be possible to both read and write proxy protocol here...
// 1. add an option to caddy-layer4 to read proxy protocol by wrapping the TCP listener
// 2. caddy-layer4 terminate TLS by wrapping the TCP listener (caddy-layer4 already does this)
// 3. forward proxy protocol to the backend (caddy-layer4 already supports this)
// However, this is more of an edge case -- probably not needed by 99% of users.
// Biggest usecase would probably be email, but not really cuz email has to go via TCP anyways due to STARTTLS
caddyConfig["layer4"].Servers["layer4_server_0"].Routes = append(
caddyConfig["layer4"].Servers["layer4_server_0"].Routes,
CaddyRoute{
Handle: []CaddyHandler{
{
Handler: "tls",
},
{
Handler: "proxy",
Upstreams: []CaddyUpstream{
{
Dial: destination,
},
},
},
},
Match: []CaddyMatch{
{
TLS: &CaddyMatchTLS{
SNI: []string{tunnelListener.ListenHostnameGlob},
},
},
},
},
)
}
if tunnel.Protocol == "tcp" {
tunnelListener := service.getPrototypeListenerConfig(tunnel)
tunnelListener.ListenHostnameGlob = ""
thresholdConfig.Listeners = append(thresholdConfig.Listeners, tunnelListener)
// TODO user specifying PROXY protocol should be supported here.
thresholdConfig.ServiceToLocalAddrMap[tunnelListener.BackEndService] = destination
// no caddy config, TCP goes direct from threshold to the destination service
}
}
return &thresholdConfig, &caddyConfig, nil
}
func (service *ConfigService) ConfigureThreshold(thresholdConfig *ThresholdTunnelsConfig) error {
url := fmt.Sprintf("%s/liveconfig", service.ThresholdAdminBaseURL)
jsonBytes, err := json.Marshal(thresholdConfig)
if err != nil {
return errors.Wrapf(err, "error serializing threshold json for ConfigService.Configure", url)
}
request, err := http.NewRequest("PUT", url, bytes.NewReader(jsonBytes))
if err != nil {
return errors.Wrapf(err, "error constructing http request to threshold (%s) for ConfigService.Configure", url)
}
response, err := service.ThresholdClient.Do(request)
if err != nil {
return errors.Wrapf(err, "error sending http request to threshold (%s) for ConfigService.Configure", url)
}
bodyBytes, err := ioutil.ReadAll(response.Body)
if err != nil {
return errors.Wrapf(err, "http read error on request to threshold (%s) for ConfigService.Configure", url)
}
if response.StatusCode != 200 {
return errors.Errorf(
"ConfigService.Configure: got HTTP %d %s from threshold (%s): %s",
response.StatusCode, response.Status, url, string(bodyBytes),
)
}
return nil
}
func (service *ConfigService) TestThreshold(thresholdConfig *ThresholdTunnelsConfig) error {
url := fmt.Sprintf("%s/start_test", service.ThresholdAdminBaseURL)
response, err := service.ThresholdClient.Get(url)
if err != nil {
return err
}
if response.StatusCode != http.StatusOK {
responseContent := "http read error when trying to read error message"
responseBytes, err := ioutil.ReadAll(response.Body)
if err == nil {
responseContent = string(responseBytes)
}
return errors.Errorf("calling /start_test on the threshold client returned HTTP %d %s: %s", response.StatusCode, response.Status, responseContent)
}
// TODO get thresholdServerAddress from the threshold admin api
thresholdServerAddress := ""
sentTokens := map[string]string{}
listenerConfigsByToken := map[string]ThresholdTunnel{}
for _, listenerConfig := range thresholdConfig.Listeners {
// only test the tunnels belonging to this threshold client.
if listenerConfig.ClientId != service.ClientId {
continue
}
// bytez, _ := json.MarshalIndent(listenerConfig, "", " ")
// log.Printf("TestThreshold(): listenerConfig = '%s'", string(bytez))
tokenBuffer := make([]byte, 8)
rand.Read(tokenBuffer)
token := fmt.Sprintf("%x", tokenBuffer)
sentTokens[token] = "sent"
listenerConfigsByToken[token] = listenerConfig
if listenerConfig.ListenHostnameGlob != "" && listenerConfig.ListenHostnameGlob != "*" {
hostnames := strings.Split(listenerConfig.ListenHostnameGlob, ",")
for _, hostname := range hostnames {
specificHostname := strings.ReplaceAll(hostname, "*", "a")
if listenerConfig.ListenPort != 80 {
remoteAddress := fmt.Sprintf("%s:%d", specificHostname, listenerConfig.ListenPort)
//log.Printf("TestThreshold(): remoteAddress = '%s'", remoteAddress)
connection, err := tls.Dial("tcp", remoteAddress, &tls.Config{InsecureSkipVerify: true})
if err != nil {
log.Printf("TestThreshold(): connecting to threshold server '%s' returned %s", remoteAddress, err)
return err
}
connection.Write([]byte(token))
responseBuffer := make([]byte, 16)
_, err = connection.Read(responseBuffer)
if err != nil && err != io.EOF {
log.Printf("TestThreshold(): threshold server '%s' read error %s", remoteAddress, err)
return err
}
if string(responseBuffer) != token {
return errors.Errorf("sent test token '%s' to tls://%s, recieved '%s' in response", token, remoteAddress, string(responseBuffer))
}
} else {
url := fmt.Sprintf(fmt.Sprintf("http://%s/%s", specificHostname, token))
response, err := http.Get(url)
if err != nil {
return err
}
if response.StatusCode != http.StatusOK {
return errors.Errorf("sent test token '%s' to %s, recieved HTTP %d %s", token, url, response.StatusCode, response.Status)
}
responseBytes, err := ioutil.ReadAll(response.Body)
if err != nil {
return err
}
if string(responseBytes) != token {
return errors.Errorf("sent test token '%s' to %s, recieved '%s' in response", token, url, string(responseBytes))
}
}
}
} else {
tcpAddressString := fmt.Sprintf("%s:%d", thresholdServerAddress, listenerConfig.ListenPort)
tcpAddress, err := net.ResolveTCPAddr("tcp", tcpAddressString)
if err != nil {
return err
}
connection, err := net.DialTCP("tcp", nil, tcpAddress)
if err != nil {
return err
}
connection.Write([]byte(token))
responseBuffer := make([]byte, 16)
_, err = connection.Read(responseBuffer)
if err != nil && err != io.EOF {
return err
}
if string(responseBuffer) != token {
return errors.Errorf("sent test token '%s' to tcp://%s, recieved '%s' in response", token, tcpAddressString, string(responseBuffer))
}
}
}
url = fmt.Sprintf("%s/end_test", service.ThresholdAdminBaseURL)
response, err = service.ThresholdClient.Get(url)
if err != nil {
return err
}
responseBytes, err := ioutil.ReadAll(response.Body)
if err != nil {
return errors.Wrap(err, "trying to read /end_test response")
}
if response.StatusCode != http.StatusOK {
return errors.Errorf("calling /start_test on the threshold client returned HTTP %d %s: %s", response.StatusCode, response.Status, string(responseBytes))
}
lines := strings.Split(string(responseBytes), "\n")
for _, line := range lines {
if len(strings.TrimSpace(line)) > 0 {
state, hasState := sentTokens[line]
if !hasState {
jsonBytes, _ := json.MarshalIndent(listenerConfigsByToken, "", " ")
return errors.Errorf("malformed or incorrect token '%s' was returned by threshold test. configured test tokens were: %s ", line, string(jsonBytes))
}
if state != "sent" {
return errors.Errorf("token '%s' (%s:%d) was returned by threshold test more than once", line, listenerConfigsByToken[line].ListenHostnameGlob, listenerConfigsByToken[line].ListenPort)
}
sentTokens[line] = "recieved"
}
}
for token, result := range sentTokens {
if result != "recieved" {
return errors.Errorf("token '%s' (%s:%d) was never returned by threshold test", token, listenerConfigsByToken[token].ListenHostnameGlob, listenerConfigsByToken[token].ListenPort)
}
}
return nil
}
func (service *ConfigService) ConfigureCaddy(caddyConfig *map[string]*CaddyApp) error {
url := fmt.Sprintf("%s/config/apps", service.CaddyAdminBaseURL)
jsonBytes, err := json.Marshal(caddyConfig)
if err != nil {
return errors.Wrapf(err, "error serializing caddy json for ConfigService.Configure", url)
}
log.Printf("Caddy Config: %s\n\n", string(jsonBytes))
request, err := http.NewRequest("POST", url, bytes.NewReader(jsonBytes))
if err != nil {
return errors.Wrapf(err, "error constructing http request to caddy (%s) for ConfigService.Configure", url)
}
request.Header.Add("Content-Type", "application/json")
3 years ago
// TODO if/when this needs to happen... ???
// caddy may fail to accept the new config if it cannot create these files. they must not already exist... sometimes??
// however if we delete them every time, it also doesn't seem to work; they wont always be re-created.
//
// if service.UseUnixSockets {
// os.Remove(caddyHTTPSocketFile)
// os.Remove(caddyHTTPSSocketFile)
// os.Remove(caddyTLSSocketFile)
// }
response, err := service.CaddyAdminClient.Do(request)
if err != nil {
return errors.Wrapf(err, "error sending http request to caddy (%s) for ConfigService.Configure", url)
}
bodyBytes, err := ioutil.ReadAll(response.Body)
if err != nil {
return errors.Wrapf(err, "http read error on request to caddy (%s) for ConfigService.Configure", url)
}
if response.StatusCode != 200 {
return errors.Errorf(
"ConfigService.Configure: got HTTP %d %s from caddy (%s): %s",
response.StatusCode, response.Status, url, string(bodyBytes),
)
}
return nil
}
func (service *ConfigService) EnsureCaddyACMECompletes(thresholdConfig *ThresholdTunnelsConfig) error {
// TODO poll for certs present in:
// root@thingpad:/opt/greenhouse-daemon/caddyData/caddy/certificates# ls acme-v02.api.letsencrypt.org-directory
// TODO test threshold tunnels before setting up caddy.
return nil
}
func (service *ConfigService) TestFinalTunnels(tunnels []GUITunnel) error {
// TODO test each tunnel
return nil
}
func (service *ConfigService) getPrototypeListenerConfig(tunnel GUITunnel) ThresholdTunnel {
domain := tunnel.Domain
if tunnel.HasSubdomain {
domain = fmt.Sprintf("%s.%s", tunnel.Subdomain, domain)
}
return ThresholdTunnel{
ListenAddress: "0.0.0.0",
ListenHostnameGlob: domain,
ListenPort: tunnel.PublicPort,
BackEndService: tunnel.GetServiceId(),
ClientId: service.ClientId,
}
}
func (service *ConfigService) getHTTPSRedirectRoute() CaddyRoute {
// because we want to be able to listen on unix sockets we have to do the http and https servers separately,
// which means we have to do our own https redirect
return CaddyRoute{
Handle: []CaddyHandler{
{
Handler: "static_response",
Headers: map[string][]string{
"Location": {"https://{http.request.host}{http.request.uri}"},
},
StatusCode: 301,
},
},
}
}
func (service *ConfigService) getFbclidRemovalRoute() CaddyRoute {
// facebook adds this evil ?fbclid=xyz request parameter whenever someone clicks a link
// this handler will match all requests that have this parameter
// and it will redirect to the same URI with the parameter removed
return CaddyRoute{
Handle: []CaddyHandler{
{
Handler: "static_response",
Headers: map[string][]string{
3 years ago
"Location": {"{http.regexp.fbclid_regex.1}"},
},
StatusCode: 302,
},
},
Match: []CaddyMatch{
{
VarsRegexp: map[string]CaddyVarsRegexp{
"{http.request.uri}": {
Name: "fbclid_regex",
Pattern: "^(.*?)([?&]fbclid=[a-zA-Z0-9_-]+)$",
},
},
},
},
}
}