🌱🏠 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.

524 lines
18 KiB

package main
import (
"bytes"
"crypto/sha256"
"encoding/json"
"fmt"
"io/ioutil"
"log"
"net/http"
"net/url"
"regexp"
"sort"
"strconv"
"strings"
"time"
chart "github.com/wcharczuk/go-chart/v2"
chartdrawing "github.com/wcharczuk/go-chart/v2/drawing"
os_from_user_agent_header "zgo.at/gadget"
)
type LokiResponse struct {
Status string `json:"status"`
Data LokiData `json:"data"`
}
type LokiData struct {
Result []LokiStream `json:"result"`
}
type LokiStream struct {
Stream map[string]string `json:"stream"`
Values lokiValues `json:"values"`
}
type lokiValues [][]string
func (values lokiValues) Len() int {
return len(values)
}
func (values lokiValues) Less(i, j int) bool {
return values[i][0] > values[j][1]
}
func (values lokiValues) Swap(i, j int) {
values[i], values[j] = values[j], values[i]
}
func registerProfileRoutes(app *FrontendApp) {
app.handleWithSession("/profile", func(responseWriter http.ResponseWriter, request *http.Request, session Session) {
if !session.EmailVerified {
// anti-XSS: only set path into the flash cookie if it matches a basic url pattern
if app.basicURLPathRegex.MatchString(request.URL.Path) {
msg := fmt.Sprintf("Please verify your email address in order to access %s%s", app.Domain, request.URL.Path)
app.setFlash(responseWriter, session, "info", msg)
}
http.Redirect(responseWriter, request, "/verify-email", http.StatusFound)
return
}
// email is verified, continue:
rawHash := sha256.Sum256([]byte(session.SessionId))
hashOfSessionId := fmt.Sprintf("%x", rawHash[:8])
tenant, err := app.Model.GetTenant(session.TenantId)
if err != nil {
app.unhandledError(responseWriter, request, err)
return
}
billingYear, billingMonth, _, _, _ := getBillingTimeInfo()
usageTotal, err := app.Model.GetTenantUsageTotal(session.TenantId, billingYear, billingMonth)
if err != nil {
app.unhandledError(responseWriter, request, err)
return
}
newAPIToken := (*session.Flash)["api-token"]
newAPITokenName := (*session.Flash)["api-token-name"]
if request.Method == "POST" {
postedHashOfSessionId := request.PostFormValue("hashOfSessionId")
if postedHashOfSessionId != hashOfSessionId {
app.setFlash(responseWriter, session, "error", "anti-CSRF (cross site request forgery) validation failed. Please try again.\n")
http.Redirect(responseWriter, request, "/profile", http.StatusFound)
return
}
action := request.PostFormValue("action")
if action == "update_free_subdomain" {
postedFreeSubdomain := strings.ToLower(request.PostFormValue("subdomain"))
subdomainRegex := regexp.MustCompile("^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$")
if !subdomainRegex.MatchString(postedFreeSubdomain) {
app.setFlash(
responseWriter, session, "error",
"the provided subdomain was invalid; subdomains consist only of letters, numbers, and dashes, ranging from 1 to 61 characters long",
)
http.Redirect(responseWriter, request, "/profile", http.StatusFound)
return
}
alreadyTaken, err := app.Model.SetFreeSubdomain(session.TenantId, postedFreeSubdomain)
if err != nil {
go postTelemetryFromRequest("update_free_subdomain", request, app, fmt.Sprintf(
"'%s': db error: %s", postedFreeSubdomain, err,
))
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
}
if alreadyTaken {
app.setFlash(responseWriter, session, "error", fmt.Sprintf("the subdomain '%s' is already taken", postedFreeSubdomain))
http.Redirect(responseWriter, request, "/profile", http.StatusFound)
return
}
err = app.Backend.Reallocate(false, false)
if err != nil {
go postTelemetryFromRequest("update_free_subdomain", request, app, fmt.Sprintf(
"'%s': reallocate error: %s", postedFreeSubdomain, err,
))
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
}
go postTelemetryFromRequest("update_free_subdomain", request, app, fmt.Sprintf(
"'%s': success!!", postedFreeSubdomain,
))
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 {
go postTelemetryFromRequest("add_external_domain", request, app, fmt.Sprintf(
"'%s' -> '%s': validate: %s", postedExternalDomain, usersPersonalSubdomain, err,
))
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 {
go postTelemetryFromRequest("add_external_domain", request, app, fmt.Sprintf(
"'%s' -> '%s': CNAME is missing", postedExternalDomain, usersPersonalSubdomain,
))
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 {
go postTelemetryFromRequest("add_external_domain", request, app, fmt.Sprintf(
"'%s' -> '%s': model error: %s", postedExternalDomain, usersPersonalSubdomain, err,
))
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
}
go postTelemetryFromRequest("add_external_domain", request, app, fmt.Sprintf(
"'%s' -> '%s': success!!", postedExternalDomain, usersPersonalSubdomain,
))
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"))
if len(keyName) == 0 {
app.setFlash(responseWriter, session, "error", "API Token Name is required\n")
http.Redirect(responseWriter, request, "/profile", http.StatusFound)
return
}
if !regexp.MustCompile("[A-Za-z0-9_-]+").MatchString(keyName) {
app.setFlash(responseWriter, session, "error", "API Token Name may only contain letters, numbers, dashes, and underscores\n")
http.Redirect(responseWriter, request, "/profile", http.StatusFound)
return
}
apiToken, name, err := app.Backend.CreateAPIToken(session.TenantId, keyName)
if err != nil {
app.unhandledError(responseWriter, request, err)
return
}
go postTelemetryFromRequest("create_api_token", request, app, fmt.Sprintf("'%s': success!!", keyName))
app.setFlash(responseWriter, session, "api-token", apiToken)
app.setFlash(responseWriter, session, "api-token-name", name)
app.setFlash(responseWriter, session, "info", fmt.Sprintf("Success! Your new '%s' API Token is %s. It will not be displayed again, so make sure to copy and paste it or write it down now!\n", keyName, apiToken))
} else {
app.setFlash(responseWriter, session, "error", fmt.Sprintf("unknown action '%s'\n", action))
}
http.Redirect(responseWriter, request, "/profile", http.StatusFound)
return
}
apiTokens := []APIToken{}
for _, token := range tenant.APITokens {
//fmt.Printf("%s %s", token.Name, newAPITokenName)
if token.Name != newAPITokenName {
apiTokens = append(apiTokens, token)
}
}
goatCounterOSName := os_from_user_agent_header.Parse(request.Header.Get("User-Agent")).OSName
simplifiedOSName := map[string]string{
"iOS": "macos",
"macOS": "macos",
"Windows Phone": "windows",
"Windows": "windows",
}[goatCounterOSName]
if simplifiedOSName == "" {
simplifiedOSName = "linux"
}
lokiResponseString := getTelemetryFromLokiForUser(app, session.Email)
data := struct {
Subdomain string
SubdomainDomain string
ExternalDomains []ExternalDomain
APITokens []APIToken
NewAPIToken string
NewAPITokenName string
BytesSoFar string
BillingAlarmSMS string
BillingAlarmEmail string
BillingAlarmThreshold string
BillingLimit string
HashOfSessionId string
OperatingSystem string
LokiResponseString string
}{
Subdomain: tenant.Subdomain,
SubdomainDomain: freeSubdomainDomain,
ExternalDomains: tenant.ExternalDomains,
APITokens: apiTokens,
NewAPIToken: newAPIToken,
NewAPITokenName: newAPITokenName,
BytesSoFar: ByteCountSI(usageTotal),
BillingAlarmSMS: tenant.SMSAlarmNumber,
BillingAlarmEmail: tenant.Email,
BillingAlarmThreshold: fmt.Sprintf("%.2f", float64(tenant.BillingAlarmCents)/float64(100)),
BillingLimit: fmt.Sprintf("%.2f", float64(tenant.ServiceLimitCents)/float64(100)),
HashOfSessionId: hashOfSessionId,
OperatingSystem: simplifiedOSName,
LokiResponseString: lokiResponseString,
}
app.buildPageFromTemplateWithClass(responseWriter, request, session, "alpha-profile.html", data, "no-horizontal-margin")
})
app.handleWithSession("/profile/usage_graph.png", func(responseWriter http.ResponseWriter, request *http.Request, session Session) {
// tenant, err := app.Model.GetTenant(session.TenantId)
// if err != nil {
// app.unhandledError(responseWriter, request, err)
// return
// }
_, _, start, end, _ := getBillingTimeInfo()
usageMetrics, err := app.Model.GetTenantUsageMetrics(session.TenantId, start, end)
if err != nil {
app.unhandledError(responseWriter, request, err)
return
}
type datapoint struct {
Time time.Time
Bytes int64
}
i := 0
usageMetricObjects := make([]datapoint, len(usageMetrics))
for time, bytez := range usageMetrics {
usageMetricObjects[i] = datapoint{Time: time, Bytes: bytez}
i++
}
sort.Slice(usageMetricObjects, func(i, j int) bool {
return usageMetricObjects[j].Time.After(usageMetricObjects[i].Time)
})
// fmt.Println(start.Format(time.RFC1123), end.Format(time.RFC1123))
// fmt.Println(usageMetricObjects[0].Time.Format(time.RFC1123), usageMetricObjects[len(usageMetricObjects)/2].Time.Format(time.RFC1123), usageMetricObjects[len(usageMetricObjects)-1].Time.Format(time.RFC1123))
xValues := make([]float64, len(usageMetricObjects)+3)
yValues := make([]float64, len(usageMetricObjects)+3)
var total int64 = 0
xValues[0] = float64(start.UnixNano())
yValues[0] = float64(0)
if len(usageMetricObjects) > 0 {
xValues[1] = float64(usageMetricObjects[0].Time.UnixNano() - int64(time.Second*10))
} else {
xValues[1] = float64(start.UnixNano())
}
yValues[1] = float64(0)
for i, obj := range usageMetricObjects {
total = total + obj.Bytes
xValues[i+2] = float64(obj.Time.UnixNano())
yValues[i+2] = float64(total)
}
xValues[len(xValues)-1] = float64(end.UnixNano())
yValues[len(yValues)-1] = float64(total)
// it will err if the y scale is from 0 to 0
if total == 0 {
total = 1000
}
// split xValues and yValues into two separate series, one of the past and one of the future
upToNowCount := 0
nowNano := float64(end.UnixNano())
for _, t := range xValues {
if nowNano > t {
upToNowCount++
}
}
upToNowX := make([]float64, upToNowCount)
upToNowY := make([]float64, upToNowCount)
// this -1 thing makes the most recent datapoint included in both series.
futureX := make([]float64, len(xValues)-(upToNowCount-1))
futureY := make([]float64, len(xValues)-(upToNowCount-1))
for i, t := range xValues {
if i < upToNowCount {
upToNowX[i] = t
if i == upToNowCount-1 {
futureX[i-(upToNowCount-1)] = t
}
} else {
futureX[i-(upToNowCount-1)] = t
}
}
for i, v := range yValues {
if i < upToNowCount {
upToNowY[i] = v
if i == upToNowCount-1 {
futureY[i-(upToNowCount-1)] = v
}
} else {
futureY[i-(upToNowCount-1)] = v
}
}
graph := chart.Chart{
Width: 450,
Height: 200,
XAxis: chart.XAxis{
ValueFormatter: func(v interface{}) string {
if typed, isTyped := v.(float64); isTyped {
timeInstance := time.Unix(0, int64(typed)).UTC()
if timeInstance.After(end) {
timeInstance = end
}
return timeInstance.Format("Jan 2")
}
return ""
},
Range: &chart.ContinuousRange{
Min: float64(start.UnixNano()),
Max: float64(end.UnixNano()),
},
},
YAxis: chart.YAxis{
ValueFormatter: func(v interface{}) string {
if typed, isTyped := v.(float64); isTyped {
return ByteCountSI(int64(typed))
}
return ""
},
Range: &chart.ContinuousRange{
Min: float64(0),
Max: float64(total) * float64(1.2),
},
},
Series: []chart.Series{
chart.ContinuousSeries{
Name: "Bytes",
Style: chart.Style{
StrokeColor: chartdrawing.Color{R: 0, G: 100, B: 255, A: 255},
FillColor: chartdrawing.Color{R: 0, G: 100, B: 255, A: 100},
},
YAxis: chart.YAxisPrimary,
XValues: upToNowX,
YValues: upToNowY,
},
chart.ContinuousSeries{
Name: "Future",
Style: chart.Style{
StrokeColor: chartdrawing.Color{R: 0, G: 100, B: 255, A: 255},
FillColor: chartdrawing.Color{R: 0, G: 100, B: 255, A: 70},
StrokeDashArray: []float64{5.0, 5.0},
},
YAxis: chart.YAxisPrimary,
XValues: futureX,
YValues: futureY,
},
},
}
//log.Println(graph.YAxis.Range.GetDelta())
buffer := bytes.NewBuffer([]byte{})
err = graph.Render(chart.PNG, buffer)
if err != nil {
app.unhandledError(responseWriter, request, err)
return
}
responseWriter.Header().Set("Content-Type", "image/png")
responseWriter.Header().Set("Content-Length", strconv.Itoa(buffer.Len()))
_, err = responseWriter.Write(buffer.Bytes())
if err != nil {
log.Printf("http Write error on usage_graph png: %+v", err)
}
})
}
func getTelemetryFromLokiForUser(app *FrontendApp, email string) string {
lokiQuery := fmt.Sprintf("{delay=\"user\", account=~\"^%s$\"}", email)
lokiQueryURL := fmt.Sprintf(
"%s/loki/api/v1/query_range?direction=BACKWARD&limit=1000&query=%s&start=%d&end=%d&step=900",
app.LokiURL, url.QueryEscape(lokiQuery), time.Now().Add(-1*time.Hour*24*29).UnixNano(), time.Now().Add(time.Hour).UnixNano(),
)
lokiResponseString := "HTTP Dial Error getting telemetry data\n"
lokiResponse, err := app.httpClient.Get(lokiQueryURL)
if err == nil {
lokiResponseString = fmt.Sprintf("HTTP %d error getting telemetry data", lokiResponse.StatusCode)
if lokiResponse.StatusCode < 300 {
lokiResponseString = "HTTP read error getting telemetry data"
lokiResponseBytes, err := ioutil.ReadAll(lokiResponse.Body)
if err == nil {
lokiResponseString = "JSON parse error getting telemetry data"
var lokiResponseObject LokiResponse
err = json.Unmarshal(lokiResponseBytes, &lokiResponseObject)
if err == nil {
lokiResponseString = "Loki response error getting telemetry data"
if lokiResponseObject.Status == "success" {
allValues := lokiValues{}
for _, stream := range lokiResponseObject.Data.Result {
allValues = append(allValues, stream.Values...)
}
sort.Sort(allValues)
responseStringBytes := []byte{}
for _, value := range allValues {
toAppend := "malformed data from loki\n"
if len(value) == 2 {
timestampUnixNanos, err := strconv.ParseInt(value[0], 10, 64)
if err == nil {
toAppend = fmt.Sprintf(fmt.Sprintf("%s %s\n", time.Unix(0, timestampUnixNanos).Format(time.RFC3339), value[1]))
}
}
responseStringBytes = append(responseStringBytes, []byte(toAppend)...)
}
lokiResponseString = string(responseStringBytes)
}
}
}
} else {
errorString := "read error"
bytez, err := ioutil.ReadAll(lokiResponse.Body)
if err == nil {
errorString = string(bytez)
}
log.Printf("HTTP %d getting telemetry data for %s:\n%s\n%s\n", lokiResponse.StatusCode, email, lokiQueryURL, errorString)
}
} else {
log.Printf("error getting telemetry data for %s:\n%s\n%s\n", email, lokiQueryURL, err)
}
return lokiResponseString
}
func ByteCountSI(b int64) string {
const unit = 1000
if b < unit {
return fmt.Sprintf("%d B", b)
}
div, exp := int64(unit), 0
for n := b / unit; n >= unit; n /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %cB",
float64(b)/float64(div), "kMGTPE"[exp])
}