Browse Source

implementing external domain support for alpha version

master
forest 3 weeks ago
parent
commit
07f36321a2
14 changed files with 560 additions and 97 deletions
  1. +86
    -1
      backend.go
  2. +141
    -11
      db_model.go
  3. +2
    -2
      frontend.go
  4. +62
    -44
      frontend/alpha-profile.gotemplate.html
  5. +22
    -2
      frontend/static/greenhouse.css
  6. +6
    -0
      frontend/using-your-own-domain-name-with-greenhouse.gotemplate.html
  7. +2
    -9
      frontend_admin_panel.go
  8. +46
    -0
      frontend_profile.go
  9. +18
    -14
      ingress_service.go
  10. +11
    -3
      main.go
  11. +10
    -8
      public_api.go
  12. +148
    -2
      scheduled_tasks.go
  13. +0
    -1
      schema_versions/02_up_create_tenants_etc.sql
  14. +6
    -0
      schema_versions/03_up_scheduled_tasks.sql

+ 86
- 1
backend.go View File

@ -495,7 +495,92 @@ func getBillingTimeInfo() (int, int, time.Time, time.Time, float64) {
return billingMonth, billingYear, startOfBillingMonth, endOfBillingMonth, amountOfMonthElapsed
}
func (app *BackendApp) Rebalance() (bool, error) {
func (app *BackendApp) ValidateExternalDomains() error {
log.Printf("starting ValidateExternalDomains()...")
toValidate, err := app.Model.GetExternalDomains()
if err != nil {
return err
}
for _, tuple := range toValidate {
externalDomain := tuple[0]
personalDomain := tuple[1]
_, err := app.ValidateExternalDomain(externalDomain, personalDomain, true)
if err != nil {
log.Printf("failed looking up %s: %s", externalDomain, err)
}
}
log.Printf("finished ValidateExternalDomains()!")
return nil
}
func (app *BackendApp) ValidateExternalDomain(externalDomain, personalDomain string, updateDatabase bool) (bool, error) {
googleDNSResolver := &net.Resolver{
PreferGo: true,
Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
d := net.Dialer{
Timeout: time.Millisecond * time.Duration(10000),
}
return d.DialContext(ctx, network, "8.8.8.8:53")
},
}
quad9DNSResolver := &net.Resolver{
PreferGo: true,
Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
d := net.Dialer{
Timeout: time.Millisecond * time.Duration(10000),
}
return d.DialContext(ctx, network, "9.9.9.9:53")
},
}
for attempts := 0; attempts < 5; attempts++ {
cnameFromGoogle, googleErr := googleDNSResolver.LookupCNAME(context.Background(), externalDomain)
cnameFromQuad9, quad9Err := quad9DNSResolver.LookupCNAME(context.Background(), externalDomain)
cnameFromDefaultResolver, defaultErr := net.LookupCNAME(externalDomain)
if googleErr != nil && quad9Err != nil && defaultErr != nil {
if attempts >= 4 {
return false, defaultErr
}
} else {
personalDomainWithPeriodAtTheEnd := fmt.Sprintf("%s.", personalDomain)
googleIsValid := strings.HasSuffix(cnameFromGoogle, personalDomainWithPeriodAtTheEnd)
quad9IsValid := strings.HasSuffix(cnameFromQuad9, personalDomainWithPeriodAtTheEnd)
defaultIsValid := strings.HasSuffix(cnameFromDefaultResolver, personalDomainWithPeriodAtTheEnd)
log.Printf(
"ValidateExternalDomain(): %s --> %s,%s,%s\n(personalDomain=%s) (valid: %t,%t,%t)\n",
externalDomain, cnameFromGoogle, cnameFromQuad9, cnameFromDefaultResolver, personalDomain, googleIsValid, quad9IsValid, defaultIsValid,
)
if googleIsValid || quad9IsValid || defaultIsValid {
if updateDatabase {
err := app.Model.MarkExternalDomainAsVerified(externalDomain)
if err != nil {
return false, err
}
}
return true, nil
} else {
return false, nil
}
}
}
return false, errors.New("ran out of attempts and niether succeeded nor failed, this should never happen :\\")
}
func (app *BackendApp) Rebalance() error {
log.Println("Starting Rebalance Process... ")
completed, err := app.tryRebalance()
if !completed && err == nil {
log.Println("Rebalance not complete yet. Running backendApp.tryRebalance() again")
_, err := app.tryRebalance()
return err
}
return err
}
func (app *BackendApp) tryRebalance() (bool, error) {
billingYear, billingMonth, _, _, amountOfMonthElapsed := getBillingTimeInfo()


+ 141
- 11
db_model.go View File

@ -59,6 +59,7 @@ type TenantInfo struct {
TunnelSettings *TunnelSettings
Deactivated bool
APITokens []APIToken
ExternalDomains []ExternalDomain
}
type TenantVPSInstance struct {
@ -79,6 +80,11 @@ type APIToken struct {
LastUsed time.Time
}
type ExternalDomain struct {
DomainName string
IsValid bool
}
const DomainVerificationPollingInterval = time.Hour
func (i *TenantVPSInstance) GetVPSInstanceId() string {
@ -86,7 +92,7 @@ func (i *TenantVPSInstance) GetVPSInstanceId() string {
}
func initDatabase(config *Config) *DBModel {
desiredSchemaVersion := 2
desiredSchemaVersion := 3
db, err := sql.Open(config.DatabaseType, config.DatabaseConnectionString)
if err != nil {
@ -251,7 +257,7 @@ func (model *DBModel) VerifyEmail(token string, tenantId int) error {
if err != nil && err != sql.ErrNoRows {
log.Printf("VerifyEmail(): query error %+v", err)
}
if err != nil || time.Now().After(expires) {
if err != nil || time.Now().UTC().After(expires) {
return errors.New("email verification token was invalid or expired")
} else {
model.DB.Exec("DELETE FROM email_verification_tokens WHERE token = $1", token)
@ -376,6 +382,7 @@ func (model *DBModel) SetFreeSubdomain(tenantId int, subdomain string) (bool, er
if err != nil {
return false, errors.Wrap(err, "SetFreeSubdomain(): ")
}
defer rows.Close()
if rows.Next() {
return true, nil
@ -463,6 +470,7 @@ func (model *DBModel) GetVPSInstances() (map[string]*VPSInstance, error) {
if err != nil {
return nil, errors.Wrap(err, "GetVPSInstances(): ")
}
defer rows.Close()
toReturn := map[string]*VPSInstance{}
for rows.Next() {
@ -529,8 +537,9 @@ func (model *DBModel) GetTenants() (map[int]*TenantInfo, error) {
if err != nil {
return nil, errors.Wrap(err, "GetTenants(): ")
}
defer rows.Close()
verificationCutoff := time.Now().Add(-(DomainVerificationPollingInterval + time.Minute))
verificationCutoff := time.Now().UTC().Add(-(DomainVerificationPollingInterval + time.Minute))
authorizedDomains := map[int][]string{}
for rows.Next() {
var tenantId int
@ -551,10 +560,10 @@ func (model *DBModel) GetTenants() (map[int]*TenantInfo, error) {
}
rows, err = model.DB.Query(`SELECT id, created, subdomain, service_limit_cents, port_start, port_end, port_bucket FROM tenants`)
if err != nil {
return nil, errors.Wrap(err, "GetTenants(): ")
}
defer rows.Close()
toReturn := map[int]*TenantInfo{}
for rows.Next() {
@ -612,9 +621,11 @@ func (model *DBModel) GetTenant(tenantId int) (*TenantInfo, error) {
if err != nil {
return nil, errors.Wrapf(err, "GetTenant(%d): ", tenantId)
}
defer rows.Close()
verificationCutoff := time.Now().Add(-(DomainVerificationPollingInterval + time.Minute))
verificationCutoff := time.Now().UTC().Add(-(DomainVerificationPollingInterval + time.Minute))
authorizedDomains := []string{}
externalDomains := []ExternalDomain{}
for rows.Next() {
var domainName string
var lastVerified time.Time
@ -623,26 +634,30 @@ func (model *DBModel) GetTenant(tenantId int) (*TenantInfo, error) {
return nil, errors.Wrapf(err, "GetTenant(%d): ", tenantId)
}
if lastVerified.After(verificationCutoff) {
verified := lastVerified.After(verificationCutoff)
if verified {
authorizedDomains = append(authorizedDomains, domainName)
}
externalDomains = append(externalDomains, ExternalDomain{DomainName: domainName, IsValid: verified})
}
rows, err = model.DB.Query(
tokensRows, err := model.DB.Query(
`SELECT key_name, hashed_token, active, created, last_used FROM api_tokens WHERE tenant_id = $1`,
tenantId,
)
if err != nil {
return nil, errors.Wrapf(err, "GetTenant(%d): ", tenantId)
}
defer tokensRows.Close()
apiTokens := []APIToken{}
for rows.Next() {
for tokensRows.Next() {
var keyName string
var hashedToken string
var active bool
var created time.Time
var lastUsed time.Time
err := rows.Scan(&keyName, &hashedToken, &active, &created, &lastUsed)
err := tokensRows.Scan(&keyName, &hashedToken, &active, &created, &lastUsed)
if err != nil {
return nil, errors.Wrapf(err, "GetTenant(%d): ", tenantId)
}
@ -703,11 +718,79 @@ func (model *DBModel) GetTenant(tenantId int) (*TenantInfo, error) {
PortEnd: portEnd,
AuthorizedDomains: authorizedDomains,
},
APITokens: apiTokens,
APITokens: apiTokens,
ExternalDomains: externalDomains,
}, nil
}
func (model *DBModel) AddExternalDomain(tenantId int, externalDomain string) error {
_, err := model.DB.Exec("INSERT INTO external_domains (tenant_id, domain_name) VALUES ($1, $2)", tenantId, externalDomain)
if err != nil {
return errors.Wrap(err, "AddExternalDomain(): ")
}
return nil
}
func (model *DBModel) GetExternalDomains() ([][]string, error) {
rows, err := model.DB.Query(`SELECT id, subdomain FROM tenants`)
if err != nil {
return nil, errors.Wrap(err, "GetExternalDomains(): ")
}
defer rows.Close()
personalDomainsByTenant := map[int]string{}
for rows.Next() {
var tenantId int
var subdomain *string
err := rows.Scan(&tenantId, &subdomain)
if err != nil {
return nil, errors.Wrap(err, "GetExternalDomains(): ")
}
if subdomain != nil {
personalDomainsByTenant[tenantId] = fmt.Sprintf("%s.%s", *subdomain, freeSubdomainDomain)
}
}
externalDomainsRows, err := model.DB.Query(`SELECT tenant_id, domain_name FROM external_domains`)
if err != nil {
return nil, errors.Wrap(err, "GetTenants(): ")
}
defer externalDomainsRows.Close()
toReturn := [][]string{}
for externalDomainsRows.Next() {
var tenantId int
var externalDomain string
err := externalDomainsRows.Scan(&tenantId, &externalDomain)
if err != nil {
return nil, errors.Wrap(err, "GetExternalDomains(): ")
}
personalDomain, hasPersonalDomain := personalDomainsByTenant[tenantId]
if hasPersonalDomain {
toReturn = append(toReturn, []string{externalDomain, personalDomain})
}
}
return toReturn, nil
}
func (model *DBModel) MarkExternalDomainAsVerified(externalDomain string) error {
result, err := model.DB.Exec("UPDATE external_domains SET last_verified = NOW() WHERE domain_name = $1", externalDomain)
if err != nil {
return errors.Wrap(err, "MarkExternalDomainAsVerified(): ")
}
affected, err := result.RowsAffected()
if err != nil {
return errors.Wrap(err, "MarkExternalDomainAsVerified(): ")
}
if affected != 1 {
return errors.Errorf("zero rows were affected by MarkExternalDomainAsVerified('%s')", externalDomain)
}
return nil
}
func (model *DBModel) GetTenantVPSInstanceRows(billingYear, billingMonth int) ([]*TenantVPSInstance, error) {
// tenantCondition := ""
// if tenantId > 0 {
@ -726,10 +809,10 @@ func (model *DBModel) GetTenantVPSInstanceRows(billingYear, billingMonth int) ([
WHERE billing_year = $1 AND billing_month = $2
`, billingYear, billingMonth,
)
if err != nil {
return nil, errors.Wrap(err, "GetTenantVPSInstanceRows(): ")
}
defer rows.Close()
toReturn := []*TenantVPSInstance{}
for rows.Next() {
@ -852,6 +935,8 @@ func (model *DBModel) GetTenantUsageTotal(tenantId int, billingYear, billingMont
if err != nil {
return 0, errors.Wrap(err, "GetTenantUsage(): ")
}
defer rows.Close()
var monthlyBytes int64
for rows.Next() {
var bytes int64
@ -873,6 +958,8 @@ func (model *DBModel) GetTenantUsageMetrics(tenantId int, start, end time.Time)
if err != nil {
return nil, errors.Wrap(err, "GetTenantUsageMetrics(): ")
}
defer rows.Close()
toReturn := map[time.Time]int64{}
for rows.Next() {
var measured time.Time
@ -943,6 +1030,47 @@ func (model *DBModel) SaveInstanceConfiguration(
return errors.Wrap(err, "SaveInstanceConfiguration(): ")
}
func (model *DBModel) PollScheduledTask(name string, every time.Duration) (bool, error) {
rows, err := model.DB.Query(`SELECT last_started, last_succeeded FROM scheduled_tasks WHERE name = $1`, name)
if err != nil {
return false, errors.Wrap(err, "PollScheduledTask(): ")
}
defer rows.Close()
if rows.Next() {
var lastStarted time.Time
var lastSucceeded time.Time
err := rows.Scan(&lastStarted, &lastSucceeded)
//log.Printf("st %s, sc %s, since: %s\n", lastStarted, lastSucceeded, time.Since(lastSucceeded))
if err != nil {
return false, errors.Wrap(err, "PollScheduledTask(): ")
}
if time.Since(lastSucceeded) > every {
_, err := model.DB.Exec("UPDATE scheduled_tasks SET last_started = $1 WHERE name = $2", time.Now().UTC(), name)
if err != nil {
return false, errors.Wrap(err, "PollScheduledTask(): ")
}
return true, nil
}
return false, nil
} else {
unixEpoch := time.Date(1970, 1, 1, 0, 0, 0, 1, time.Now().UTC().Location())
_, err := model.DB.Exec("INSERT INTO scheduled_tasks (name, last_succeeded) VALUES ($1, $2)", name, unixEpoch)
if err != nil {
return false, errors.Wrap(err, "PollScheduledTask(): ")
}
return true, nil
}
}
func (model *DBModel) ScheduledTaskCompleted(name string) error {
_, err := model.DB.Exec("UPDATE scheduled_tasks SET last_succeeded = $1 WHERE name = $2", time.Now().UTC(), name)
if err != nil {
return errors.Wrap(err, "PollScheduledTask(): ")
}
return nil
}
func (model *DBModel) PutKeyPair(caName, name string, key, cert []byte) error {
_, err := model.DB.Exec(`
INSERT INTO pki_key_pairs (ca_name, name, key_bytes, cert_bytes)
@ -968,6 +1096,8 @@ func (model *DBModel) GetServerKeyPair(caName, name string) ([]byte, []byte, err
if err != nil {
return nil, nil, errors.Wrap(err, "GetServerKeyPair(): ")
}
defer rows.Close()
for rows.Next() {
err = rows.Scan(&key, &cert)
if err != nil {


+ 2
- 2
frontend.go View File

@ -51,7 +51,7 @@ type FrontendApp struct {
AdminTenantId int
}
func initFrontend(workingDirectory string, config *Config, model *DBModel, backend *BackendApp, emailService *EmailService) FrontendApp {
func initFrontend(workingDirectory string, config *Config, model *DBModel, backend *BackendApp, emailService *EmailService, ingress *IngressService) FrontendApp {
app := FrontendApp{
Port: config.FrontendPort,
@ -63,7 +63,7 @@ func initFrontend(workingDirectory string, config *Config, model *DBModel, backe
EmailService: emailService,
Model: model,
Backend: backend,
Ingress: NewIngressService(config, model),
Ingress: ingress,
HTMLTemplates: map[string]*template.Template{},
PasswordHashSalt: "Ko0jOdSCzEyDtK4rmoocfcR9LxwOrIZsaVPBjImkb6AhRW6yNSmgsU122ArU1URBjcJ1EnskZ5r7",
SessionCache: map[string]*Session{},


+ 62
- 44
frontend/alpha-profile.gotemplate.html View File

@ -13,11 +13,9 @@
<form class="profile-form" method="POST" action="#">
<label for="subdomain">Personal Subdomain: </label>
<div class="horizontal align-center margin-bottom">
<input class=" subdomain right-align" type="text" name="subdomain" id="subdomain" placeholder="subdomain" value="{{ .Subdomain }}">
</input><span>.greenhouseusers.com</span>
</div>
<div class="horizontal flex-grow-2">
<input class=" subdomain text-align-right" type="text" name="subdomain" id="subdomain" placeholder="subdomain" value="{{ .Subdomain }}">
</input><span>.{{ .SubdomainDomain }}</span>
<div class="flex-grow-2">&nbsp;</div>
<input type="hidden" name="hashOfSessionId" value="{{ .HashOfSessionId }}"/>
<input type="hidden" name="action" value="update_free_subdomain"/>
<input type="submit" value="Update"></input>
@ -27,31 +25,42 @@
<div class="horizontal justify-right align-center margin-bottom">
<form class="profile-form" method="POST" action="#">
{{ if or .CustomDomains .NewCustomDomain }}
<label for="custom-domains">Custom Domains: </label>
<div id="custom-domains">
{{ range $domain := .CustomDomains }}
<div class="custom-domain"><span>{{ $domain.DomainName }}</span></div>
{{ end }}
{{ if .NewCustomDomain }}
<div class="custom-domain new-custom-domain"><div>{{ .NewCustomDomain }}</div></div>
{{ if .ExternalDomains }}
<label for="external-domains">External Domains: </label>
<div id="external-domains">
{{ range $domain := .ExternalDomains }}
<div class="external-domain {{ if not $domain.IsValid }}invalid{{ end }}">
<span>{{ $domain.DomainName }} {{ if not $domain.IsValid }}(invalid!){{ end }}</span>
</div>
{{ end }}
</div>
{{ else }}
<p>You do not have any Custom Domains yet.</p>
<p>You do not have any External Domains yet.</p>
{{ end }}
<label for="subdomain">New Custom Domains: </label>
<label for="external-domain">Add External Domain: </label>
<div class="horizontal align-center margin-bottom">
<input class=" subdomain right-align" type="text" name="subdomain" id="subdomain" placeholder="example.com" value="">
<input type="text" name="external-domain" id="external-domain" placeholder="example.com" value="">
<div class="flex-grow-2">&nbsp;</div>
<input type="hidden" name="hashOfSessionId" value="{{ .HashOfSessionId }}"/>
<input type="hidden" name="action" value="add_external_domain"/>
<input type="submit" value="Add"></input>
</div>
<div class="horizontal flex-grow-2">
<input type="hidden" name="hashOfSessionId" value="{{ .HashOfSessionId }}"/>
<input type="hidden" name="action" value="update_free_subdomain"/>
<input type="submit" value="Update"></input>
<div class="horizontal justify-right align-right margin-bottom">
<span class="fine-print text-align-right">
If you wish to use your own domain name with Greenhouse, <br/>
you'll have to perform some DNS configuration before adding it here.<br/>
For more information, see
<a href="/using-your-own-domain-name-with-greenhouse">
Using Your Own Domain Name with Greenhouse
</a>
<br/>
</span>
</div>
</form>
</div>
<p>
Your bandwidth usage:
@ -96,37 +105,46 @@
</div>
</div>
<div class="tab-container two-tabs">
<input type="radio" name="client" value="download" id="client-download" checked="checked"></input>
<label class="tab" for="client-download">download the greenhouse client software</label>
<div class="vertical tab-content">
<ul>
<li>Use with any number of servers / domains</li>
<li>Primary supported protocols: HTTP, HTTPS, anything-over-TLS</li>
<li>20 Arbitrarily-assigned static TCP ports for use with SSH & other legacy protocols</li>
<li>UDP is currently not supported, but we can probably add it if you need it!</li>
</ul>
<div>
<h3>&nbsp;&nbsp;&nbsp;download the greenhouse client software</h3>
<div class="tab-container three-tabs">
<input type="radio" name="client" value="windows" id="client-windows" checked="checked"></input>
<label class="tab" for="client-windows">
<div class="horizontal align-center">
<img class="os-image" src="static/windows.png"/> windows
</div>
</label>
<div class="vertical tab-content">
<p>
<b>Free Tier:</b> usage below 3 GB is free for the first 3 months, no credit/debit card required. <br/>
Once you consume 3 GB of bandwidth or 3 months elapse since opening the account, <br/>
service will be suspended until you add a payment method.
</p>
<p>
<b>Price:</b> $0.01 / GB (one cent per gigabyte of bandwidth, billed monthly)
how to install on windows
</p>
</div>
<input type="radio" name="client" value="linux" id="client-linux" checked="checked"></input>
<label class="tab" for="client-linux">
<div class="horizontal align-center">
<img class="os-image" src="static/tux.png"/> linux
</div>
</label>
<div class="vertical tab-content">
<p>
Our payment processor, Stripe, has a <a href="https://stripe.com/docs/api/charges">minimum charge of $0.50 (fifty cents)</a>, so your account will be <br/>
charged at least $0.50 during the first month, however, you won't be billed again until you have used <br/>
at least $0.50 worth of bandwidth.
how to install on linux
</p>
</div>
<input type="radio" name="client" value="macos" id="client-macos" checked="checked"></input>
<label class="tab" for="client-macos">
<div class="horizontal align-center">
<img class="os-image" src="static/macos.png"/> mac os
</div>
</label>
<div class="vertical tab-content">
<p>
If you want to host an email server, you will need to choose the "dedicated server" option, <br/>
because the email protocol (SMTP) <a href="https://greenhouse.server.garden/how-it-works#email">requires a dedicated IP address for each domain</a>.
how to install on mac os
</p>
</div>
</div>
</div>
</div>

+ 22
- 2
frontend/static/greenhouse.css View File

@ -165,6 +165,12 @@ pre.flash.info {
margin-right: 0.3em;
}
input,button,textarea,
input:hover,button:hover,textarea:hover,
input:focus,button:focus,textarea:focus {
outline: 0;
}
input,
.js-form-submit-button {
box-sizing: content-box;
@ -178,7 +184,7 @@ input:focus {
border: 2px solid #029dfd;
padding: calc(0.5rem - 1px);
}
input.right-align {
.text-align-right {
text-align: right;
}
input.short {
@ -247,7 +253,8 @@ form.vertical .js-form-submit-button {
padding: 0.7em;
}
.api-token {
.api-token,
.external-domain {
border-bottom: 1px solid #44332277;
margin: 0.5em;
padding: 0.05em;
@ -263,6 +270,16 @@ form.vertical .js-form-submit-button {
font-weight: bold;
}
.os-image {
height: 1.5em;
margin-right: 0.5em;
}
.invalid {
color: red;
}
@ -388,6 +405,9 @@ li {
background: white;
box-shadow: 0.1rem 0.5rem 1rem 0 #00000020;
}
/* this makes the tab-content for the selected tab visible */
input[type="radio"].tab:checked+label {
display: block;


+ 6
- 0
frontend/using-your-own-domain-name-with-greenhouse.gotemplate.html View File

@ -0,0 +1,6 @@
If you wish to use your own domain name (<code>example.com</code>) with Greenhouse, you must first<br/>
create a <code>CNAME</code> resource record pointing to <code>{{ .Subdomain }}.{{ .SubdomainDomain }}</code><br/>
Note that you can only create a CNAME record for a subdomain on your domain, for example <code>www.example.com</code>.
So you would probably also want to set up a web redirect to on the base domain

+ 2
- 9
frontend_admin_panel.go View File

@ -68,16 +68,9 @@ func registerAdminPanelRoutes(app *FrontendApp) {
app.setFlash(responseWriter, session, "info", "rebalance has been kicked off in the background\n")
go (func() {
log.Println("Starting backendApp.Rebalance()")
completed, err := app.Backend.Rebalance()
err := app.Backend.Rebalance()
if err != nil {
log.Printf("Rebalance failed: %+v\n", err)
} else if !completed {
log.Println("Rebalance not complete yet. Running backendApp.Rebalance() again")
_, err := app.Backend.Rebalance()
if err != nil {
log.Printf("Rebalance failed: %+v\n", err)
}
log.Printf("\nrebalance failed! %+v\n\n", err)
}
})()


+ 46
- 0
frontend_profile.go View File

@ -88,6 +88,48 @@ func registerProfileRoutes(app *FrontendApp) {
successMessage := fmt.Sprintf("Success! Your personal subdomain is now '%s.%s'\n", postedFreeSubdomain, freeSubdomainDomain)
app.setFlash(responseWriter, session, "info", successMessage)
} else if action == "add_external_domain" {
postedExternalDomain := strings.ToLower(request.PostFormValue("external-domain"))
// https://mkyong.com/regular-expressions/domain-name-regular-expression-example/
domainRegex := regexp.MustCompile("^([A-Za-z0-9][A-Za-z0-9-]{0,63}[A-Za-z0-9]?\\.)+[A-Za-z]{2,6}$")
if !domainRegex.MatchString(postedExternalDomain) {
app.setFlash(
responseWriter, session, "error",
fmt.Sprintf("the domain '%s' appeared to be invalid", postedExternalDomain),
)
http.Redirect(responseWriter, request, "/profile", http.StatusFound)
return
}
usersPersonalSubdomain := fmt.Sprintf("%s.%s", tenant.Subdomain, freeSubdomainDomain)
valid, err := app.Backend.ValidateExternalDomain(postedExternalDomain, usersPersonalSubdomain, false)
if err != nil {
errorMessage := fmt.Sprintf("unable to update your subdomain: %s", err)
log.Printf("%s: %+v", errorMessage, err)
app.setFlash(responseWriter, session, "error", errorMessage)
http.Redirect(responseWriter, request, "/profile", http.StatusFound)
return
}
if !valid {
app.setFlash(responseWriter, session, "error", fmt.Sprintf(
"the domain '%s' does not appear to have a CNAME record pointing to '%s'. Either you have not created the CNAME record set yet, it was not created correctly, or not enough time has elapsed for the DNS record change to propagate.",
postedExternalDomain, usersPersonalSubdomain,
))
http.Redirect(responseWriter, request, "/profile", http.StatusFound)
return
}
err = app.Model.AddExternalDomain(session.TenantId, postedExternalDomain)
if err != nil {
errorMessage := "unable to update your subdomain: internal server error"
log.Printf("%s: %+v", errorMessage, err)
app.setFlash(responseWriter, session, "error", errorMessage)
http.Redirect(responseWriter, request, "/profile", http.StatusFound)
return
}
app.setFlash(responseWriter, session, "info", fmt.Sprintf("Success! '%s' has been added as an external domain. You may now create tunnels from '%s' using any of your greenhouse clients. \n", postedExternalDomain, postedExternalDomain))
} else if action == "create_api_token" {
keyName := strings.TrimSpace(request.PostFormValue("key_name"))
@ -131,6 +173,8 @@ func registerProfileRoutes(app *FrontendApp) {
data := struct {
Subdomain string
SubdomainDomain string
ExternalDomains []ExternalDomain
APITokens []APIToken
NewAPIToken string
NewAPITokenName string
@ -142,6 +186,8 @@ func registerProfileRoutes(app *FrontendApp) {
HashOfSessionId string
}{
Subdomain: tenant.Subdomain,
SubdomainDomain: freeSubdomainDomain,
ExternalDomains: tenant.ExternalDomains,
APITokens: apiTokens,
NewAPIToken: newAPIToken,
NewAPITokenName: newAPITokenName,


+ 18
- 14
ingress_service.go View File

@ -36,12 +36,17 @@ type GUITunnel struct {
}
type GreenhouseDaemonStatus struct {
NeedsAPIToken bool `json:"needs_api_token"`
HashedToken string `json:"hashed_api_token"`
NeedsAPIToken bool `json:"needs_api_token"`
HashedToken string `json:"hashed_api_token"`
ApplyConfigStatuses []string `json:"apply_config_statuses"`
ApplyConfigStatusIndex int `json:"apply_config_status_index"`
ApplyConfigStatusError string `json:"apply_config_status_error"`
}
const adminThresholdNodeId = "greenhouse_internal_node"
const greenhouseExternalDomain = "greenhouse-alpha.server.garden"
func NewIngressService(config *Config, model *DBModel) *IngressService {
toReturn := &IngressService{
Model: model,
@ -131,22 +136,22 @@ func (service *IngressService) StartGreenhouseDaemon() error {
}
func (service *IngressService) GetGreenhouseDaemonStatus() (string, error) {
func (service *IngressService) GetGreenhouseDaemonStatus() (*GreenhouseDaemonStatus, error) {
responseBytes, err := service.MyHTTP200("GET", "http://unix/status", nil, nil)
if err != nil {
return "", err
return nil, err
}
var responseStatus GreenhouseDaemonStatus
err = json.Unmarshal(responseBytes, &responseStatus)
if err != nil {
return nil, err
}
return string(responseBytes), nil
return &responseStatus, nil
}
func (service *IngressService) ConfigureGreenhouseDaemon() error {
responseBytes, err := service.MyHTTP200("GET", "http://unix/status", nil, nil)
if err != nil {
return err
}
var responseStatus GreenhouseDaemonStatus
err = json.Unmarshal(responseBytes, &responseStatus)
responseStatus, err := service.GetGreenhouseDaemonStatus()
if err != nil {
return err
}
@ -214,9 +219,8 @@ func (service *IngressService) ConfigureGreenhouseDaemon() error {
greenhouseGUITunnels := []GUITunnel{
{
Protocol: "https",
HasSubdomain: true,
Subdomain: "greenhouse",
Domain: fmt.Sprintf("%s.%s", tenant.Subdomain, freeSubdomainDomain),
HasSubdomain: false,
Domain: "greenhouse-alpha.server.garden",
DestinationType: "local_port",
DestinationPort: service.FrontendPort,
},


+ 11
- 3
main.go View File

@ -64,12 +64,19 @@ func main() {
easypkiInstance := NewGreenhouseEasyPKI(model)
pkiService := pki.NewPKIService(easypkiInstance)
ingressService := NewIngressService(config, model)
backendApp := initBackend(workingDirectory, config, pkiService, model, emailService)
frontendApp := initFrontend(workingDirectory, config, model, backendApp, emailService)
frontendApp := initFrontend(workingDirectory, config, model, backendApp, emailService, ingressService)
AddAPIRoutesToFrontend(&frontendApp)
scheduledTasks := NewScheduledTasks(ingressService, backendApp, model)
err = scheduledTasks.Initialize()
if err != nil {
// TODO should this be Fatalf??
log.Printf("Greenhouse's initialization process failed: \n%+v\n\n", err)
}
go (func(backendApp *BackendApp) {
defer (func() {
@ -89,8 +96,9 @@ func main() {
})(backendApp)
// TODO disable this for prod
AddAPIRoutesToFrontend(&frontendApp)
// TODO disable this for prod
if !isProduction {
go (func() {
for {


+ 10
- 8
public_api.go View File

@ -40,13 +40,15 @@ func AddAPIRoutesToFrontend(app *FrontendApp) {
return
}
tenant, err := app.Model.GetTenant(user.TenantId)
if err != nil {
app.unhandledError(responseWriter, err)
return
}
// used to use fmt.Sprintf("%s.%s", tenant.Subdomain, freeSubdomainDomain) as the greenhouseDomain
// tenant, err := app.Model.GetTenant(user.TenantId)
// if err != nil {
// app.unhandledError(responseWriter, err)
// return
// }
clientConfig, err := app.Backend.ThresholdProvisioning.GetClientConfig(
user.TenantId, fmt.Sprintf("%s.%s", tenant.Subdomain, freeSubdomainDomain), newNodeId, user.APIToken,
user.TenantId, greenhouseExternalDomain, newNodeId, user.APIToken,
)
if err != nil {
app.unhandledError(responseWriter, err)
@ -68,8 +70,8 @@ func handleWithAPIToken(app *FrontendApp, path string, handler func(http.Respons
app.Router.HandleFunc(path, func(responseWriter http.ResponseWriter, request *http.Request) {
authorizationHeader := request.Header.Get("Authorization")
apiToken := strings.TrimPrefix(authorizationHeader, "Bearer ")
hasCorrectPrefix := !strings.HasPrefix(authorizationHeader, "Bearer ")
if hasCorrectPrefix || len(apiToken) < 10 {
hasCorrectPrefix := strings.HasPrefix(authorizationHeader, "Bearer")
if !hasCorrectPrefix || len(apiToken) < 10 {
log.Printf("authorizationHeader has invalid format: length=%d, hasCorrectPrefix=%t", len(apiToken), hasCorrectPrefix)
http.Error(responseWriter, "Unauthorized", http.StatusUnauthorized)
return


+ 148
- 2
scheduled_tasks.go View File

@ -1,8 +1,154 @@
package main
import (
"log"
"time"
errors "git.sequentialread.com/forest/pkg-errors"
)
type ScheduledTasks struct {
Backend *BackendApp
Ingress *IngressService
DB *DBModel
Registry map[string]ScheduledTask
}
type ScheduledTask struct {
Every time.Duration
Action func() error
}
func NewScheduledTasks(ingress *IngressService, backendApp *BackendApp, dbModel *DBModel) *ScheduledTasks {
return &ScheduledTasks{
Ingress: ingress,
Backend: backendApp,
DB: dbModel,
Registry: map[string]ScheduledTask{},
}
}
func (tasks *ScheduledTasks) Initialize() error {
var err error
// TODO this needs to run only if it hasn't ran recently.. ?
err = tasks.Register("rebalance", time.Hour, func() error { return tasks.Backend.Rebalance() })
if err != nil {
return errors.Wrap(err, "could not register rebalance task: ")
}
err = tasks.Ingress.StartGreenhouseDaemon()
if err != nil {
return errors.Wrap(err, "could not start greenhouse-daemon: ")
}
// wait for daemon to become responsive
startedWaitingForDaemon := time.Now()
daemonResponded := false
for !daemonResponded && time.Since(startedWaitingForDaemon) < (time.Second*10) {
time.Sleep(time.Millisecond * 500)
_, err = tasks.Ingress.GetGreenhouseDaemonStatus()
if err != nil {
log.Printf("could not reach greenhouse-daemon: %s\n", err)
} else {
daemonResponded = true
}
}
if !daemonResponded {
return errors.Wrap(err, "greenhouse-daemon never responded to requests after 10 seconds: ")
}
// TODO this needs to run only if the config changes...?
// err = tasks.Ingress.ConfigureGreenhouseDaemon()
// if err != nil {
// return errors.Wrap(err, "could not configure the greenhouse-daemon: ")
// }
lastApplyConfigStatusIndex := -1
startedWaitingForDaemon = time.Now()
daemonApplyConfigProcessFinished := false
for !daemonApplyConfigProcessFinished && time.Since(startedWaitingForDaemon) < (time.Second*10) {
time.Sleep(time.Millisecond * 500)
status, err := tasks.Ingress.GetGreenhouseDaemonStatus()
if err != nil {
return errors.New("greenhouse-daemon stopped responding to requests. Did it crash?")
} else if status.NeedsAPIToken {
return errors.New("greenhouse-daemon configuration seems to have failed: I gave it an API token and its still complaining that it needs one")
} else if status.ApplyConfigStatusError != "" {
return errors.New(status.ApplyConfigStatusError)
} else if status.ApplyConfigStatusIndex > lastApplyConfigStatusIndex {
for lastApplyConfigStatusIndex < status.ApplyConfigStatusIndex {
lastApplyConfigStatusIndex++
if lastApplyConfigStatusIndex < len(status.ApplyConfigStatuses) {
log.Println(status.ApplyConfigStatuses[lastApplyConfigStatusIndex])
}
}
if lastApplyConfigStatusIndex >= len(status.ApplyConfigStatuses) {
daemonApplyConfigProcessFinished = true
}
startedWaitingForDaemon = time.Now()
}
}
if !daemonApplyConfigProcessFinished {
return errors.New("greenhouse-daemon never finished applying its config. Timed out after no activity for 10 seconds.")
}
err = tasks.Register("validate-external-domains", DomainVerificationPollingInterval, func() error { return tasks.Backend.ValidateExternalDomains() })
if err != nil {
return errors.Wrap(err, "could not register validate-external-domains task: ")
}
log.Println("🌱🏠 greenhouse has initialized successfully!")
go (func() {
for {
time.Sleep(time.Second * 10)
for name, scheduledTask := range tasks.Registry {
name := name
scheduledTask := scheduledTask
needsToBeRun, err := tasks.DB.PollScheduledTask(name, scheduledTask.Every)
if err != nil {
log.Printf("DB.PollScheduledTask('%s') failed: %s\n", name, err)
continue
}
if needsToBeRun {
go (func(name string, scheduledTask ScheduledTask) {
err := scheduledTask.Action()
if err != nil {
log.Printf("scheduledTask (%s) Action() failed: %+v\n", name, err)
} else {
err := tasks.DB.ScheduledTaskCompleted(name)
if err != nil {
log.Printf("DB.ScheduledTaskCompleted('%s') failed: %s\n", name, err)
}
}
})(name, scheduledTask)
}
}
}
})()
return nil
}
func NewScheduledTasks() *ScheduledTasks {
return &ScheduledTasks{}
func (tasks *ScheduledTasks) Register(name string, every time.Duration, action func() error) error {
tasks.Registry[name] = ScheduledTask{
Every: every,
Action: action,
}
needsToBeRun, err := tasks.DB.PollScheduledTask(name, every)
if err != nil {
return err
}
if needsToBeRun {
err := action()
if err != nil {
return err
}
err = tasks.DB.ScheduledTaskCompleted(name)
if err != nil {
return err
}
}
return nil
}

+ 0
- 1
schema_versions/02_up_create_tenants_etc.sql View File

@ -78,7 +78,6 @@ CREATE TABLE session_cookies (
CREATE TABLE external_domains (
tenant_id INTEGER REFERENCES tenants(id) ON DELETE RESTRICT,
domain_name TEXT NOT NULL UNIQUE,
verification_token TEXT NOT NULL,
last_verified TIMESTAMP NOT NULL DEFAULT NOW(),
created TIMESTAMP NOT NULL DEFAULT NOW()
);


+ 6
- 0
schema_versions/03_up_scheduled_tasks.sql View File

@ -0,0 +1,6 @@
CREATE TABLE scheduled_tasks (
name TEXT PRIMARY KEY NOT NULL,
last_started TIMESTAMP NOT NULL DEFAULT NOW(),
last_succeeded TIMESTAMP NOT NULL
);

Loading…
Cancel
Save