Compare commits
4
Commits
v0.1.11
...
39659f0144
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
39659f0144 | ||
|
|
227c890409 | ||
|
|
38ab4f7db2 | ||
|
|
f93698f63f |
No files matched your search
@@ -28,14 +28,17 @@ const (
|
||||
defaultOffline = 90 * time.Second
|
||||
)
|
||||
|
||||
// AlertChannel is somewhere alerts are sent: an email address or a webhook.
|
||||
// AlertChannel is somewhere alerts are sent: an email address, a webhook, or a
|
||||
// Sandbar server (which pages whoever is on call and tracks the alert).
|
||||
type AlertChannel struct {
|
||||
ID string `json:"id"`
|
||||
OrgID string `json:"orgId"` // "" = a server-level channel (for edges)
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"` // "email" or "webhook"
|
||||
Target string `json:"target"` // address(es), or a webhook URL (secret: URLs often carry tokens)
|
||||
Kind string `json:"kind"` // "email", "webhook" or "sandbar"
|
||||
Target string `json:"target"` // address(es), a webhook URL, or Sandbar's address (secret: URLs often carry tokens)
|
||||
Format string `json:"format"` // webhook body: "json", "slack", "discord" or "ntfy"
|
||||
Token string `json:"token"` // Sandbar: API key (secret, never sent back to the browser)
|
||||
Policy string `json:"policy"` // Sandbar: escalation policy id; blank sends the alert without paging anyone
|
||||
AllowPrivate bool `json:"allowPrivate"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
@@ -50,6 +53,7 @@ type alertState struct {
|
||||
type problem struct{ org, kind, name, msg string }
|
||||
|
||||
type alertNote struct {
|
||||
Key string `json:"key"` // stable per problem, so a recovery can find its alert
|
||||
Status string `json:"status"` // "firing", "resolved" or "test"
|
||||
Org string `json:"org"`
|
||||
Kind string `json:"kind"`
|
||||
@@ -251,7 +255,7 @@ func (s *Server) evaluateAlerts(now time.Time) {
|
||||
chans = append(chans, *c)
|
||||
}
|
||||
}
|
||||
out = append(out, delivery{alertNote{Status: status, Org: orgName(st.OrgID), Kind: st.Kind, Name: st.Name, Message: st.Message, Since: st.Since, Time: now}, chans})
|
||||
out = append(out, delivery{alertNote{Key: st.Key, Status: status, Org: orgName(st.OrgID), Kind: st.Kind, Name: st.Name, Message: st.Message, Since: st.Since, Time: now}, chans})
|
||||
s.db.addAlertEvent(now, st.OrgID, st.Key, status, st.Name, st.Message)
|
||||
}
|
||||
keys := make([]string, 0, len(probs))
|
||||
@@ -362,6 +366,8 @@ func (s *Server) send(ch AlertChannel, n alertNote) error {
|
||||
}
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
case "sandbar":
|
||||
return s.sendSandbar(ch, n)
|
||||
case "webhook":
|
||||
body, ctype, hdr := webhookBody(ch.Format, n)
|
||||
req, err := http.NewRequest("POST", ch.Target, bytes.NewReader(body))
|
||||
@@ -385,6 +391,47 @@ func (s *Server) send(ch AlertChannel, n alertNote) error {
|
||||
return errors.New("unknown channel kind")
|
||||
}
|
||||
|
||||
// sendSandbar raises the alert on a Sandbar server, or resolves it there when the
|
||||
// problem clears. Sandbar de-duplicates by key, so a reminder updates the alert
|
||||
// that is already open instead of opening another. A test raises an info alert
|
||||
// with no policy (nobody is paged) and resolves it straight away.
|
||||
func (s *Server) sendSandbar(ch AlertChannel, n alertNote) error {
|
||||
post := func(body map[string]string) error {
|
||||
raw, _ := json.Marshal(body)
|
||||
req, err := http.NewRequest("POST", strings.TrimRight(ch.Target, "/")+"/api/v1/alerts", bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+ch.Token)
|
||||
resp, err := netguard.Client(ch.AllowPrivate).Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
switch {
|
||||
case resp.StatusCode == 401 || resp.StatusCode == 403:
|
||||
return errors.New("Sandbar rejected the API key")
|
||||
case resp.StatusCode >= 300:
|
||||
return fmt.Errorf("Sandbar answered %s", resp.Status)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
key := "hammerhead:" + n.Key
|
||||
switch n.Status {
|
||||
case "resolved":
|
||||
return post(map[string]string{"key": key, "status": "resolved", "source": "hammerhead"})
|
||||
case "test":
|
||||
key = "hammerhead:test:" + ch.ID
|
||||
if err := post(map[string]string{"key": key, "source": "hammerhead", "summary": "Hammerhead test alert", "detail": n.text(), "severity": "info"}); err != nil {
|
||||
return err
|
||||
}
|
||||
return post(map[string]string{"key": key, "status": "resolved", "source": "hammerhead"})
|
||||
}
|
||||
return post(map[string]string{"key": key, "source": "hammerhead", "summary": n.Name + ": " + n.Message,
|
||||
"detail": "Organization: " + n.Org + "\nSince: " + n.Since.Format(time.RFC1123), "severity": "critical", "policy": ch.Policy})
|
||||
}
|
||||
|
||||
func webhookBody(format string, n alertNote) (body []byte, ctype string, hdr map[string]string) {
|
||||
text := n.subject() + "\n" + n.text()
|
||||
switch format {
|
||||
@@ -415,12 +462,25 @@ func (s *Server) validateChannel(ch *AlertChannel, trusted bool) error {
|
||||
return errors.New("enter one or more email addresses, separated by commas")
|
||||
}
|
||||
}
|
||||
ch.Format, ch.AllowPrivate = "", false
|
||||
ch.Format, ch.Token, ch.Policy, ch.AllowPrivate = "", "", "", false
|
||||
case "sandbar":
|
||||
u, err := url.Parse(ch.Target)
|
||||
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
|
||||
return errors.New("enter Sandbar's address, such as https://sandbar.example.com")
|
||||
}
|
||||
if ch.Token = strings.TrimSpace(ch.Token); ch.Token == "" {
|
||||
return errors.New("enter an API key from Sandbar's Account page")
|
||||
}
|
||||
ch.Policy, ch.Format = strings.TrimSpace(ch.Policy), ""
|
||||
if ch.AllowPrivate && !trusted {
|
||||
return errors.New("sending to private addresses (like a service on your home network) needs a server administrator to allow it for this organization")
|
||||
}
|
||||
case "webhook":
|
||||
u, err := url.Parse(ch.Target)
|
||||
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
|
||||
return errors.New("the webhook must be an http:// or https:// URL")
|
||||
}
|
||||
ch.Token, ch.Policy = "", ""
|
||||
switch ch.Format {
|
||||
case "json", "slack", "discord", "ntfy":
|
||||
case "":
|
||||
@@ -432,7 +492,7 @@ func (s *Server) validateChannel(ch *AlertChannel, trusted bool) error {
|
||||
return errors.New("sending to private addresses (like a service on your home network) needs a server administrator to allow it for this organization")
|
||||
}
|
||||
default:
|
||||
return errors.New("a channel is an email or a webhook")
|
||||
return errors.New("a channel is an email, a webhook or a Sandbar server")
|
||||
}
|
||||
if ch.Name == "" {
|
||||
ch.Name = ch.Kind
|
||||
|
||||
+5
-2
@@ -23,7 +23,7 @@ type channelView struct {
|
||||
}
|
||||
|
||||
func preview(ch AlertChannel) string {
|
||||
if ch.Kind != "webhook" {
|
||||
if ch.Kind == "email" {
|
||||
return ch.Target
|
||||
}
|
||||
if u, err := url.Parse(ch.Target); err == nil {
|
||||
@@ -47,7 +47,7 @@ func (s *Server) alertOverview(w http.ResponseWriter, sc alertScope) {
|
||||
for _, c := range s.st.Channels {
|
||||
if c.OrgID == sc.org {
|
||||
v := channelView{AlertChannel: *c, Preview: preview(*c)}
|
||||
v.Target = "" // webhook URLs often contain tokens
|
||||
v.Target, v.Token = "", "" // webhook URLs often contain tokens
|
||||
if c.Kind == "email" {
|
||||
v.Target = c.Target
|
||||
}
|
||||
@@ -95,6 +95,9 @@ func (s *Server) saveChannel(w http.ResponseWriter, r *http.Request, sc alertSco
|
||||
if in.Target == "" {
|
||||
in.Target = existing.Target // blank keeps the saved URL/address
|
||||
}
|
||||
if id != "" && in.Token == "" && in.Kind == "sandbar" {
|
||||
in.Token = existing.Token // blank keeps the saved API key
|
||||
}
|
||||
}
|
||||
trusted := sc.trusted
|
||||
if err := s.validateChannel(&in, trusted); err != nil {
|
||||
|
||||
+55
-1
@@ -10,8 +10,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.veillax.com/Veillax/hammerhead/internal/secretbox"
|
||||
"git.veillax.com/Veillax/reef/model"
|
||||
"git.veillax.com/Veillax/reef/secretbox"
|
||||
)
|
||||
|
||||
type hook struct {
|
||||
@@ -487,3 +487,57 @@ func TestPromEscape(t *testing.T) {
|
||||
t.Errorf("promEscape = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSandbarChannelRaisesAndResolves(t *testing.T) {
|
||||
f := newFx(t)
|
||||
h := newHook(t)
|
||||
f.s.mu.Lock()
|
||||
f.s.st.Channels = []*AlertChannel{{ID: "c2", OrgID: f.org, Name: "pager", Kind: "sandbar", Target: strings.TrimSuffix(h.URL, "/hook/secret-token"),
|
||||
Token: "sb_secret", Policy: "pol1", AllowPrivate: true, Enabled: true}}
|
||||
f.s.mu.Unlock()
|
||||
|
||||
f.eval(f.base)
|
||||
f.setHealthy(false)
|
||||
f.eval(f.base.Add(10 * time.Second))
|
||||
f.eval(f.base.Add(3 * time.Minute))
|
||||
var up map[string]string
|
||||
if err := json.Unmarshal([]byte(h.next(2*time.Second)), &up); err != nil {
|
||||
t.Fatal("no Sandbar request:", err)
|
||||
}
|
||||
if up["key"] != "hammerhead:target:m1:0" || up["policy"] != "pol1" || up["severity"] != "critical" || !strings.Contains(up["summary"], "Minecraft") {
|
||||
t.Fatalf("unexpected alert sent to Sandbar: %v", up)
|
||||
}
|
||||
h.mu.Lock()
|
||||
auth := h.hdrs[0].Get("Authorization")
|
||||
h.mu.Unlock()
|
||||
if auth != "Bearer sb_secret" {
|
||||
t.Fatalf("API key not sent: %q", auth)
|
||||
}
|
||||
|
||||
f.setHealthy(true)
|
||||
f.eval(f.base.Add(4 * time.Minute))
|
||||
var down map[string]string
|
||||
if err := json.Unmarshal([]byte(h.next(2*time.Second)), &down); err != nil {
|
||||
t.Fatal("no resolve request:", err)
|
||||
}
|
||||
if down["key"] != up["key"] || down["status"] != "resolved" {
|
||||
t.Fatalf("recovery did not resolve the same alert: %v", down)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSandbarChannelValidation(t *testing.T) {
|
||||
f := newFx(t)
|
||||
for _, ch := range []AlertChannel{
|
||||
{Kind: "sandbar", Target: "not a url", Token: "k"},
|
||||
{Kind: "sandbar", Target: "https://sandbar.example.com"}, // no API key
|
||||
} {
|
||||
ch := ch
|
||||
if err := f.s.validateChannel(&ch, true); err == nil {
|
||||
t.Fatalf("accepted %+v", ch)
|
||||
}
|
||||
}
|
||||
ok := AlertChannel{Kind: "sandbar", Target: "https://sandbar.example.com", Token: " k ", Policy: " p "}
|
||||
if err := f.s.validateChannel(&ok, true); err != nil || ok.Token != "k" || ok.Policy != "p" {
|
||||
t.Fatalf("%v %+v", err, ok)
|
||||
}
|
||||
}
|
||||
@@ -13,8 +13,8 @@ import (
|
||||
"time"
|
||||
|
||||
"git.veillax.com/Veillax/hammerhead"
|
||||
"git.veillax.com/Veillax/hammerhead/internal/secretbox"
|
||||
"git.veillax.com/Veillax/reef/dotenv"
|
||||
"git.veillax.com/Veillax/reef/secretbox"
|
||||
"git.veillax.com/Veillax/reef/selfupdate"
|
||||
)
|
||||
|
||||
|
||||
@@ -9,8 +9,8 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.veillax.com/Veillax/hammerhead/internal/secretbox"
|
||||
"git.veillax.com/Veillax/reef/model"
|
||||
"git.veillax.com/Veillax/reef/secretbox"
|
||||
_ "modernc.org/sqlite" // pure Go, so no C compiler is needed
|
||||
)
|
||||
|
||||
@@ -113,7 +113,7 @@ func (d *DB) sealed(v any) any {
|
||||
return &c
|
||||
case *AlertChannel:
|
||||
c := *x
|
||||
c.Target = b.Seal(c.Target)
|
||||
c.Target, c.Token = b.Seal(c.Target), b.Seal(c.Token)
|
||||
return &c
|
||||
case Settings:
|
||||
x.SMTP.Pass = b.Seal(x.SMTP.Pass)
|
||||
@@ -144,7 +144,7 @@ func (d *DB) unseal(v any) error {
|
||||
case *model.DKIMKey:
|
||||
return d.open1(&x.PrivateKey)
|
||||
case *AlertChannel:
|
||||
return d.open1(&x.Target)
|
||||
return errors.Join(d.open1(&x.Target), d.open1(&x.Token))
|
||||
case *Settings:
|
||||
return d.open1(&x.SMTP.Pass)
|
||||
}
|
||||
|
||||
+1
-1
@@ -13,8 +13,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.veillax.com/Veillax/hammerhead/internal/secretbox"
|
||||
"git.veillax.com/Veillax/reef/model"
|
||||
"git.veillax.com/Veillax/reef/secretbox"
|
||||
)
|
||||
|
||||
func apiClient(t *testing.T, s *Server) (do func(method, path string, in any) (int, []byte)) {
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.veillax.com/Veillax/blacktip"
|
||||
"git.veillax.com/Veillax/reef/model"
|
||||
)
|
||||
|
||||
// A website on every address does not take the port from a website that names an address: each
|
||||
// listens where it should, and the named address answers only for its own site.
|
||||
func TestWildcardWebsiteStepsAsideForANamedAddress(t *testing.T) {
|
||||
w := setup(t, blacktip.TraefikConfig{})
|
||||
w.admin.must("POST", "/api/orgs/"+w.org+"/domains", map[string]any{"name": "example.test", "edgeId": w.edge}, nil)
|
||||
wild, named := webBackend(t, "wild", 200), webBackend(t, "named", 200)
|
||||
port := freePort(t)
|
||||
|
||||
w.open(model.Mapping{Proto: "http", BindIP: "0.0.0.0", Port: port, Hostname: "wild.example.test", Targets: tgt(w.site, wild)})
|
||||
w.open(model.Mapping{Proto: "http", BindIP: "127.0.0.2", Port: port, Hostname: "named.example.test", Targets: tgt(w.site, named)})
|
||||
|
||||
ask := func(ip, host string) (int, string) {
|
||||
c := &http.Client{Timeout: 3 * time.Second, Transport: &http.Transport{DisableKeepAlives: true,
|
||||
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
|
||||
return (&net.Dialer{}).DialContext(ctx, "tcp", net.JoinHostPort(ip, strconv.Itoa(port)))
|
||||
}}}
|
||||
req, _ := http.NewRequest("GET", "http://"+host+"/", nil)
|
||||
resp, err := c.Do(req)
|
||||
if err != nil {
|
||||
return 0, err.Error()
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
var body map[string]string
|
||||
json.Unmarshal(b, &body)
|
||||
return resp.StatusCode, body["backend"]
|
||||
}
|
||||
wait := func(ip, host, wantBackend string) {
|
||||
t.Helper()
|
||||
var code int
|
||||
var got string
|
||||
for i := 0; i < 60; i++ {
|
||||
if code, got = ask(ip, host); code == 200 && got == wantBackend {
|
||||
return
|
||||
}
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("%s via %s: status %d backend %q, want %q", host, ip, code, got, wantBackend)
|
||||
}
|
||||
|
||||
wait("127.0.0.2", "named.example.test", "named") // the named address serves its own site
|
||||
wait("127.0.0.1", "wild.example.test", "wild") // the wildcard still covers the other addresses
|
||||
// and the wildcard gave up the named address: its site is not served there
|
||||
if code, _ := ask("127.0.0.2", "wild.example.test"); code == 200 {
|
||||
t.Errorf("the wildcard website still answers on the address another website names")
|
||||
}
|
||||
if code, _ := ask("127.0.0.1", "named.example.test"); code == 200 {
|
||||
t.Errorf("a website answers on an address it was not given")
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,8 @@ go 1.27.3
|
||||
|
||||
require (
|
||||
git.veillax.com/Veillax/bamboo v0.1.5
|
||||
git.veillax.com/Veillax/blacktip v0.1.7
|
||||
git.veillax.com/Veillax/reef v0.1.7
|
||||
git.veillax.com/Veillax/blacktip v0.1.9
|
||||
git.veillax.com/Veillax/reef v0.1.10
|
||||
github.com/emersion/go-msgauth v0.7.0
|
||||
golang.org/x/crypto v0.58.0
|
||||
modernc.org/sqlite v1.61.0
|
||||
|
||||
@@ -1,29 +1,9 @@
|
||||
git.veillax.com/Veillax/bamboo v0.1.0 h1:16BG1P8982dIkHA2l0LPS0xYQc8d86PruVAGxc8BP84=
|
||||
git.veillax.com/Veillax/bamboo v0.1.0/go.mod h1:i4l4uJgG6Ymfwbjnlz0xeTt3qW/+1W1L2JBToMw50n0=
|
||||
git.veillax.com/Veillax/bamboo v0.1.3 h1:A66gz9GmmtUq1iIGUAL2ItIeC6iTy5pvqJOoIjRHjFg=
|
||||
git.veillax.com/Veillax/bamboo v0.1.3/go.mod h1:UuHV6aNHmV9jpow0xeGlXX0QhMB9glPJUGzgIM7S1MM=
|
||||
git.veillax.com/Veillax/bamboo v0.1.4 h1:pgiBJA46CBJx7T3bloCjPekb6wl6W5H8teokFAH5gj0=
|
||||
git.veillax.com/Veillax/bamboo v0.1.4/go.mod h1:wzSpfql7U4a3b9mCVH01OPG9H9szrzwSFDufWw02e0g=
|
||||
git.veillax.com/Veillax/bamboo v0.1.5 h1:i81c75/7wjayP9tOQ3tSnJWTBhvbjAwNBEPhT+3P2Xk=
|
||||
git.veillax.com/Veillax/bamboo v0.1.5/go.mod h1:se4kkcP6c3uCGD4HQd+nDLoblS0lN8qa+eFWLo5Horw=
|
||||
git.veillax.com/Veillax/blacktip v0.1.0 h1:L8WpW56gqtvZ1IAapE95Ynl6NKjqTA98WwmEH01kFFk=
|
||||
git.veillax.com/Veillax/blacktip v0.1.0/go.mod h1:sj+mr8393rajzWbl0+RRZrstGyDtZ/GHsut6ebScVxg=
|
||||
git.veillax.com/Veillax/blacktip v0.1.4 h1:o3uNoA0ucB/qcN9ccofysdFA6RTEU4jKdgeTWuE2Uw0=
|
||||
git.veillax.com/Veillax/blacktip v0.1.4/go.mod h1:f7zXCYlQf/9ffDLjPrMHylKAyXX4Nb+aQ7kErmOOhqI=
|
||||
git.veillax.com/Veillax/blacktip v0.1.6 h1:dUJzNGXgieWX05ftWjnMfh6norhaeKTqo1P13lJvjtk=
|
||||
git.veillax.com/Veillax/blacktip v0.1.6/go.mod h1:ZnPRc2gPh9ElILfPH39G/x3B6KAiHQyeoDLycSDgVPM=
|
||||
git.veillax.com/Veillax/blacktip v0.1.7 h1:PjxEkhE4fnGWht9Pj2/l6S8fOX2Iqjir3rc9vfpMFiQ=
|
||||
git.veillax.com/Veillax/blacktip v0.1.7/go.mod h1:dVm4wC6wufPjFzK8pvHnqNJJlxroRYmnq8mdkCqOKGc=
|
||||
git.veillax.com/Veillax/reef v0.1.0 h1:O0OhEKFx2CsvACJ20sfzJNjGsngomtfDdKdxu4ROpCs=
|
||||
git.veillax.com/Veillax/reef v0.1.0/go.mod h1:ZBUd/BARO5kkpUNTP81TTY1aQXLHFR6bKDhCxckjHso=
|
||||
git.veillax.com/Veillax/reef v0.1.3 h1:RxSq2xwaup1+MieAkhlvOTZnYwPf19thFHEAe/7VxsI=
|
||||
git.veillax.com/Veillax/reef v0.1.3/go.mod h1:ZBUd/BARO5kkpUNTP81TTY1aQXLHFR6bKDhCxckjHso=
|
||||
git.veillax.com/Veillax/reef v0.1.4 h1:T2+B8P1GAitEyTYRkwMT3UHbG9mN5zXb/inE2muXdQ4=
|
||||
git.veillax.com/Veillax/reef v0.1.4/go.mod h1:ZBUd/BARO5kkpUNTP81TTY1aQXLHFR6bKDhCxckjHso=
|
||||
git.veillax.com/Veillax/reef v0.1.6 h1:NgZ4u2Z3hJ+BYD/zX9lov8fox4s2i8txtYNOpIsikVo=
|
||||
git.veillax.com/Veillax/reef v0.1.6/go.mod h1:ZBUd/BARO5kkpUNTP81TTY1aQXLHFR6bKDhCxckjHso=
|
||||
git.veillax.com/Veillax/reef v0.1.7 h1:5C2udonlJac2xvDOE5e+zDxej+AbZ9ttrNSE4owmwuU=
|
||||
git.veillax.com/Veillax/reef v0.1.7/go.mod h1:ZBUd/BARO5kkpUNTP81TTY1aQXLHFR6bKDhCxckjHso=
|
||||
git.veillax.com/Veillax/blacktip v0.1.9 h1:MMkF8GCrN01IWwwii1WM5l+nqIbHsOznyrbG7OvDtoo=
|
||||
git.veillax.com/Veillax/blacktip v0.1.9/go.mod h1:9mYwKMiKXN/P0dpPtdTzG7zf1IeHt/aISa0SQD9rlzY=
|
||||
git.veillax.com/Veillax/reef v0.1.10 h1:b15oD1vyrGOriCHhipQtJ3j7Cy5ForYWvfJkqifLQPg=
|
||||
git.veillax.com/Veillax/reef v0.1.10/go.mod h1:ZBUd/BARO5kkpUNTP81TTY1aQXLHFR6bKDhCxckjHso=
|
||||
github.com/Microsoft/go-winio v0.6.4 h1:kJF2pamtYCWXf0xAT8sAkwKC0pMl2HYBrRPHAqyUc+g=
|
||||
github.com/Microsoft/go-winio v0.6.4/go.mod h1:1xl5JXG4ze/m74GH6Dc4Mwxfc3VL5siRVQjIAlHH07U=
|
||||
github.com/dustin/go-humanize v1.1.0 h1:dbKTrvD0klcbBV/h4AWJdMuZogJACoMlvWIWZ5b2xWg=
|
||||
|
||||
@@ -204,6 +204,43 @@ func (s *Server) addSite(w http.ResponseWriter, r *http.Request, c *orgCtx) {
|
||||
writeJSON(w, 200, st)
|
||||
}
|
||||
|
||||
// putSite renames a site and sets its tags. The built-in local site is shared between
|
||||
// organizations, so it can't be edited here.
|
||||
func (s *Server) putSite(w http.ResponseWriter, r *http.Request, c *orgCtx) {
|
||||
var in struct {
|
||||
Name string `json:"name"`
|
||||
Tags []string `json:"tags"`
|
||||
}
|
||||
if !decode(w, r, &in) {
|
||||
return
|
||||
}
|
||||
in.Name = strings.TrimSpace(in.Name)
|
||||
if in.Name == "" {
|
||||
fail(w, 400, errors.New("name is required"))
|
||||
return
|
||||
}
|
||||
tags, err := normalizeTags(in.Tags)
|
||||
if err != nil {
|
||||
fail(w, 400, err)
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
st := s.findSite(r.PathValue("id"))
|
||||
if st == nil || st.OrgID != c.Org.ID {
|
||||
fail(w, 404, errors.New("no such site"))
|
||||
return
|
||||
}
|
||||
if st.Local {
|
||||
fail(w, 400, errors.New("the built-in local site can't be edited"))
|
||||
return
|
||||
}
|
||||
st.Name, st.Tags = in.Name, tags
|
||||
s.bump()
|
||||
s.audit(c.Org.ID, c.Actor, "site.update", st.Name)
|
||||
ok(w)
|
||||
}
|
||||
|
||||
func (s *Server) deleteSite(w http.ResponseWriter, r *http.Request, c *orgCtx) {
|
||||
id := r.PathValue("id")
|
||||
s.mu.Lock()
|
||||
@@ -233,7 +270,7 @@ func (s *Server) deleteSite(w http.ResponseWriter, r *http.Request, c *orgCtx) {
|
||||
}
|
||||
}
|
||||
}
|
||||
s.st.Mappings = filter(s.st.Mappings, func(m *model.Mapping) bool { return len(m.Targets) > 0 })
|
||||
s.st.Mappings = filter(s.st.Mappings, func(m *model.Mapping) bool { return len(m.Targets) > 0 || m.IsRedirect() })
|
||||
s.bump()
|
||||
s.audit(c.Org.ID, c.Actor, "site.delete", st.Name)
|
||||
ok(w)
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
// Package secretbox encrypts small secrets (tokens, passwords, private keys)
|
||||
// before they are written to the database, using AES-256-GCM.
|
||||
//
|
||||
// Sealed values look like "enc1:<base64>". Opening a value that isn't sealed
|
||||
// returns it unchanged, so data written before encryption was turned on is
|
||||
// still readable and gets sealed the next time it is saved.
|
||||
package secretbox
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const prefix = "enc1:"
|
||||
|
||||
type Box struct{ aead cipher.AEAD }
|
||||
|
||||
// GenerateKey returns a new random key in the form New accepts.
|
||||
func GenerateKey() string {
|
||||
b := make([]byte, 32)
|
||||
rand.Read(b)
|
||||
return base64.StdEncoding.EncodeToString(b)
|
||||
}
|
||||
|
||||
// New builds a Box from a key given as base64 or hex of 32 bytes. Any other
|
||||
// text is treated as a passphrase and stretched with SHA-256.
|
||||
func New(key string) (*Box, error) {
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" {
|
||||
return nil, errors.New("empty key")
|
||||
}
|
||||
raw := decodeKey(key)
|
||||
block, err := aes.NewCipher(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
aead, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Box{aead: aead}, nil
|
||||
}
|
||||
|
||||
func decodeKey(key string) []byte {
|
||||
if b, err := base64.StdEncoding.DecodeString(key); err == nil && len(b) == 32 {
|
||||
return b
|
||||
}
|
||||
if b, err := hex.DecodeString(key); err == nil && len(b) == 32 {
|
||||
return b
|
||||
}
|
||||
sum := sha256.Sum256([]byte(key))
|
||||
return sum[:]
|
||||
}
|
||||
|
||||
// Seal encrypts s. The empty string stays empty so unset secrets stay visibly unset.
|
||||
func (b *Box) Seal(s string) string {
|
||||
if s == "" || strings.HasPrefix(s, prefix) {
|
||||
return s
|
||||
}
|
||||
nonce := make([]byte, b.aead.NonceSize())
|
||||
rand.Read(nonce)
|
||||
return prefix + base64.StdEncoding.EncodeToString(b.aead.Seal(nonce, nonce, []byte(s), nil))
|
||||
}
|
||||
|
||||
// Open decrypts a sealed value. A wrong key or damaged data is an error, so a
|
||||
// bad key can never silently turn secrets into garbage.
|
||||
func (b *Box) Open(s string) (string, error) {
|
||||
if !strings.HasPrefix(s, prefix) {
|
||||
return s, nil
|
||||
}
|
||||
raw, err := base64.StdEncoding.DecodeString(s[len(prefix):])
|
||||
if err != nil || len(raw) < b.aead.NonceSize() {
|
||||
return "", errors.New("damaged secret")
|
||||
}
|
||||
n := b.aead.NonceSize()
|
||||
plain, err := b.aead.Open(nil, raw[:n], raw[n:], nil)
|
||||
if err != nil {
|
||||
return "", errors.New("can't decrypt a stored secret: is HAMMERHEAD_MASTER_KEY the key it was saved with?")
|
||||
}
|
||||
return string(plain), nil
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
package secretbox
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSealOpenRoundTripAndKeys(t *testing.T) {
|
||||
key := GenerateKey()
|
||||
b, err := New(key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sealed := b.Seal("hunter2")
|
||||
if !strings.HasPrefix(sealed, "enc1:") || strings.Contains(sealed, "hunter2") {
|
||||
t.Fatalf("not sealed: %q", sealed)
|
||||
}
|
||||
if again := b.Seal("hunter2"); again == sealed {
|
||||
t.Error("two seals of the same value must differ (random nonce)")
|
||||
}
|
||||
if b.Seal(sealed) != sealed {
|
||||
t.Error("sealing an already sealed value must not double-encrypt")
|
||||
}
|
||||
if got, err := b.Open(sealed); err != nil || got != "hunter2" {
|
||||
t.Errorf("Open = %q, %v", got, err)
|
||||
}
|
||||
if got, _ := b.Open("legacy plaintext"); got != "legacy plaintext" {
|
||||
t.Errorf("unsealed legacy data should pass through, got %q", got)
|
||||
}
|
||||
if b.Seal("") != "" {
|
||||
t.Error("empty stays empty")
|
||||
}
|
||||
|
||||
other, _ := New(GenerateKey())
|
||||
if _, err := other.Open(sealed); err == nil {
|
||||
t.Error("the wrong key must fail loudly, not return garbage")
|
||||
}
|
||||
if _, err := b.Open(sealed[:len(sealed)-4] + "AAAA"); err == nil {
|
||||
t.Error("tampered data must fail")
|
||||
}
|
||||
|
||||
// a passphrase works as a key, and is stable
|
||||
p1, _ := New("correct horse battery staple")
|
||||
p2, _ := New("correct horse battery staple")
|
||||
if got, err := p2.Open(p1.Seal("x")); err != nil || got != "x" {
|
||||
t.Errorf("passphrase keys must be reproducible: %q %v", got, err)
|
||||
}
|
||||
if _, err := New(" "); err == nil {
|
||||
t.Error("an empty key must be rejected")
|
||||
}
|
||||
}
|
||||
+91
-2
@@ -6,6 +6,8 @@ import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -53,9 +55,39 @@ func listFromCSV(in []string) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// normalizeTags trims tags, drops blanks and repeats (ignoring case; the first spelling wins)
|
||||
// and keeps the list short enough to stay readable in the dashboard.
|
||||
func normalizeTags(in []string) ([]string, error) {
|
||||
var out []string
|
||||
seen := map[string]bool{}
|
||||
for _, t := range in {
|
||||
t = strings.Join(strings.Fields(t), " ")
|
||||
if t == "" || seen[strings.ToLower(t)] {
|
||||
continue
|
||||
}
|
||||
if len([]rune(t)) > 32 {
|
||||
return nil, errors.New("A tag can be at most 32 characters.")
|
||||
}
|
||||
if strings.ContainsAny(t, ",<>\"") {
|
||||
return nil, errors.New("Tags can't contain commas, quotes or angle brackets.")
|
||||
}
|
||||
seen[strings.ToLower(t)] = true
|
||||
out = append(out, t)
|
||||
}
|
||||
if len(out) > 10 {
|
||||
return nil, errors.New("Use at most 10 tags.")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// validateMapping checks m against org c and normalises it. Caller holds s.mu.
|
||||
func (s *Server) validateMapping(c *orgCtx, m *model.Mapping) error {
|
||||
m.Name = strings.TrimSpace(m.Name)
|
||||
tags, err := normalizeTags(m.Tags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m.Tags = tags
|
||||
m.Hostname = strings.ToLower(strings.TrimSpace(m.Hostname))
|
||||
m.OrgID = c.Org.ID
|
||||
switch m.Proto {
|
||||
@@ -67,7 +99,16 @@ func (s *Server) validateMapping(c *orgCtx, m *model.Mapping) error {
|
||||
if edge == nil || !s.edgeAllowedFor(edge, c.Org.ID) {
|
||||
return errors.New("choose an edge")
|
||||
}
|
||||
if len(m.Targets) == 0 {
|
||||
redirect := strings.TrimSpace(m.RedirectTo) != ""
|
||||
if redirect {
|
||||
if !model.Web(m.Proto) {
|
||||
return errors.New("only websites (http or https) can redirect")
|
||||
}
|
||||
m.Targets = []model.Target{} // a redirect has nothing behind it
|
||||
} else {
|
||||
m.RedirectTo, m.RedirectPermanent, m.RedirectKeepPath = "", false, false
|
||||
}
|
||||
if len(m.Targets) == 0 && !redirect {
|
||||
return errors.New("add at least one target")
|
||||
}
|
||||
if m.Proto == model.ProtoUDP && len(m.Targets) > 1 {
|
||||
@@ -143,9 +184,19 @@ func (s *Server) validateMapping(c *orgCtx, m *model.Mapping) error {
|
||||
return errors.New("PROXY protocol must be v1, v2 or off")
|
||||
}
|
||||
// web-only options
|
||||
if !model.Web(m.Proto) {
|
||||
if redirect {
|
||||
to, err := cleanRedirect(m)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m.RedirectTo = to
|
||||
}
|
||||
if !model.Web(m.Proto) || redirect {
|
||||
m.Auth, m.Rules, m.RewriteHost = model.Auth{}, nil, ""
|
||||
m.RedirectHTTP, m.BackendHTTPS, m.HealthPath = false, false, ""
|
||||
if redirect {
|
||||
m.HealthSeconds = -1 // nothing to check
|
||||
}
|
||||
} else {
|
||||
a := &m.Auth
|
||||
a.AllowedRoles, a.AllowedUsers, a.AllowedMails = listFromCSV(a.AllowedRoles), listFromCSV(a.AllowedUsers), listFromCSV(a.AllowedMails)
|
||||
@@ -204,8 +255,24 @@ func (s *Server) validateMapping(c *orgCtx, m *model.Mapping) error {
|
||||
if m.Proto == o.Proto && model.HostBased(m.Proto) && m.BindIP == o.BindIP && m.Hostname != o.Hostname {
|
||||
continue // same listener, different hostname
|
||||
}
|
||||
if m.Proto == o.Proto && model.Web(m.Proto) && m.BindIP != o.BindIP && isWild(m.BindIP) != isWild(o.BindIP) {
|
||||
continue // a website on every address steps aside for one that names an address
|
||||
}
|
||||
return fmt.Errorf("%s port %s on %s is already used by %q. Choose another port or address.", strings.ToUpper(netClass(m.Proto)), portLabel(*m), o.BindIP, o.Name)
|
||||
}
|
||||
// an https resource with the redirect on also answers on port 80 at its address, so a raw port
|
||||
// there would clash with it (and the other way round)
|
||||
for _, o := range s.st.Mappings {
|
||||
if o.ID == m.ID || o.EdgeID != m.EdgeID || !overlap(o.BindIP, m.BindIP) {
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case m.Proto == model.ProtoHTTPS && m.RedirectHTTP && netClass(o.Proto) == "tcp" && !model.Web(o.Proto) && o.Port <= 80 && 80 <= o.LastPort():
|
||||
return fmt.Errorf("Redirecting HTTP to HTTPS also uses TCP port 80 on %s, which %q uses. Turn the redirect off or choose another port for %q.", o.BindIP, o.Name, o.Name)
|
||||
case o.Proto == model.ProtoHTTPS && o.RedirectHTTP && netClass(m.Proto) == "tcp" && !model.Web(m.Proto) && m.Port <= 80 && 80 <= m.LastPort():
|
||||
return fmt.Errorf("TCP port 80 on %s is also used by %q to redirect HTTP to HTTPS. Choose another port, or turn that redirect off.", o.BindIP, o.Name)
|
||||
}
|
||||
}
|
||||
if m.Name == "" {
|
||||
if m.Hostname != "" {
|
||||
m.Name = m.Hostname
|
||||
@@ -216,6 +283,28 @@ func (s *Server) validateMapping(c *orgCtx, m *model.Mapping) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// cleanRedirect checks where a redirect sends visitors and refuses a destination that would
|
||||
// send them straight back to the same address.
|
||||
func cleanRedirect(m *model.Mapping) (string, error) {
|
||||
to := strings.TrimSpace(m.RedirectTo)
|
||||
u, err := url.Parse(to)
|
||||
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Hostname() == "" || u.User != nil {
|
||||
return "", errors.New("Redirect to a full address such as https://www.example.com.")
|
||||
}
|
||||
if len(to) > 2048 || strings.ContainsAny(to, " \t\r\n$") {
|
||||
return "", errors.New("The redirect address is too long or contains spaces or a dollar sign.")
|
||||
}
|
||||
port := u.Port()
|
||||
if port == "" {
|
||||
port = map[string]string{"http": "80", "https": "443"}[u.Scheme]
|
||||
}
|
||||
same := u.Scheme == m.Proto && strings.EqualFold(u.Hostname(), m.Hostname) && port == strconv.Itoa(m.Port)
|
||||
if same && (m.RedirectKeepPath || u.Path == "" || u.Path == "/") {
|
||||
return "", errors.New("That redirect would send visitors back to the same address.")
|
||||
}
|
||||
return to, nil
|
||||
}
|
||||
|
||||
// portLabel is "25565" or, for a range, "2456-2458".
|
||||
func portLabel(m model.Mapping) string {
|
||||
if m.LastPort() > m.Port {
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package hammerhead
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// websites share a port by hostname, and a website on every address steps aside for one that names an address
|
||||
func TestWebsitesShareAPortAndWildcardsStepAside(t *testing.T) {
|
||||
s, err := New(t.TempDir(), Options{AdminEmail: "admin@test.local", AdminPassword: "password123", MasterKey: key})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.Close()
|
||||
do := apiClient(t, s)
|
||||
var me struct{ Orgs []struct{ ID string } }
|
||||
_, b := do("GET", "/api/me", nil)
|
||||
json.Unmarshal(b, &me)
|
||||
org := me.Orgs[0].ID
|
||||
var edge, site struct{ ID string }
|
||||
_, b = do("POST", "/api/edges", map[string]any{"name": "e", "tunnelAddr": "203.0.113.5:51820"})
|
||||
json.Unmarshal(b, &edge)
|
||||
_, b = do("POST", "/api/orgs/"+org+"/sites", map[string]any{"name": "home"})
|
||||
json.Unmarshal(b, &site)
|
||||
if code, b := do("POST", "/api/orgs/"+org+"/domains", map[string]any{"name": "example.test", "edgeId": edge.ID, "ip": "203.0.113.10"}); code != 200 {
|
||||
t.Fatalf("domain: %d %s", code, b)
|
||||
}
|
||||
base := "/api/orgs/" + org + "/mappings"
|
||||
open := func(m map[string]any) (int, string) {
|
||||
m["edgeId"] = edge.ID
|
||||
m["enabled"] = true
|
||||
m["targets"] = []any{map[string]any{"siteId": site.ID, "host": "127.0.0.1", "port": 8080}}
|
||||
code, b := do("POST", base, m)
|
||||
return code, string(b)
|
||||
}
|
||||
want := func(code int, got int, body, what string) {
|
||||
t.Helper()
|
||||
if code != got {
|
||||
t.Errorf("%s: got %d, want %d (%s)", what, got, code, body)
|
||||
}
|
||||
}
|
||||
|
||||
c, body := open(map[string]any{"proto": "https", "bindIp": "0.0.0.0", "port": 443, "hostname": "a.example.test"})
|
||||
want(200, c, body, "first website on every address")
|
||||
c, body = open(map[string]any{"proto": "https", "bindIp": "203.0.113.10", "port": 443, "hostname": "b.example.test"})
|
||||
want(200, c, body, "a website naming an address shares the port with the wildcard one")
|
||||
c, body = open(map[string]any{"proto": "https", "bindIp": "203.0.113.11", "port": 443, "hostname": "c.example.test"})
|
||||
want(200, c, body, "a second named address")
|
||||
c, body = open(map[string]any{"proto": "https", "bindIp": "203.0.113.10", "port": 443, "hostname": "d.example.test"})
|
||||
want(200, c, body, "another hostname on a named address")
|
||||
// still refused: the same hostname twice on one listener, a raw port on a website's port, and another protocol
|
||||
c, body = open(map[string]any{"proto": "https", "bindIp": "203.0.113.10", "port": 443, "hostname": "b.example.test"})
|
||||
want(400, c, body, "the same hostname twice on one address")
|
||||
c, body = open(map[string]any{"proto": "tcp", "bindIp": "0.0.0.0", "port": 443})
|
||||
want(400, c, body, "a raw port on top of websites")
|
||||
c, body = open(map[string]any{"proto": "http", "bindIp": "203.0.113.10", "port": 443, "hostname": "e.example.test"})
|
||||
want(400, c, body, "http on an https port")
|
||||
|
||||
// the HTTP to HTTPS redirect also uses port 80 at its address
|
||||
c, body = open(map[string]any{"proto": "tcp", "bindIp": "203.0.113.20", "port": 80})
|
||||
want(200, c, body, "a raw port 80 on its own address")
|
||||
c, body = open(map[string]any{"proto": "https", "bindIp": "203.0.113.20", "port": 8443, "hostname": "f.example.test", "redirectHttp": true})
|
||||
want(400, c, body, "a redirect onto a raw port 80")
|
||||
if !strings.Contains(body, "port 80") {
|
||||
t.Errorf("the error should name port 80: %s", body)
|
||||
}
|
||||
c, body = open(map[string]any{"proto": "https", "bindIp": "203.0.113.20", "port": 8443, "hostname": "f.example.test"})
|
||||
want(200, c, body, "the same website without the redirect")
|
||||
c, body = open(map[string]any{"proto": "https", "bindIp": "203.0.113.21", "port": 443, "hostname": "g.example.test", "redirectHttp": true})
|
||||
want(200, c, body, "a redirect where port 80 is free")
|
||||
c, body = open(map[string]any{"proto": "tcp", "bindIp": "203.0.113.21", "port": 80})
|
||||
want(400, c, body, "a raw port 80 where a redirect lives")
|
||||
c, body = open(map[string]any{"proto": "http", "bindIp": "203.0.113.21", "port": 80, "hostname": "h.example.test"})
|
||||
want(200, c, body, "an http website beside a redirect on port 80")
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package hammerhead
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// a redirect is a website with a destination instead of a target
|
||||
func TestDomainRedirects(t *testing.T) {
|
||||
s, err := New(t.TempDir(), Options{AdminEmail: "admin@test.local", AdminPassword: "password123", MasterKey: key})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.Close()
|
||||
do := apiClient(t, s)
|
||||
var me struct{ Orgs []struct{ ID string } }
|
||||
_, b := do("GET", "/api/me", nil)
|
||||
json.Unmarshal(b, &me)
|
||||
org := me.Orgs[0].ID
|
||||
var edge, site struct{ ID string }
|
||||
_, b = do("POST", "/api/edges", map[string]any{"name": "e", "tunnelAddr": "203.0.113.5:51820"})
|
||||
json.Unmarshal(b, &edge)
|
||||
_, b = do("POST", "/api/orgs/"+org+"/sites", map[string]any{"name": "home"})
|
||||
json.Unmarshal(b, &site)
|
||||
if code, b := do("POST", "/api/orgs/"+org+"/domains", map[string]any{"name": "example.test", "edgeId": edge.ID, "ip": "203.0.113.10"}); code != 200 {
|
||||
t.Fatalf("domain: %d %s", code, b)
|
||||
}
|
||||
base := "/api/orgs/" + org + "/mappings"
|
||||
open := func(m map[string]any) (int, []byte) {
|
||||
m["edgeId"], m["enabled"], m["bindIp"] = edge.ID, true, "0.0.0.0"
|
||||
return do("POST", base, m)
|
||||
}
|
||||
|
||||
// the redirect needs no target, and anything that only makes sense with one is dropped
|
||||
code, raw := open(map[string]any{"proto": "https", "port": 443, "hostname": "old.example.test", "redirectTo": " https://new.example.test/home ", "redirectPermanent": true, "redirectKeepPath": true,
|
||||
"auth": map[string]any{"password": true}, "rewriteHost": "x", "healthPath": "/h"})
|
||||
if code != 200 {
|
||||
t.Fatalf("create redirect: %d %s", code, raw)
|
||||
}
|
||||
var m struct {
|
||||
ID, RedirectTo, RewriteHost, HealthPath string
|
||||
RedirectPermanent, RedirectKeepPath bool
|
||||
Targets []any
|
||||
HealthSeconds int
|
||||
Auth struct{ Password bool }
|
||||
}
|
||||
json.Unmarshal(raw, &m)
|
||||
if m.RedirectTo != "https://new.example.test/home" || !m.RedirectPermanent || !m.RedirectKeepPath || len(m.Targets) != 0 ||
|
||||
m.Auth.Password || m.RewriteHost != "" || m.HealthPath != "" || m.HealthSeconds != -1 {
|
||||
t.Fatalf("redirect was not normalised: %s", raw)
|
||||
}
|
||||
|
||||
// it shares the port with other websites by hostname, like any website
|
||||
if code, raw := open(map[string]any{"proto": "https", "port": 443, "hostname": "app.example.test", "targets": []any{map[string]any{"siteId": site.ID, "host": "127.0.0.1", "port": 8080}}}); code != 200 {
|
||||
t.Fatalf("a website beside the redirect: %d %s", code, raw)
|
||||
}
|
||||
// and its plain-HTTP twin on port 80 is its own resource
|
||||
if code, raw := open(map[string]any{"proto": "http", "port": 80, "hostname": "old.example.test", "redirectTo": "https://new.example.test/home"}); code != 200 {
|
||||
t.Fatalf("http side of the redirect: %d %s", code, raw)
|
||||
}
|
||||
|
||||
for name, in := range map[string]map[string]any{
|
||||
"not a web port": {"proto": "tcp", "port": 9000, "redirectTo": "https://new.example.test"},
|
||||
"no scheme": {"proto": "https", "port": 443, "hostname": "a.example.test", "redirectTo": "new.example.test"},
|
||||
"other scheme": {"proto": "https", "port": 443, "hostname": "a.example.test", "redirectTo": "ftp://new.example.test"},
|
||||
"credentials": {"proto": "https", "port": 443, "hostname": "a.example.test", "redirectTo": "https://u:p@new.example.test"},
|
||||
"a dollar sign": {"proto": "https", "port": 443, "hostname": "a.example.test", "redirectTo": "https://new.example.test/$1"},
|
||||
"back to itself": {"proto": "https", "port": 443, "hostname": "a.example.test", "redirectTo": "https://a.example.test/"},
|
||||
"itself with a path": {"proto": "https", "port": 443, "hostname": "a.example.test", "redirectTo": "https://a.example.test/x", "redirectKeepPath": true},
|
||||
"host not in domains": {"proto": "https", "port": 443, "hostname": "a.elsewhere.test", "redirectTo": "https://new.example.test"},
|
||||
} {
|
||||
if code, raw := open(in); code != 400 {
|
||||
t.Errorf("%s: expected 400, got %d %s", name, code, raw)
|
||||
}
|
||||
}
|
||||
// http to https on the same name is the normal use, and is allowed
|
||||
if code, raw := open(map[string]any{"proto": "http", "port": 8080, "hostname": "a.example.test", "redirectTo": "https://a.example.test/"}); code != 200 {
|
||||
t.Errorf("an http to https redirect: %d %s", code, raw)
|
||||
}
|
||||
|
||||
// turning a redirect into a normal website needs a target again
|
||||
code, raw = do("PUT", base+"/"+m.ID, map[string]any{"proto": "https", "edgeId": edge.ID, "bindIp": "0.0.0.0", "port": 443, "hostname": "old.example.test", "enabled": true})
|
||||
if code != 400 || !strings.Contains(string(raw), "target") {
|
||||
t.Errorf("a website with no target and no redirect: %d %s", code, raw)
|
||||
}
|
||||
|
||||
// deleting a site must not sweep away redirects, which have no targets
|
||||
if code, raw := do("DELETE", "/api/orgs/"+org+"/sites/"+site.ID, nil); code != 200 {
|
||||
t.Fatalf("delete site: %d %s", code, raw)
|
||||
}
|
||||
_, raw = do("GET", "/api/orgs/"+org+"/state", nil)
|
||||
var st struct {
|
||||
Mappings []struct{ Hostname, RedirectTo string }
|
||||
}
|
||||
json.Unmarshal(raw, &st)
|
||||
var redirects, apps int
|
||||
for _, x := range st.Mappings {
|
||||
if x.RedirectTo != "" {
|
||||
redirects++
|
||||
}
|
||||
if x.Hostname == "app.example.test" {
|
||||
apps++
|
||||
}
|
||||
}
|
||||
if redirects != 3 || apps != 0 {
|
||||
t.Errorf("after deleting the site: %d redirects (want 3), %d apps (want 0): %s", redirects, apps, raw)
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -9,8 +9,8 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.veillax.com/Veillax/hammerhead/internal/secretbox"
|
||||
"git.veillax.com/Veillax/reef/model"
|
||||
"git.veillax.com/Veillax/reef/secretbox"
|
||||
)
|
||||
|
||||
func edgeConfigOf(t *testing.T, s *Server, token string) model.EdgeConfig {
|
||||
|
||||
@@ -127,6 +127,7 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.HandleFunc("POST /api/orgs/{org}/apikeys", s.withOrg(true, s.createKey))
|
||||
mux.HandleFunc("DELETE /api/orgs/{org}/apikeys/{id}", s.withOrg(true, s.deleteKey))
|
||||
mux.HandleFunc("POST /api/orgs/{org}/sites", s.withOrg(true, s.addSite))
|
||||
mux.HandleFunc("PUT /api/orgs/{org}/sites/{id}", s.withOrg(true, s.putSite))
|
||||
mux.HandleFunc("DELETE /api/orgs/{org}/sites/{id}", s.withOrg(true, s.deleteSite))
|
||||
mux.HandleFunc("GET /api/orgs/{org}/sites/{id}/containers", s.withOrg(true, s.siteContainers))
|
||||
mux.HandleFunc("POST /api/orgs/{org}/domains", s.withOrg(true, s.addDomain))
|
||||
|
||||
@@ -16,8 +16,8 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.veillax.com/Veillax/hammerhead/internal/secretbox"
|
||||
"git.veillax.com/Veillax/reef/model"
|
||||
"git.veillax.com/Veillax/reef/secretbox"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
package hammerhead
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNormalizeTags(t *testing.T) {
|
||||
got, err := normalizeTags([]string{" Minecraft ", "minecraft", "", "prod east", "PROD east"})
|
||||
if err != nil || !reflect.DeepEqual(got, []string{"Minecraft", "prod east"}) {
|
||||
t.Fatalf("got %v, %v", got, err)
|
||||
}
|
||||
if got, err := normalizeTags(nil); err != nil || got != nil {
|
||||
t.Fatalf("nil in should give nil out: %v, %v", got, err)
|
||||
}
|
||||
tooMany := make([]string, 11)
|
||||
for i := range tooMany {
|
||||
tooMany[i] = "t" + strings.Repeat("x", i)
|
||||
}
|
||||
for _, bad := range [][]string{{strings.Repeat("a", 33)}, {"a,b"}, {"<b>"}, tooMany} {
|
||||
if _, err := normalizeTags(bad); err == nil {
|
||||
t.Errorf("accepted %v", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSiteTagsAndRenameThroughTheAPI(t *testing.T) {
|
||||
s, err := New(t.TempDir(), Options{AdminEmail: "admin@test.local", AdminPassword: "password123", AlertInterval: time.Hour})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.Close()
|
||||
do := apiClient(t, s)
|
||||
_, b := do("GET", "/api/me", nil)
|
||||
var me struct{ Orgs []struct{ ID string } }
|
||||
json.Unmarshal(b, &me)
|
||||
org := me.Orgs[0].ID
|
||||
|
||||
code, raw := do("POST", "/api/orgs/"+org+"/sites", map[string]any{"name": "Home"})
|
||||
if code != 200 {
|
||||
t.Fatalf("add site: %d %s", code, raw)
|
||||
}
|
||||
var site struct{ ID string }
|
||||
json.Unmarshal(raw, &site)
|
||||
|
||||
if code, raw := do("PUT", "/api/orgs/"+org+"/sites/"+site.ID, map[string]any{"name": " Home lab ", "tags": []string{"Lab", "lab", "nas"}}); code != 200 {
|
||||
t.Fatalf("edit site: %d %s", code, raw)
|
||||
}
|
||||
_, raw = do("GET", "/api/orgs/"+org+"/state", nil)
|
||||
var st struct {
|
||||
Sites []struct {
|
||||
ID, Name string
|
||||
Tags []string
|
||||
Local bool
|
||||
}
|
||||
}
|
||||
json.Unmarshal(raw, &st)
|
||||
found := false
|
||||
for _, x := range st.Sites {
|
||||
if x.ID == site.ID {
|
||||
found = true
|
||||
if x.Name != "Home lab" || !reflect.DeepEqual(x.Tags, []string{"Lab", "nas"}) {
|
||||
t.Fatalf("site after edit: %+v", x)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("site missing from state: %s", raw)
|
||||
}
|
||||
|
||||
for name, body := range map[string]map[string]any{"blank name": {"name": " "}, "bad tag": {"name": "x", "tags": []string{"a,b"}}} {
|
||||
if code, _ := do("PUT", "/api/orgs/"+org+"/sites/"+site.ID, body); code != 400 {
|
||||
t.Errorf("%s: expected 400, got %d", name, code)
|
||||
}
|
||||
}
|
||||
if code, _ := do("PUT", "/api/orgs/"+org+"/sites/nope", map[string]any{"name": "x"}); code != 404 {
|
||||
t.Errorf("unknown site: expected 404, got %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSandbarKeyIsNeverSentBackAndBlankKeepsIt(t *testing.T) {
|
||||
s, err := New(t.TempDir(), Options{AdminEmail: "admin@test.local", AdminPassword: "password123", AlertInterval: time.Hour})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.Close()
|
||||
do := apiClient(t, s)
|
||||
_, b := do("GET", "/api/me", nil)
|
||||
var me struct{ Orgs []struct{ ID string } }
|
||||
json.Unmarshal(b, &me)
|
||||
org := me.Orgs[0].ID
|
||||
|
||||
code, raw := do("POST", "/api/orgs/"+org+"/alerts/channels", map[string]any{"name": "pager", "kind": "sandbar", "target": "https://sandbar.example.com", "token": "sb_topsecret", "policy": "p1", "enabled": true})
|
||||
if code != 200 {
|
||||
t.Fatalf("create channel: %d %s", code, raw)
|
||||
}
|
||||
_, raw = do("GET", "/api/orgs/"+org+"/alerts", nil)
|
||||
if strings.Contains(string(raw), "sb_topsecret") || strings.Contains(string(raw), "sandbar.example.com/api") {
|
||||
t.Fatalf("the overview leaks Sandbar details: %s", raw)
|
||||
}
|
||||
var ov struct {
|
||||
Channels []struct{ ID, Kind, Policy string }
|
||||
}
|
||||
json.Unmarshal(raw, &ov)
|
||||
if len(ov.Channels) != 1 || ov.Channels[0].Kind != "sandbar" || ov.Channels[0].Policy != "p1" {
|
||||
t.Fatalf("overview: %s", raw)
|
||||
}
|
||||
// editing without re-entering the address or key keeps both
|
||||
if code, raw := do("PUT", "/api/orgs/"+org+"/alerts/channels/"+ov.Channels[0].ID, map[string]any{"name": "pager 2", "kind": "sandbar", "policy": "p2", "enabled": true}); code != 200 {
|
||||
t.Fatalf("edit channel: %d %s", code, raw)
|
||||
}
|
||||
s.mu.Lock()
|
||||
ch := s.st.Channels[0]
|
||||
kept := ch.Token == "sb_topsecret" && ch.Target == "https://sandbar.example.com" && ch.Policy == "p2"
|
||||
s.mu.Unlock()
|
||||
if !kept {
|
||||
t.Fatalf("an edit lost the saved key or address: %+v", ch)
|
||||
}
|
||||
}
|
||||
+109
-19
@@ -159,6 +159,10 @@
|
||||
.badge-warning { background:var(--warning-bg); color:var(--warning); }
|
||||
.badge-danger { background:var(--danger-bg); color:var(--danger); }
|
||||
.badge-accent { background:var(--accent-soft); color:var(--accent-text); }
|
||||
.tags { display:flex; flex-wrap:wrap; gap:var(--s1); margin-top:var(--s1); }
|
||||
.tagbar { display:flex; flex-wrap:wrap; gap:var(--s2); margin-bottom:var(--s3); }
|
||||
.chip { min-height:44px; padding:0 var(--s3); border:var(--border-w) solid var(--border); border-radius:var(--radius); background:var(--surface); color:var(--text); font:inherit; cursor:pointer; }
|
||||
.chip.on { background:var(--accent); border-color:var(--accent); color:var(--accent-contrast); }
|
||||
.tag { display:inline-block; padding:1px var(--s2); border:var(--border-w) solid var(--border); border-radius:var(--radius); font-size:var(--text-xs); color:var(--text-muted); }
|
||||
pre { background:var(--surface-2); border:var(--border-w) solid var(--border); border-radius:var(--radius); padding:var(--s3); overflow:auto; margin:var(--s2) 0 0; user-select:all; white-space:pre-wrap; word-break:break-all; }
|
||||
|
||||
@@ -271,6 +275,25 @@ const fmtTime = d => new Date(d).toLocaleString(undefined, {month:'short', day:'
|
||||
const byId = (list, id) => (list || []).find(x => x.id === id);
|
||||
const field = (label, el, hint) => h('label', {}, label, el, hint ? h('span', {class:'sub'}, hint) : null);
|
||||
const chk = (label, checked, onchange) => { const i = h('input', {type:'checkbox', checked: !!checked}); if (onchange) i.onchange = () => onchange(i.checked); return Object.assign(h('label', {class:'chk'}, i, label), {box:i}); };
|
||||
// ----- tags -----
|
||||
// Tags are free-form labels on resources and sites. The filter choice is remembered per list until the page reloads.
|
||||
const TAGSEL = {ports:'', sites:''};
|
||||
const allTags = (...lists) => [...new Set(lists.flat().flatMap(x => x.tags || []))].sort((a, b) => a.localeCompare(b));
|
||||
const hasTag = (x, t) => !t || (x.tags || []).some(v => v.toLowerCase() === t.toLowerCase());
|
||||
const tagList = x => (x.tags || []).length ? h('div', {class:'tags'}, x.tags.map(t => h('span', {class:'tag'}, t))) : null;
|
||||
function tagInput(tags, known) {
|
||||
const id = 'tags-' + Math.random().toString(36).slice(2, 8);
|
||||
const i = h('input', {value:(tags || []).join(', '), placeholder:'e.g. minecraft, production', list:id});
|
||||
const el = field('Tags', h('div', {}, i, h('datalist', {id}, known.map(t => h('option', {value:t})))), 'Optional. Separate with commas. Use them to filter this list.');
|
||||
return {el, get:() => i.value.split(',').map(t => t.trim()).filter(Boolean)};
|
||||
}
|
||||
// chips that narrow a list to one tag; pressing the active chip, or "All", clears it
|
||||
function tagBar(key, tags) {
|
||||
if (!tags.length) return null;
|
||||
const chip = (label, val) => h('button', {class:'chip' + (TAGSEL[key] === val ? ' on' : ''), type:'button', 'aria-pressed':String(TAGSEL[key] === val),
|
||||
onclick:() => { TAGSEL[key] = TAGSEL[key] === val ? '' : val; refresh(); }}, label);
|
||||
return h('div', {class:'tagbar', role:'group', 'aria-label':'Filter by tag'}, chip('All', ''), tags.map(t => chip(t, t)));
|
||||
}
|
||||
// ----- versions and updates -----
|
||||
// A progress bar and live log for an update. load() returns {running, stage, pct, lines}; the panel polls it
|
||||
// until the update is over, and keeps going through the gap while a program restarts into its new version.
|
||||
@@ -600,7 +623,9 @@ views.ports = async () => {
|
||||
S.me.serverAdmin ? h('button', {class:'btn', onclick:() => { selectTab('edges'); history.pushState(null, '', TAB_PATH.edges); render(); }}, 'Go to Edges') : null);
|
||||
const unsetDash = !st.dashboardUrl && st.mappings.some(m => m.auth && (m.auth.sso || m.auth.password || m.auth.pin || m.auth.email));
|
||||
const traffic = await api('GET', O('/traffic?hours=24')).catch(() => ({}));
|
||||
const rows = st.mappings.slice().sort((a, b) => a.edgeId.localeCompare(b.edgeId) || a.bindIp.localeCompare(b.bindIp) || a.port - b.port);
|
||||
const tags = allTags(st.mappings);
|
||||
if (TAGSEL.ports && !tags.some(t => t.toLowerCase() === TAGSEL.ports.toLowerCase())) TAGSEL.ports = ''; // the last resource with that tag is gone
|
||||
const rows = st.mappings.filter(m => hasTag(m, TAGSEL.ports)).sort((a, b) => a.edgeId.localeCompare(b.edgeId) || a.bindIp.localeCompare(b.bindIp) || a.port - b.port);
|
||||
const isWebRow = m => m.proto === 'http' || m.proto === 'https' || m.proto === 'tls';
|
||||
const section = (title, list) => list.length ? h('div', {class:'card flush'}, h('div', {class:'sect-h'}, title + ' (' + list.length + ')'), h('div', {class:'scroll'}, h('table', {},
|
||||
h('thead', {}, h('tr', {}, ['Status', 'Name', title === 'Websites' ? 'Address' : 'Public address', 'Targets', 'Traffic (24 hours)', ''].map(t => h('th', {}, t)))),
|
||||
@@ -609,7 +634,7 @@ views.ports = async () => {
|
||||
S.headerAction = ['Add resource', () => portForm()];
|
||||
const q = h('input', {type:'search', placeholder:'Search resources…', oninput:() => { const t = q.value.toLowerCase(); table.querySelectorAll('tbody tr').forEach(tr => { tr.style.display = tr.textContent.toLowerCase().includes(t) ? '' : 'none'; }); }});
|
||||
return h('div', {}, unsetDash ? h('div', {class:'notice'}, 'Some ports have a login in front of them, but the dashboard URL isn\'t set. Visitors are sent back to this address\'s login page, so set it under Server → Settings if people reach Hammerhead by a different address.') : null,
|
||||
rows.length > 5 ? h('div', {class:'toolbar'}, q) : null, table);
|
||||
tagBar('ports', tags), rows.length > 5 ? h('div', {class:'toolbar'}, q) : null, table);
|
||||
};
|
||||
|
||||
function spark(series) {
|
||||
@@ -641,23 +666,69 @@ function mappingRow(m, st, series) {
|
||||
const pubAddr = web ? m.proto + '://' + (m.hostname || '*') + (m.port === (m.proto === 'https' ? 443 : 80) ? '' : ':' + portText(m)) : (m.hostname ? m.hostname + ' @ ' : '') + m.bindIp + ':' + portText(m);
|
||||
return h('tr', {},
|
||||
h('td', {title:why}, badge(label, tone)),
|
||||
h('td', {}, h('div', {}, m.name, ' ', h('span', {class:'tag'}, m.proto), m.proxyProtocol ? h('span', {class:'tag', style:'margin-left:4px', title:'the target is told the real client address'}, 'proxy ' + m.proxyProtocol) : null, prot ? h('span', {style:'margin-left:4px', title:'Visitors have to sign in'}, badge('Login', 'accent')) : null),
|
||||
h('td', {}, h('div', {}, m.name, ' ', h('span', {class:'tag'}, m.proto), m.redirectTo ? h('span', {class:'tag', style:'margin-left:4px'}, 'redirect') : null,m.proxyProtocol ? h('span', {class:'tag', style:'margin-left:4px', title:'the target is told the real client address'}, 'proxy ' + m.proxyProtocol) : null, prot ? h('span', {style:'margin-left:4px', title:'Visitors have to sign in'}, badge('Login', 'accent')) : null),
|
||||
tagList(m),
|
||||
s.error ? h('div', {class:'err small mono clip', title:s.error}, s.error) : null,
|
||||
s.cert && m.proto === 'https' ? h('div', {class:'small clip ' + (s.cert.startsWith('error') ? 'err' : 'dim'), title:s.cert}, 'Certificate: ' + s.cert) : null),
|
||||
h('td', {}, h('span', {class:'mono'}, pubAddr), h('div', {class:'dim small'}, (edge ? edge.name : '?') + (web && m.bindIp !== '0.0.0.0' ? ' · ' + m.bindIp : ''))),
|
||||
h('td', {class:'small'}, m.targets.map((t, i) => {
|
||||
h('td', {class:'small'}, m.redirectTo ? h('div', {}, h('span', {class:'mono clip', title:m.redirectTo}, '→ ' + m.redirectTo),
|
||||
h('div', {class:'dim'}, (m.redirectPermanent ? 'Permanent (301)' : 'Temporary (302)') + (m.redirectKeepPath ? ', keeps the path' : ''))) : m.targets.map((t, i) => {
|
||||
const th = (s.targets || [])[i], site = byId(st.sites, t.siteId);
|
||||
return h('div', {title:th && !th.healthy ? th.detail : '', style:'margin-bottom:4px'}, badge(th ? (th.healthy ? 'Up' : 'Down') : 'Unknown', th ? (th.healthy ? 'success' : 'danger') : ''), ' ', site ? site.name : '?', h('div', {class:'mono'}, t.host + ':' + t.port));
|
||||
})),
|
||||
h('td', {class:'small dim'}, m.total && m.total.conns ? [`${m.total.conns} conn`, h('br'), `${bytes(m.total.bytesIn)} in, ${bytes(m.total.bytesOut)} out`] : 'None yet', spark(series)),
|
||||
h('td', {}, h('div', {class:'actions'},
|
||||
h('button', {class:'ghost', onclick:() => portForm(m)}, 'Edit'),
|
||||
h('button', {class:'ghost', onclick:() => m.redirectTo ? redirectForm(m) : portForm(m)}, 'Edit'),
|
||||
h('button', {class:'ghost', onclick: act(async () => { await api('PUT', O('/mappings/' + m.id), {...m, enabled:!m.enabled}); refresh(); })}, m.enabled ? 'Pause' : 'Enable'),
|
||||
web && prot ? h('button', {class:'ghost', onclick:() => shareDialog(m)}, 'Share') : null,
|
||||
web && prot ? h('button', {class:'ghost', title:'Everyone currently signed in to this resource has to sign in again', onclick: ask({title:`Sign everyone out of ${m.name}?`, body:'Everyone who is signed in to this resource has to sign in again. They can sign back in straight away.', confirm:'Sign out visitors', danger:false}, async () => { await api('POST', O(`/mappings/${m.id}/revoke`)); toast('Visitors signed out'); })}, 'Sign out visitors') : null,
|
||||
h('button', {class:'ghost danger', onclick: ask({title:`Close ${m.name}?`, body:'The port closes and visitors can no longer reach it. You can open it again later.', confirm:'Close resource'}, async () => { await api('DELETE', O('/mappings/' + m.id)); refresh(); })}, 'Close'))));
|
||||
}
|
||||
|
||||
// A redirect sends visitors from one hostname to another address. New ones open the HTTPS side on port 443 and,
|
||||
// if asked, the plain HTTP side on port 80, so both ways of typing the old name land on the new one.
|
||||
function redirectForm(existing) {
|
||||
const st = S.st, m = existing || {proto:'https', enabled:true, bindIp:'0.0.0.0', redirectPermanent:true, redirectKeepPath:true};
|
||||
const edgeSel = h('select', {}, st.edges.map(e => h('option', {value:e.id, selected:e.id === m.edgeId}, e.name + (e.online ? '' : ' (offline)'))));
|
||||
const ipIn = h('select', {});
|
||||
const refreshIPs = () => {
|
||||
const e = byId(st.edges, edgeSel.value), keep = ipIn.value || m.bindIp || '0.0.0.0';
|
||||
const saved = existing && m.edgeId === edgeSel.value && m.bindIp !== '0.0.0.0' ? [m.bindIp] : [];
|
||||
const ips = [...new Set([...((e && e.ips) || []), ...saved])];
|
||||
const label = ip => { const d = st.domains.filter(x => x.ip === ip).map(x => x.name); return d.length ? `${ip} (${d.join(', ')})` : ip; };
|
||||
ipIn.replaceChildren(h('option', {value:'0.0.0.0'}, 'Every address on the edge'), ...ips.map(ip => h('option', {value:ip}, label(ip))));
|
||||
ipIn.value = ips.includes(keep) ? keep : '0.0.0.0';
|
||||
};
|
||||
edgeSel.onchange = refreshIPs; refreshIPs();
|
||||
const from = h('input', {value:m.hostname || '', list:'rdoms', placeholder:'old.example.com or *.example.com'});
|
||||
const doms = h('datalist', {id:'rdoms'}, st.domains.map(d => h('option', {value:d.name})));
|
||||
const to = h('input', {value:m.redirectTo || '', placeholder:'https://new.example.com'});
|
||||
const kind = h('select', {}, [[true, 'Permanent (301): browsers and search engines remember it'], [false, 'Temporary (302): checked every time']].map(([v, t]) => h('option', {value:String(v), selected:!!m.redirectPermanent === v}, t)));
|
||||
const keep = chk('Keep the path and query, so old.example.com/a?b=1 goes to the same place on the new address', m.redirectKeepPath);
|
||||
const both = chk('Also redirect plain HTTP (port 80)', true);
|
||||
const name = h('input', {value:m.name || '', placeholder:'optional'}), tg = tagInput(m.tags, allTags(st.mappings));
|
||||
const save = act(async () => {
|
||||
const base = {name:name.value, edgeId:edgeSel.value, bindIp:ipIn.value || '0.0.0.0', hostname:from.value, redirectTo:to.value, redirectPermanent:kind.value === 'true',
|
||||
redirectKeepPath:keep.box.checked, tags:tg.get(), enabled:m.enabled !== false, targets:[]};
|
||||
if (existing) { await api('PUT', O('/mappings/' + m.id), {...base, proto:m.proto, port:m.port}); }
|
||||
else {
|
||||
await api('POST', O('/mappings'), {...base, proto:'https', port:443});
|
||||
if (both.box.checked) {
|
||||
try { await api('POST', O('/mappings'), {...base, proto:'http', port:80, name:name.value ? name.value + ' (HTTP)' : ''}); }
|
||||
catch (e) { throw new Error('The HTTPS redirect was added, but the HTTP one could not be: ' + e.message); }
|
||||
}
|
||||
}
|
||||
close(); toast(existing ? 'Redirect saved' : 'Redirect added'); refresh();
|
||||
});
|
||||
const close = modal(existing ? 'Edit redirect' : 'Add a domain redirect', h('div', {class:'stack'},
|
||||
h('p', {class:'dim'}, existing ? `This redirect answers ${m.proto === 'https' ? 'HTTPS on port ' : 'HTTP on port '}${m.port}.` : 'Visitors who open the old address are sent to the new one. HTTPS gets its certificate automatically.'),
|
||||
h('div', {class:'grid'}, field('Redirect this address', from, st.domains.length ? 'Must be under one of your domains' : 'Add a domain on the Domains tab first'), field('To', to, 'A full address, starting with http:// or https://')),
|
||||
doms, field('Type', kind), keep, existing ? null : both,
|
||||
h('div', {class:'grid'}, field('Edge', edgeSel), field('Public IP', ipIn, 'Domains pointed at an address are shown next to it')),
|
||||
h('div', {class:'grid'}, field('Name', name), tg.el),
|
||||
h('div', {class:'actions form-actions'}, h('button', {class:'ghost', onclick:() => close()}, 'Cancel'), h('button', {class:'btn', onclick:save}, existing ? 'Save changes' : 'Add redirect'))), {sticky:true});
|
||||
}
|
||||
|
||||
function portForm(existing) {
|
||||
const st = S.st, m = existing || { proto:'tcp', enabled:true, bindIp:'0.0.0.0', targets:[{siteId:'', host:'127.0.0.1', port:''}], auth:{}, rules:[] };
|
||||
const a = m.auth || {};
|
||||
@@ -665,6 +736,7 @@ function portForm(existing) {
|
||||
// the public address is picked from the edge's own addresses, each shown with the domains pointed at it
|
||||
const ipIn = h('select', {}), ipList = document.createTextNode('');
|
||||
const protoSel = h('select', {disabled:!!existing}, PROTOS.filter(p => !existing || p[0] !== 'both').map(([v, t]) => h('option', {value:v, selected:v === m.proto}, t)));
|
||||
const tagsIn = tagInput(m.tags, allTags(st.mappings));
|
||||
const name = h('input', {value:m.name || '', placeholder:'optional'}), port =h('input', {value:m.port ? portText(m) : '', placeholder:'25565 or 2456-2458'});
|
||||
const host = h('input', {value:m.hostname || '', list:'doms', placeholder:'app.example.com or *.example.com'});
|
||||
const domList = h('datalist', {id:'doms'}, st.domains.map(d => h('option', {value:d.name})));
|
||||
@@ -762,7 +834,7 @@ function portForm(existing) {
|
||||
if (!pr) throw new Error('Enter a port like 25565, or a range like 2456-2458');
|
||||
const roles = roleChecks.filter(x => x.c.box.checked).map(x => x.r.id);
|
||||
const base = {
|
||||
name:name.value, edgeId:edgeSel.value, bindIp:ipIn.value || '0.0.0.0', port:+pr[1], portEnd:pr[2] ? +pr[2] : 0, hostname:host.value, targets,
|
||||
name:name.value, edgeId:edgeSel.value, bindIp:ipIn.value || '0.0.0.0', port:+pr[1], portEnd:pr[2] ? +pr[2] : 0, hostname:host.value, targets, tags:tagsIn.get(),
|
||||
enabled:m.enabled !== false, rules:rules.filter(r => r.value.trim()),
|
||||
auth:{sso:ssoC.box.checked, password:pwC.box.checked, pin:pinC.box.checked, email:mailC.box.checked, allowedRoles:roles, allowedUsers:a.allowedUsers || [],
|
||||
allowedMails:mails.value.split(',').map(s => s.trim()).filter(Boolean), sessionHours:+hours.value || 0},
|
||||
@@ -777,7 +849,7 @@ function portForm(existing) {
|
||||
close(); toast(existing ? `Saved ${base.name || 'the resource'}` : `Added ${base.name || 'the resource'}`); refresh();
|
||||
});
|
||||
const close = existing ? modal('Edit ' + (m.name || 'resource'), h('div', {class:'stack'},
|
||||
h('div', {class:'grid'}, field('Name', name), field('Edge', edgeSel), field('Public IP', ipIn, 'Domains pointed at an address are shown next to it'), field('Protocol', protoSel), field('Public port', port, 'One port, or a range like 2456-2458 for TCP and UDP')),
|
||||
h('div', {class:'grid'}, field('Name', name), field('Edge', edgeSel), field('Public IP', ipIn, 'Domains pointed at an address are shown next to it'), field('Protocol', protoSel), field('Public port', port, 'One port, or a range like 2456-2458 for TCP and UDP'), tagsIn.el),
|
||||
ipList, domList, hostBox, proxyBox, h('fieldset', {}, h('legend', {}, 'Where does the traffic go?'), tgtBox), adv, webBox,
|
||||
h('div', {class:'actions form-actions'}, h('button', {class:'ghost', onclick:() => close()}, 'Cancel'), h('button', {class:'btn', onclick:save}, existing ? 'Save changes' : 'Open port'))), {sticky:true}) : wizardModal();
|
||||
|
||||
@@ -790,6 +862,7 @@ function portForm(existing) {
|
||||
['both', 'TCP + UDP', 'The same port for both, as many games want.'],
|
||||
['tls', 'TLS passthrough', 'Route encrypted traffic by hostname without decrypting it.'],
|
||||
['http', 'Website, plain HTTP', 'No certificate: for internal use or behind another proxy.'],
|
||||
['redirect', 'Domain redirect', 'Send visitors from one address to another, such as old.example.com to new.example.com.'],
|
||||
];
|
||||
const [accessFs, rulesFs, advFs] = [...webBox.children];
|
||||
let step = 0, chosen = false;
|
||||
@@ -829,6 +902,7 @@ function portForm(existing) {
|
||||
port.value = t === 'https' || t === 'tls' ? '443' : t === 'http' ? '80' : (targets[0] && targets[0].port ? String(targets[0].port) : '');
|
||||
};
|
||||
const pick = v => {
|
||||
if (v === 'redirect') { close(); redirectForm(); return; }
|
||||
protoSel.value = v; chosen = true; protoSel.onchange();
|
||||
if (v === 'https') redir.box.checked = true;
|
||||
port.value = ''; step = 1; draw();
|
||||
@@ -869,7 +943,7 @@ function portForm(existing) {
|
||||
} else if (cur === 'access') {
|
||||
kids.push(accessFs);
|
||||
} else {
|
||||
kids.push(field('Name', name, 'Optional. Shown in the list.'), h('div', {class:'card', style:'background:var(--surface-2); margin:0'}, summary()),
|
||||
kids.push(field('Name', name, 'Optional. Shown in the list.'), tagsIn.el, h('div', {class:'card', style:'background:var(--surface-2); margin:0'}, summary()),
|
||||
h('details', {}, h('summary', {class:'small dim', style:'cursor:pointer'}, 'More options (health checks' + (isWeb() ? ', rules, redirects' : '') + ')'),
|
||||
h('div', {class:'stack', style:'margin-top:10px'}, adv, ...(isWeb() ? [rulesFs, advFs] : []))));
|
||||
}
|
||||
@@ -910,10 +984,19 @@ views.sites = async () => {
|
||||
h('p', {class:'dim'}, 'A site is a private network that holds your services. Every edge also has a built-in "this machine" site for services on the same box as Blacktip.'), field('Name', name),
|
||||
h('div', {class:'actions form-actions'}, h('button', {class:'ghost', onclick:() => close()}, 'Cancel'), h('button', {class:'btn', onclick:() => { close(); add(); }}, 'Add site')))); }];
|
||||
const latest = await latestVersions();
|
||||
const tags = allTags(st.sites);
|
||||
if (TAGSEL.sites && !tags.some(t => t.toLowerCase() === TAGSEL.sites.toLowerCase())) TAGSEL.sites = '';
|
||||
const editSite = s => { // rename a site and set its tags
|
||||
const nm = h('input', {value:s.name}), tg = tagInput(s.tags, tags);
|
||||
const close = modal('Edit ' + s.name, h('div', {class:'stack'}, field('Name', nm), tg.el,
|
||||
h('div', {class:'actions form-actions'}, h('button', {class:'ghost', onclick:() => close()}, 'Cancel'),
|
||||
h('button', {class:'btn', onclick: act(async () => { await api('PUT', O('/sites/' + s.id), {name:nm.value, tags:tg.get()}); close(); toast('Saved ' + nm.value.trim()); refresh(); })}, 'Save changes'))));
|
||||
};
|
||||
return h('div', {},
|
||||
tagBar('sites', tags),
|
||||
card('', h('table', {}, h('thead', {}, h('tr', {}, ['Status', 'Name', 'Kind', 'Tunnels to', 'Version', 'Containers', ''].map(t => h('th', {}, t)))),
|
||||
h('tbody', {}, st.sites.map(s => h('tr', {},
|
||||
h('td', {}, st.online[s.id] ? badge('Online', 'success') : badge('Offline', 'danger')), h('td', {}, s.name),
|
||||
h('tbody', {}, st.sites.filter(s => hasTag(s, TAGSEL.sites)).map(s => h('tr', {},
|
||||
h('td', {}, st.online[s.id] ? badge('Online', 'success') : badge('Offline', 'danger')), h('td', {}, s.name, tagList(s)),
|
||||
h('td', {}, s.local ? 'This machine' : 'Bamboo agent'),
|
||||
h('td', {}, s.local ? 'Direct' : ((s.connected || []).map(id => (byId(st.edges, id) || {name:id}).name).join(', ') || 'nothing connected')),
|
||||
s.local ? h('td', {class:'dim'}, 'Same as its edge') : versionCell(s, latest.bamboo, !!s.updateId, O(`/sites/${s.id}/update`), O(`/sites/${s.id}/update/log`)),
|
||||
@@ -922,6 +1005,7 @@ views.sites = async () => {
|
||||
modal('Containers on ' + s.name, l.length ? h('table', {}, h('thead', {}, h('tr', {}, ['Name', 'Image', 'Ports'].map(t => h('th', {}, t)))), h('tbody', {}, l.map(c => h('tr', {}, h('td', {}, c.name), h('td', {class:'dim'}, c.image), h('td', {class:'mono small'}, c.ports.map(p => (p.public ? p.public + '→' : '') + p.private + '/' + p.type).join(', ')))))) :
|
||||
h('p', {class:'dim'}, 'None reported. Start the agent with --docker (or BLACKTIP_DOCKER=1 / BAMBOO_DOCKER=1) on a machine where Docker is running.')); })}, 'View')),
|
||||
h('td', {}, s.local ? null : h('div', {class:'actions'},
|
||||
h('button', {class:'ghost', onclick:() => editSite(s)}, 'Edit'),
|
||||
h('button', {class:'ghost', onclick:() => showCmd(s)}, 'Show command'),
|
||||
h('button', {class:'ghost danger', onclick: ask({title:`Delete ${s.name}?`, body:'Every port that uses only this site is deleted with it, and its agent loses its connection. This cannot be undone.', confirm:'Delete site'}, async () => { await api('DELETE', O('/sites/' + s.id)); refresh(); })}, 'Delete')))))))));
|
||||
};
|
||||
@@ -1056,26 +1140,32 @@ const FORMATS = [['json', 'JSON'], ['slack', 'Slack'], ['discord', 'Discord'], [
|
||||
function channelForm(base, existing, trusted) {
|
||||
const c = existing || {kind:'email', format:'json', enabled:true};
|
||||
const name = h('input', {value:c.name || '', placeholder:'e.g. On-call'});
|
||||
const kind = h('select', {}, [['email', 'Email'], ['webhook', 'Webhook']].map(([v, t]) => h('option', {value:v, selected:v === c.kind}, t)));
|
||||
const kind = h('select', {}, [['email', 'Email'], ['sandbar', 'Sandbar'], ['webhook', 'Webhook']].map(([v, t]) => h('option', {value:v, selected:v === c.kind}, t)));
|
||||
const target = h('input', {value:c.kind === 'email' ? c.target || '' : '', placeholder:c.kind === 'webhook' && existing ? 'unchanged' : ''});
|
||||
const token = h('input', {type:'password', autocomplete:'off', placeholder:existing && c.kind === 'sandbar' ? 'unchanged' : 'sb_…'});
|
||||
const policy = h('input', {value:c.policy || '', placeholder:'Optional'});
|
||||
const sandbarBox = h('div', {class:'grid'}, field('API key', token, 'Create one on Sandbar\'s Account page.'), field('Escalation policy ID', policy, 'Who gets paged. Leave blank to send alerts without paging anyone.'));
|
||||
const fmt = h('select', {}, FORMATS.map(([v, t]) => h('option', {value:v, selected:v === (c.format || 'json')}, t)));
|
||||
const priv = chk('This address is on a private network (like a home server)', c.allowPrivate);
|
||||
const en = chk('Enabled', c.enabled !== false);
|
||||
const fmtBox = field('Webhook format', fmt, 'Slack and Discord incoming-webhook URLs work as they are. ntfy sends a push notification.');
|
||||
const targetBox = field('Where', target);
|
||||
const redraw = () => {
|
||||
const w = kind.value === 'webhook';
|
||||
const w = kind.value === 'webhook', sb = kind.value === 'sandbar';
|
||||
fmtBox.style.display = w ? '' : 'none';
|
||||
priv.style.display = w && trusted ? '' : 'none';
|
||||
target.placeholder = w ? (existing && c.kind === 'webhook' ? 'unchanged (it may contain a secret token)' : 'https://hooks.example.com/…') : 'you@example.com, oncall@example.com';
|
||||
sandbarBox.style.display = sb ? '' : 'none';
|
||||
priv.style.display = (w || sb) && trusted ? '' : 'none';
|
||||
targetBox.firstChild.textContent =sb ? 'Sandbar address' : 'Where';
|
||||
target.placeholder = sb ? (existing && c.kind === 'sandbar' ? 'unchanged' : 'https://sandbar.example.com') :
|
||||
w ? (existing && c.kind === 'webhook' ? 'unchanged (it may contain a secret token)' : 'https://hooks.example.com/…') : 'you@example.com, oncall@example.com';
|
||||
};
|
||||
kind.onchange = redraw; redraw();
|
||||
const save = act(async () => {
|
||||
await api(existing ? 'PUT' : 'POST', base + '/channels' + (existing ? '/' + existing.id : ''), {name:name.value, kind:kind.value, target:target.value, format:kind.value === 'webhook' ? fmt.value : '', allowPrivate:trusted && priv.box.checked, enabled:en.box.checked});
|
||||
await api(existing ? 'PUT' : 'POST', base + '/channels' + (existing ? '/' + existing.id : ''), {name:name.value, kind:kind.value, target:target.value, format:kind.value === 'webhook' ? fmt.value : '', token:token.value, policy:policy.value, allowPrivate:trusted && priv.box.checked, enabled:en.box.checked});
|
||||
close(); toast(existing ? 'Alert channel saved' : 'Alert channel added'); refresh();
|
||||
});
|
||||
const close = modal(existing ? 'Edit channel' : 'Add an alert channel', h('div', {class:'stack'},
|
||||
h('div', {class:'grid'}, field('Name', name), field('Type', kind)), targetBox, fmtBox, priv, en,
|
||||
h('div', {class:'grid'}, field('Name', name), field('Type', kind)), targetBox, sandbarBox, fmtBox, priv, en,
|
||||
h('div', {class:'actions form-actions'}, h('button', {class:'ghost', onclick:() => close()}, 'Cancel'), h('button', {class:'btn', onclick:save}, 'Save'))), {sticky:true});
|
||||
}
|
||||
|
||||
@@ -1083,7 +1173,7 @@ async function alertsView(base, scoped, trusted) {
|
||||
const d = await api('GET', base);
|
||||
const channels = h('table', {}, h('thead', {}, h('tr', {}, ['Name', 'Type', 'Sends to', ''].map(x => h('th', {}, x)))),
|
||||
h('tbody', {}, d.channels.map(c => h('tr', {}, h('td', {}, c.name, c.enabled ? null : h('span', {class:'tag', style:'margin-left:6px'}, 'off')),
|
||||
h('td', {}, c.kind === 'email' ? 'Email' : 'Webhook (' + c.format + ')'), h('td', {class:'mono small'}, c.preview),
|
||||
h('td', {}, c.kind === 'email' ? 'Email' : c.kind === 'sandbar' ? 'Sandbar' : 'Webhook (' + c.format + ')'), h('td', {class:'mono small'}, c.preview),
|
||||
h('td', {}, h('div', {class:'actions'},
|
||||
h('button', {class:'ghost', onclick: act(async () => { await api('POST', base + '/channels/' + c.id + '/test'); toast('Test sent'); })}, 'Send test'),
|
||||
h('button', {class:'ghost', onclick:() => channelForm(base, c, trusted)}, 'Edit'),
|
||||
@@ -1091,11 +1181,11 @@ async function alertsView(base, scoped, trusted) {
|
||||
const debounce = h('input', {type:'number', min:1, max:120, value:d.settings.debounceMin}), repeat = h('input', {type:'number', min:0, max:168, value:d.settings.repeatHours});
|
||||
S.headerAction = ['Add channel', () => channelForm(base, null, trusted)];
|
||||
return h('div', {},
|
||||
!d.emailConfigured ? h('div', {class:'notice'}, 'Email alerts need an email server. Set one up under Server → Settings, or use a webhook.') : null,
|
||||
!d.emailConfigured ? h('div', {class:'notice'}, 'Email alerts need an email server. Set one up under Server → Settings, or use Sandbar or a webhook.') : null,
|
||||
card('Right now', d.active.length ? h('table', {}, h('tbody', {}, d.active.map(a => h('tr', {}, h('td', {}, a.fired ? badge('Firing', 'danger') : badge('Waiting', 'warning')), h('td', {}, a.message), h('td', {class:'dim small'}, 'since ' + ago(a.since)))))) :
|
||||
emptyState('check', 'Nothing is wrong', 'Problems that are firing or about to fire appear here.')),
|
||||
card('Where alerts go',
|
||||
d.channels.length ? channels : emptyState('alerts', 'No channels yet', 'Add an email address or a webhook to be told when something stops working.')),
|
||||
d.channels.length ? channels : emptyState('alerts', 'No channels yet', 'Add an email address, a Sandbar server or a webhook to be told when something stops working.')),
|
||||
scoped ? card('Timing', h('p', {class:'dim'}, 'A problem has to last this long before you are told, so a restart or a brief blip stays quiet. You get one message when it starts and one when it clears.'),
|
||||
h('div', {class:'grid'}, field('Wait this many minutes first', debounce), field('Remind me every (hours)', repeat, 'Use 0 to send no reminders'),
|
||||
h('button', {class:'ghost', onclick: act(async () => { await api('PUT', base + '/settings', {debounceMin:+debounce.value, repeatHours:+repeat.value}); toast('Alert timing saved'); })}, 'Save timing'))) : null,
|
||||
|
||||
Reference in new issue
Block a user