🌱🏠 instant least-authority port-forwarding (with automatic HTTPS) for anyone, anywhere! We **really** don't want your TLS private keys, you can keep them 😃 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.

374 lines
11 KiB

package main
import (
"encoding/json"
"fmt"
"io/ioutil"
"log"
"os"
"os/exec"
"os/signal"
"path/filepath"
"regexp"
"runtime"
"runtime/debug"
"strconv"
"strings"
"time"
"git.sequentialread.com/forest/greenhouse/pki"
errors "git.sequentialread.com/forest/pkg-errors"
"golang.org/x/sys/unix"
)
type Config struct {
FrontendPort int
FrontendDomain string
FrontendTLSCertificate string
FrontendTLSKey string
HomepageMarkdownURL string
LokiURL string
EnableRegistration bool
AdminTenantId int
DigitalOceanAPIKey string
DigitalOceanRegion string
DigitalOceanImage string
DigitalOceanSSHAuthorizedKeys []ConfigSSHKey
GandiAPIKey string
SSHPrivateKeyFile string
BackblazeBucketName string
BackblazeKeyId string
BackblazeSecretKey string
ThresholdPort int
ThresholdManagementPort int
DatabaseConnectionString string
DatabaseType string
DatabaseSchema string
SMTP EmailService
}
type ConfigSSHKey struct {
Name string
PublicKey string
}
const isProduction = false
func main() {
_, err := time.LoadLocation("UTC")
if err != nil {
go postTelemetry("greenhouse-web", "", "admin", "", fmt.Sprintf("load UTC location failed: %s", err))
panic(errors.Wrap(err, "can't start the app because can't load UTC location"))
}
workingDirectory := determineWorkingDirectoryByLocatingConfigFile()
config := getConfig(workingDirectory)
model := initDatabase(config)
emailService := &config.SMTP
err = emailService.Initialize()
if err != nil {
go postTelemetry("greenhouse-web", "", "admin", strconv.Itoa(config.AdminTenantId), fmt.Sprintf("emailService.Initialize() failed: %s", err))
panic(err)
}
easypkiInstance := NewGreenhouseEasyPKI(model)
pkiService := pki.NewPKIService(easypkiInstance)
backendApp := initBackend(workingDirectory, config, pkiService, model, emailService)
ingressService := NewIngressService(config, backendApp, model)
frontendApp := initFrontend(workingDirectory, config, model, backendApp, emailService, ingressService)
scheduledTasks := NewScheduledTasks(ingressService, backendApp, model, config.AdminTenantId)
err = scheduledTasks.Initialize()
if err != nil {
// TODO should this be Fatalf??
go postTelemetry("greenhouse-web", "", "admin", strconv.Itoa(config.AdminTenantId), fmt.Sprintf("initialization process failed: %s", err))
log.Printf("Greenhouse's initialization process failed: \n%+v\n\n", err)
}
go (func(backendApp *BackendApp) {
defer (func() {
if r := recover(); r != nil {
go postTelemetry("greenhouse-web", "", "admin", strconv.Itoa(config.AdminTenantId), fmt.Sprintf("metric collection panic: %s", r))
fmt.Printf("backendApp: panic: %+v\n", r)
debug.PrintStack()
}
})()
for {
err := backendApp.ConsumeMetrics()
if err != nil {
firstLineOfError := strings.Split(fmt.Sprintf("metric collection failed: %s", err), "\n")[0]
go postTelemetry("greenhouse-web", "", "admin", strconv.Itoa(config.AdminTenantId), firstLineOfError)
log.Printf("metric collection failed: %+v\n", err)
}
time.Sleep(time.Second * 6)
}
})(backendApp)
AddAPIRoutesToFrontend(&frontendApp)
// TODO disable this for prod
if !isProduction {
go (func() {
for {
time.Sleep(time.Second)
frontendApp.reloadTemplates()
}
})()
// go (func() {
// var appUrl = fmt.Sprintf("http://localhost:%d", config.FrontendPort)
// var serverIsRunning = false
// var attempts = 0
// for !serverIsRunning && attempts < 15 {
// attempts++
// time.Sleep(time.Millisecond * 500)
// client := &http.Client{
// Timeout: time.Millisecond * 500,
// }
// response, err := client.Get(appUrl)
// if err == nil && response.StatusCode == 200 {
// serverIsRunning = true
// }
// }
// openUrl(appUrl)
// })()
}
go postTelemetry("greenhouse-web", "", "admin", strconv.Itoa(config.AdminTenantId), "greenhouse started up!")
go func() {
err = frontendApp.ListenAndServe()
go postTelemetry("greenhouse-web", "", "admin", strconv.Itoa(config.AdminTenantId), fmt.Sprintf("frontendApp.ListenAndServe(): %s", err))
panic(err)
}()
sigs := make(chan os.Signal, 10)
signal.Notify(sigs, unix.SIGTERM, unix.SIGINT, unix.SIGHUP)
done := make(chan bool, 1)
go func() {
sig := <-sigs
log.Printf("Greenhouse recieved signal: %s\n", sig)
go postTelemetry("greenhouse-web", "", "admin", strconv.Itoa(config.AdminTenantId), fmt.Sprintf("greenhouse recieved signal %s", sig.String()))
ingressService.StopGreenhouseDaemon()
attempts := 0
for attempts < 20 {
attempts++
time.Sleep(500 * time.Millisecond)
log.Println("Greenhouse is waiting greenhouse-daemon to stop...")
if !ingressService.GetGreenhouseDaemonProcessRunning() {
log.Printf("greenhouse-daemon has stopped\n")
done <- true
return
}
}
postTelemetry("greenhouse-web", "", "admin", strconv.Itoa(config.AdminTenantId), "greenhouse timed out waiting for greenhouse-daemon to stop! ")
done <- true
}()
<-done
fmt.Println("exiting")
}
func getConfig(workingDirectory string) *Config {
configBytes, err := ioutil.ReadFile(filepath.Join(workingDirectory, "config.json"))
if err != nil {
configBytes, err = ioutil.ReadFile(filepath.Join(workingDirectory, "config/config.json"))
if err != nil {
log.Fatalf("getConfig(): can't ioutil.ReadFile(\"config.json\" or \"config/config.json\") because %+v \n", err)
}
}
var config Config
err = json.Unmarshal(configBytes, &config)
if err != nil {
log.Fatalf("runServer(): can't json.Unmarshal(configBytes, &config) because %+v \n", err)
}
if config.AdminTenantId == 0 {
config.AdminTenantId = 1
}
lokiURL := os.Getenv("GREENHOUSE_LOKI_URL")
homepageMarkdownURL := os.Getenv("GREENHOUSE_HOMEPAGE_MARKDOWN_URL")
enableRegistrationString := os.Getenv("GREENHOUSE_ENABLE_REGISTRATION")
digitalOceanAPIKey := os.Getenv("GREENHOUSE_DIGITALOCEAN_API_KEY")
databaseConnectionString := os.Getenv("GREENHOUSE_DATABASE_CONNECTION_STRING")
databaseSchema := os.Getenv("GREENHOUSE_DATABASE_SCHEMA")
gandiAPIKey := os.Getenv("GREENHOUSE_GANDI_API_KEY")
sshPrivateKeyFile := os.Getenv("GREENHOUSE_SSH_PRIVATE_KEY_FILE")
backblazeBucketName := os.Getenv("GREENHOUSE_BACKBLAZE_BUCKET_NAME")
backblazeKeyId := os.Getenv("GREENHOUSE_BACKBLAZE_KEY_ID")
backblazeSecretKey := os.Getenv("GREENHOUSE_BACKBLAZE_SECRET_KEY")
smtpHost := os.Getenv("GREENHOUSE_SMTP_HOST")
smtpPortString := os.Getenv("GREENHOUSE_SMTP_PORT")
smtpUsername := os.Getenv("GREENHOUSE_SMTP_USERNAME")
smtpPassword := os.Getenv("GREENHOUSE_SMTP_PASSWORD")
smtpEncryption := os.Getenv("GREENHOUSE_SMTP_ENCRYPTION")
if lokiURL != "" {
config.LokiURL = lokiURL
}
if homepageMarkdownURL != "" {
config.HomepageMarkdownURL = homepageMarkdownURL
}
if enableRegistrationString != "" {
v, err := strconv.ParseBool(enableRegistrationString)
if err != nil {
log.Fatalf("getConfig(): can't convert enableRegistrationString '%s' to bool: %+v \n", enableRegistrationString, err)
}
config.EnableRegistration = v
}
if digitalOceanAPIKey != "" {
config.DigitalOceanAPIKey = digitalOceanAPIKey
}
if databaseConnectionString != "" {
config.DatabaseConnectionString = databaseConnectionString
}
if databaseSchema != "" {
config.DatabaseSchema = databaseSchema
}
if gandiAPIKey != "" {
config.GandiAPIKey = gandiAPIKey
}
if sshPrivateKeyFile != "" {
config.SSHPrivateKeyFile = sshPrivateKeyFile
}
if backblazeBucketName != "" {
config.BackblazeBucketName = backblazeBucketName
}
if backblazeKeyId != "" {
config.BackblazeKeyId = backblazeKeyId
}
if backblazeSecretKey != "" {
config.BackblazeSecretKey = backblazeSecretKey
}
if smtpHost != "" {
config.SMTP.Host = smtpHost
}
if smtpPortString != "" {
smtpPort, err := strconv.Atoi(smtpPortString)
if err != nil {
log.Fatalf("getConfig(): can't convert smtpPortString '%s' to int: %+v \n", smtpPortString, err)
}
config.SMTP.Port = smtpPort
}
if smtpUsername != "" {
config.SMTP.Username = smtpUsername
}
if smtpPassword != "" {
config.SMTP.Password = smtpPassword
}
if smtpEncryption != "" {
config.SMTP.Encryption = smtpEncryption
}
configToLog, _ := json.MarshalIndent(config, "", " ")
configToLogString := string(configToLog)
configToLogString = regexp.MustCompile(
`("DigitalOceanAPIKey": "[^"]{2})[^"]+([^"]{2}",)`,
).ReplaceAllString(
configToLogString,
"$1******$2",
)
configToLogString = regexp.MustCompile(
`("DatabaseConnectionString": "[^"]+ password=)[^" ]+( [^"]+",)`,
).ReplaceAllString(
configToLogString,
"$1******$2",
)
configToLogString = regexp.MustCompile(
`("Password": ")[^"]+(",)`,
).ReplaceAllString(
configToLogString,
"$1******$2",
)
configToLogString = regexp.MustCompile(
`("GandiAPIKey": ")[^"]+(",)`,
).ReplaceAllString(
configToLogString,
"$1******$2",
)
configToLogString = regexp.MustCompile(
`("BackblazeSecretKey": ")[^"]+(",)`,
).ReplaceAllString(
configToLogString,
"$1******$2",
)
log.Printf("🌱🏠 greenhouse is starting up using config:\n%s\n", configToLogString)
return &config
}
func determineWorkingDirectoryByLocatingConfigFile() string {
workingDirectory, err := os.Getwd()
if err != nil {
log.Fatalf("determineWorkingDirectoryByLocatingConfigFile(): can't os.Getwd(): %+v", err)
}
executableDirectory, err := getCurrentExecDir()
if err != nil {
log.Fatalf("determineWorkingDirectoryByLocatingConfigFile(): can't getCurrentExecDir(): %+v", err)
}
configFileLocation1 := filepath.Join(executableDirectory, "config.json")
configFileLocation2 := filepath.Join(workingDirectory, "config.json")
configFileLocation := configFileLocation1
configFileStat, err := os.Stat(configFileLocation)
workingDirectoryToReturn := executableDirectory
if err != nil || !configFileStat.Mode().IsRegular() {
configFileLocation = configFileLocation2
configFileStat, err = os.Stat(configFileLocation)
workingDirectoryToReturn = workingDirectory
}
if err != nil || !configFileStat.Mode().IsRegular() {
log.Fatalf("determineWorkingDirectoryByLocatingConfigFile(): no config file. checked %s and %s", configFileLocation1, configFileLocation2)
}
return workingDirectoryToReturn
}
func getCurrentExecDir() (dir string, err error) {
path, err := exec.LookPath(os.Args[0])
if err != nil {
fmt.Printf("exec.LookPath(%s) returned %s\n", os.Args[0], err)
return "", err
}
absPath, err := filepath.Abs(path)
if err != nil {
fmt.Printf("filepath.Abs(%s) returned %s\n", path, err)
return "", err
}
dir = filepath.Dir(absPath)
return dir, nil
}
func openUrl(url string) {
var err error
switch runtime.GOOS {
case "linux":
err = exec.Command("xdg-open", url).Start()
case "darwin":
err = exec.Command("open", url).Start()
case "windows":
err = exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start()
default:
err = fmt.Errorf("unsupported platform")
}
if err != nil {
fmt.Printf("can't open app in web browser because '%s'\n", err)
}
}