Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7481b2d01c | ||
|
|
35d4000bc4 |
No files matched your search
@@ -164,13 +164,16 @@ for each provider (e.g. `CF_DNS_API_TOKEN` for `cloudflare`); `.env` is the plac
|
|||||||
same offset (up to 1000 ports per entry; a busy port inside a range is named in the status).
|
same offset (up to 1000 ports per entry; a busy port inside a range is named in the status).
|
||||||
- TCP, UDP, "TCP + UDP" in one click, HTTP and HTTPS reverse proxy, and TLS passthrough by SNI.
|
- TCP, UDP, "TCP + UDP" in one click, HTTP and HTTPS reverse proxy, and TLS passthrough by SNI.
|
||||||
- **SSH into a site, and a terminal in the dashboard.** Start Bamboo with `-ssh` and list the allowed public
|
- **SSH into a site, and a terminal in the dashboard.** Start Bamboo with `-ssh` and list the allowed public
|
||||||
keys in its `authorized_keys`. The **SSH** button on the Sites page opens a TCP port on an edge to that
|
keys in its `authorized_keys`. The **SSH** button on the Sites page shows Hammerhead's own public key: add
|
||||||
server and shows the `ssh` command to use. The same dialog shows Hammerhead's own public key: add it to
|
it to `authorized_keys` and **Open terminal** gives an administrator a shell in the browser. No port has to
|
||||||
`authorized_keys` and **Open terminal** gives an administrator a shell in the browser. Hammerhead is the
|
be open anywhere: Bamboo reports its SSH server, and when a terminal is opened it calls back to Hammerhead
|
||||||
SSH client (it signs in through that SSH port), saves each site's host key the first time and refuses a
|
and joins that connection to the server. Hammerhead is the SSH client, saves each site's host key the
|
||||||
different one later (with a button to forget it), allows a terminal only from the dashboard's own origin,
|
first time and refuses a different one later (with a button to forget it), allows a terminal only from the
|
||||||
logs opening and closing in the audit log, and closes an idle one after 30 minutes. The shell runs as the
|
dashboard's own origin, logs opening and closing in the audit log, and closes an idle one after 30
|
||||||
user Bamboo runs as, so treat Hammerhead's key like a root login if Bamboo runs as root.
|
minutes. The shell runs as the user Bamboo runs as, so treat Hammerhead's key like a root login if Bamboo
|
||||||
|
runs as root. To connect from your own computer with an ordinary `ssh` client, the same dialog can open a
|
||||||
|
TCP port on an edge to that server and shows the command to use. An older Bamboo that does not report its
|
||||||
|
SSH server can still be reached through such a port.
|
||||||
- **PROXY protocol (v1/v2)** on tcp and tls ports: the target is told the real client address, so IP
|
- **PROXY protocol (v1/v2)** on tcp and tls ports: the target is told the real client address, so IP
|
||||||
bans, rate limits, spam checks and logs see the visitor instead of the tunnel. Your server must be
|
bans, rate limits, spam checks and logs see the visitor instead of the tunnel. Your server must be
|
||||||
set to expect it and to trust only the edge or site: Velocity/BungeeCord `haproxy-protocol`, Postfix
|
set to expect it and to trust only the edge or site: Velocity/BungeeCord `haproxy-protocol`, Postfix
|
||||||
|
|||||||
@@ -195,7 +195,7 @@ func (s *Server) siteConfig(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
have, _ := strconv.ParseInt(r.URL.Query().Get("v"), 10, 64)
|
have, _ := strconv.ParseInt(r.URL.Query().Get("v"), 10, 64)
|
||||||
if !s.wait(r, have) {
|
if !s.waitUntil(r, have, func() bool { return s.newTerminalsLocked(st.ID) }) {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
if st := s.siteByToken(r); st != nil {
|
if st := s.siteByToken(r); st != nil {
|
||||||
st.LastSeen = time.Now()
|
st.LastSeen = time.Now()
|
||||||
@@ -235,6 +235,7 @@ func (s *Server) siteConfig(w http.ResponseWriter, r *http.Request) {
|
|||||||
cfg.MailEgress = append(cfg.MailEgress, model.MailSpec{ID: x.ID, EdgeID: x.EdgeID, Listen: x.Listen, Username: x.Username, Password: x.Password})
|
cfg.MailEgress = append(cfg.MailEgress, model.MailSpec{ID: x.ID, EdgeID: x.EdgeID, Listen: x.Listen, Username: x.Username, Password: x.Password})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
cfg.Terminals = s.waitingTerminalsLocked(st.ID)
|
||||||
writeJSON(w, 200, cfg)
|
writeJSON(w, 200, cfg)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,6 +261,7 @@ func (s *Server) siteReport(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.recordUpdateProgress(st.ID, in.UpdateID, in.UpdateStage, in.UpdatePct, in.UpdateLog, oldVersion, st.Version)
|
s.recordUpdateProgress(st.ID, in.UpdateID, in.UpdateStage, in.UpdatePct, in.UpdateLog, oldVersion, st.Version)
|
||||||
s.containers[st.ID] = in.Containers
|
s.containers[st.ID] = in.Containers
|
||||||
s.discovered[st.ID] = cleanDiscovered(in.Discovered)
|
s.discovered[st.ID] = cleanDiscovered(in.Discovered)
|
||||||
|
st.SSH, st.SSHRelay = clipText(in.SSH, 100), in.SSHRelay
|
||||||
for _, x := range s.st.MailEgress {
|
for _, x := range s.st.MailEgress {
|
||||||
if x.SiteID != st.ID {
|
if x.SiteID != st.ID {
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
package e2e
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.veillax.com/Veillax/bamboo"
|
||||||
|
"git.veillax.com/Veillax/bamboo/sshd"
|
||||||
|
"git.veillax.com/Veillax/blacktip"
|
||||||
|
"git.veillax.com/Veillax/reef/model"
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
|
"golang.org/x/net/websocket"
|
||||||
|
)
|
||||||
|
|
||||||
|
// With Bamboo reporting its SSH server, the terminal works with no SSH port anywhere: Hammerhead
|
||||||
|
// asks the agent to call back and signs in over that connection.
|
||||||
|
func TestWebTerminalWithoutAnyOpenPort(t *testing.T) {
|
||||||
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
siteCfgHook = func(c *bamboo.Config) { c.SSH = ln.Addr().String() } // what bamboo -ssh tells the agent
|
||||||
|
w := setup(t, blacktip.TraefikConfig{})
|
||||||
|
siteCfgHook = nil
|
||||||
|
base := "/api/orgs/" + w.org
|
||||||
|
u, _ := url.Parse(w.ts.URL)
|
||||||
|
|
||||||
|
var key struct{ PublicKey string }
|
||||||
|
w.admin.must("GET", base+"/terminal", nil, &key)
|
||||||
|
dir := t.TempDir()
|
||||||
|
keys := filepath.Join(dir, "authorized_keys")
|
||||||
|
if err := os.WriteFile(keys, []byte(key.PublicKey+"\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
srv, err := sshd.New(sshd.Config{AuthorizedKeys: keys, HostKey: filepath.Join(dir, "ssh_host_ed25519_key")})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
t.Cleanup(cancel)
|
||||||
|
go srv.Serve(ctx, ln)
|
||||||
|
|
||||||
|
// the agent's next report tells Hammerhead where its SSH server is
|
||||||
|
var st struct{ Sites []model.Site }
|
||||||
|
for i := 0; i < 60; i++ {
|
||||||
|
w.admin.must("GET", base+"/state", nil, &st)
|
||||||
|
for _, s := range st.Sites {
|
||||||
|
if s.ID == w.site && s.SSH != "" {
|
||||||
|
i = 60
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if i < 60 {
|
||||||
|
time.Sleep(200 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var site model.Site
|
||||||
|
for _, s := range st.Sites {
|
||||||
|
if s.ID == w.site {
|
||||||
|
site = s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if site.SSH != ln.Addr().String() || !site.SSHRelay {
|
||||||
|
t.Fatalf("the site did not report its SSH server: %+v", site)
|
||||||
|
}
|
||||||
|
|
||||||
|
wsURL := "ws" + strings.TrimPrefix(w.ts.URL, "http") + base + "/sites/" + w.site + "/terminal?cols=100&rows=30"
|
||||||
|
dial := func() *websocket.Conn {
|
||||||
|
t.Helper()
|
||||||
|
cfg, err := websocket.NewConfig(wsURL, w.ts.URL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var ck []string
|
||||||
|
for _, c := range w.admin.c.Jar.Cookies(u) {
|
||||||
|
ck = append(ck, c.Name+"="+c.Value)
|
||||||
|
}
|
||||||
|
cfg.Header.Set("Cookie", strings.Join(ck, "; "))
|
||||||
|
ws, err := websocket.DialConfig(cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return ws
|
||||||
|
}
|
||||||
|
first := func() (m termMsg) {
|
||||||
|
t.Helper()
|
||||||
|
ws := dial()
|
||||||
|
defer ws.Close()
|
||||||
|
for {
|
||||||
|
ws.SetReadDeadline(time.Now().Add(20 * time.Second))
|
||||||
|
var f []byte
|
||||||
|
if err := websocket.Message.Receive(ws, &f); err != nil {
|
||||||
|
t.Fatalf("waiting for the terminal: %v", err)
|
||||||
|
}
|
||||||
|
if len(f) > 0 && f[0] == '{' {
|
||||||
|
return parseTermMsg(f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
m := first()
|
||||||
|
if sshd.Supported {
|
||||||
|
if m.Type != "ready" {
|
||||||
|
t.Fatalf("opening the terminal with no port: %+v", m)
|
||||||
|
}
|
||||||
|
ws := dial()
|
||||||
|
defer ws.Close()
|
||||||
|
// $((40+2)) is expanded by the shell, so "relay-42" can only come from the command running
|
||||||
|
if err := websocket.Message.Send(ws, []byte("echo relay-$((40+2))\n")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var out bytes.Buffer
|
||||||
|
for !strings.Contains(out.String(), "relay-42\r\n") {
|
||||||
|
ws.SetReadDeadline(time.Now().Add(15 * time.Second))
|
||||||
|
var f []byte
|
||||||
|
if err := websocket.Message.Receive(ws, &f); err != nil {
|
||||||
|
t.Fatalf("waiting for the command's output (got %q): %v", out.String(), err)
|
||||||
|
}
|
||||||
|
out.Write(f)
|
||||||
|
}
|
||||||
|
} else if m.Type != "error" || !strings.Contains(m.Message, "terminal") {
|
||||||
|
// this platform can't run shells, but asking for one proves the call-back and Hammerhead's key worked
|
||||||
|
t.Fatalf("with no port, on a platform without shells: %+v", m)
|
||||||
|
}
|
||||||
|
|
||||||
|
// it really did not need a port
|
||||||
|
var maps struct{ Mappings []model.Mapping }
|
||||||
|
w.admin.must("GET", base+"/state", nil, &maps)
|
||||||
|
if len(maps.Mappings) != 0 {
|
||||||
|
t.Errorf("the test opened a port after all: %+v", maps.Mappings)
|
||||||
|
}
|
||||||
|
|
||||||
|
// a machine with no shell (the published container image has none) says why, instead of the
|
||||||
|
// terminal just disconnecting: the shell's error output reaches the browser, then the exit code
|
||||||
|
if sshd.Supported {
|
||||||
|
t.Setenv("SHELL", "/nonexistent/shell")
|
||||||
|
ws := dial()
|
||||||
|
defer ws.Close()
|
||||||
|
var out bytes.Buffer
|
||||||
|
var exit termMsg
|
||||||
|
for exit.Type != "exit" {
|
||||||
|
ws.SetReadDeadline(time.Now().Add(15 * time.Second))
|
||||||
|
var f []byte
|
||||||
|
if err := websocket.Message.Receive(ws, &f); err != nil {
|
||||||
|
t.Fatalf("waiting for the shell to end (got %q): %v", out.String(), err)
|
||||||
|
}
|
||||||
|
if len(f) > 0 && f[0] == '{' {
|
||||||
|
if m := parseTermMsg(f); m.Type == "exit" {
|
||||||
|
exit = m
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out.Write(f)
|
||||||
|
}
|
||||||
|
if exit.Code != 127 || !strings.Contains(out.String(), "no shell to run") {
|
||||||
|
t.Errorf("a missing shell: exit %d, output %q", exit.Code, out.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// the SSH server still decides who gets in
|
||||||
|
other := newSSHSigner(t)
|
||||||
|
if err := os.WriteFile(keys, ssh.MarshalAuthorizedKey(other.PublicKey()), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if m := first(); m.Type != "error" || !strings.Contains(m.Message, "authorized_keys") {
|
||||||
|
t.Errorf("after removing Hammerhead's key: %+v", m)
|
||||||
|
}
|
||||||
|
|
||||||
|
// and a stopped SSH server is reported at once rather than after a timeout
|
||||||
|
cancel()
|
||||||
|
ln.Close()
|
||||||
|
time.Sleep(300 * time.Millisecond)
|
||||||
|
start := time.Now()
|
||||||
|
if m := first(); m.Type != "error" || !strings.Contains(m.Message, "SSH server") {
|
||||||
|
t.Errorf("with the SSH server stopped: %+v", m)
|
||||||
|
}
|
||||||
|
if time.Since(start) > 8*time.Second {
|
||||||
|
t.Errorf("a stopped SSH server took %s to be reported", time.Since(start))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseTermMsg(b []byte) termMsg {
|
||||||
|
var m termMsg
|
||||||
|
json.Unmarshal(b, &m)
|
||||||
|
return m
|
||||||
|
}
|
||||||
@@ -85,8 +85,8 @@ func TestWebTerminalThroughTheEdgeToBamboosServer(t *testing.T) {
|
|||||||
anon := newClient(t, w.ts.URL)
|
anon := newClient(t, w.ts.URL)
|
||||||
anon.expect(401, "GET", base+"/terminal", nil)
|
anon.expect(401, "GET", base+"/terminal", nil)
|
||||||
|
|
||||||
// asking before any SSH port exists says what to do
|
// this Bamboo has no SSH server of its own to call back to, so before a port exists the message says to start it
|
||||||
if m := first(); m.Type != "error" || !strings.Contains(m.Message, "SSH port") {
|
if m := first(); m.Type != "error" || !strings.Contains(m.Message, "-ssh") {
|
||||||
t.Errorf("before opening an SSH port: %+v", m)
|
t.Errorf("before opening an SSH port: %+v", m)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ module git.veillax.com/Veillax/hammerhead
|
|||||||
go 1.27.3
|
go 1.27.3
|
||||||
|
|
||||||
require (
|
require (
|
||||||
git.veillax.com/Veillax/bamboo v0.1.8
|
git.veillax.com/Veillax/bamboo v0.1.10
|
||||||
git.veillax.com/Veillax/blacktip v0.1.10
|
git.veillax.com/Veillax/blacktip v0.1.10
|
||||||
git.veillax.com/Veillax/reef v0.1.11
|
git.veillax.com/Veillax/reef v0.1.12
|
||||||
github.com/emersion/go-msgauth v0.7.0
|
github.com/emersion/go-msgauth v0.7.0
|
||||||
golang.org/x/crypto v0.58.0
|
golang.org/x/crypto v0.58.0
|
||||||
golang.org/x/net v0.61.0
|
golang.org/x/net v0.61.0
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
git.veillax.com/Veillax/bamboo v0.1.8 h1:mOdkGBzM8CjFVjR3784/WAYGt3U4bSdOaAJqFWMVh3I=
|
git.veillax.com/Veillax/bamboo v0.1.10 h1:R21+L54cS87VXmOS4eAsfRwp6vL+ORLqJUBaRHBIlJE=
|
||||||
git.veillax.com/Veillax/bamboo v0.1.8/go.mod h1:hUMWRFuDIiTNaqjpx2F7RxOg7ussrbVZAQIhzhPF780=
|
git.veillax.com/Veillax/bamboo v0.1.10/go.mod h1:Z3O7xoa52mFaADs7RxaBQl0a0K+5HlHPB1eOYQjM15k=
|
||||||
git.veillax.com/Veillax/blacktip v0.1.10 h1:2e1wUbMCp4z/DvQjAJCRmLyqu4PX+qV3mo5WBliqGiE=
|
git.veillax.com/Veillax/blacktip v0.1.10 h1:2e1wUbMCp4z/DvQjAJCRmLyqu4PX+qV3mo5WBliqGiE=
|
||||||
git.veillax.com/Veillax/blacktip v0.1.10/go.mod h1:tarAhdKU9hx5pdufe9wG3dRWYkWgPYZvawKM5DWgt6o=
|
git.veillax.com/Veillax/blacktip v0.1.10/go.mod h1:tarAhdKU9hx5pdufe9wG3dRWYkWgPYZvawKM5DWgt6o=
|
||||||
git.veillax.com/Veillax/reef v0.1.11 h1:kbjgxJpmpp1HRckbW1tqJUm5/ftgvSlmUHAhFXugOlY=
|
git.veillax.com/Veillax/reef v0.1.12 h1:Yizgcbh/TUz/dkO/4F+MOZkXlN7FU3/et/5uq1dH72s=
|
||||||
git.veillax.com/Veillax/reef v0.1.11/go.mod h1:ZBUd/BARO5kkpUNTP81TTY1aQXLHFR6bKDhCxckjHso=
|
git.veillax.com/Veillax/reef v0.1.12/go.mod h1:ZBUd/BARO5kkpUNTP81TTY1aQXLHFR6bKDhCxckjHso=
|
||||||
github.com/Microsoft/go-winio v0.6.4 h1:kJF2pamtYCWXf0xAT8sAkwKC0pMl2HYBrRPHAqyUc+g=
|
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/Microsoft/go-winio v0.6.4/go.mod h1:1xl5JXG4ze/m74GH6Dc4Mwxfc3VL5siRVQjIAlHH07U=
|
||||||
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||||
|
|||||||
@@ -175,5 +175,6 @@ func (s *Server) Handler() http.Handler {
|
|||||||
mux.HandleFunc("POST /edge/logs", s.edgeLogs)
|
mux.HandleFunc("POST /edge/logs", s.edgeLogs)
|
||||||
mux.HandleFunc("GET /site/config", s.siteConfig)
|
mux.HandleFunc("GET /site/config", s.siteConfig)
|
||||||
mux.HandleFunc("POST /site/report", s.siteReport)
|
mux.HandleFunc("POST /site/report", s.siteReport)
|
||||||
|
mux.HandleFunc("GET /site/terminal/{id}", s.terminalCallBack)
|
||||||
return mux
|
return mux
|
||||||
}
|
}
|
||||||
@@ -210,6 +210,7 @@ type Server struct {
|
|||||||
mailChecks map[string]model.MailCheck // mail egress ID -> the edge's deliverability report
|
mailChecks map[string]model.MailCheck // mail egress ID -> the edge's deliverability report
|
||||||
containers map[string][]model.Container // by site ID
|
containers map[string][]model.Container // by site ID
|
||||||
discovered map[string][]model.DiscoveredService // by site ID: suggested targets the agent found
|
discovered map[string][]model.DiscoveredService // by site ID: suggested targets the agent found
|
||||||
|
terms map[string]*pendingTerm // terminal sessions waiting for an agent to call back, by session ID
|
||||||
changed chan struct{}
|
changed chan struct{}
|
||||||
|
|
||||||
tickets map[string]ticket
|
tickets map[string]ticket
|
||||||
@@ -263,6 +264,7 @@ func New(dir string, opts Options) (*Server, error) {
|
|||||||
mailChecks: map[string]model.MailCheck{},
|
mailChecks: map[string]model.MailCheck{},
|
||||||
containers: map[string][]model.Container{},
|
containers: map[string][]model.Container{},
|
||||||
discovered: map[string][]model.DiscoveredService{},
|
discovered: map[string][]model.DiscoveredService{},
|
||||||
|
terms: map[string]*pendingTerm{},
|
||||||
changed: make(chan struct{}),
|
changed: make(chan struct{}),
|
||||||
tickets: map[string]ticket{},
|
tickets: map[string]ticket{},
|
||||||
oauth: map[string]oauthState{},
|
oauth: map[string]oauthState{},
|
||||||
@@ -412,9 +414,15 @@ func (s *Server) bump() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// wait blocks until the version moves past have, or 25s pass. It returns false on timeout.
|
// wait blocks until the version moves past have, or 25s pass. It returns false on timeout.
|
||||||
func (s *Server) wait(r *http.Request, have int64) bool {
|
func (s *Server) wait(r *http.Request, have int64) bool { return s.waitUntil(r, have, nil) }
|
||||||
|
|
||||||
|
// waitUntil is wait that also returns at once when ready (called with s.mu held) says there is
|
||||||
|
// something to deliver that does not move the version, such as a terminal waiting for an agent.
|
||||||
|
// Checking it under the same lock that takes the change channel is what keeps a request that
|
||||||
|
// arrives between two polls from being missed.
|
||||||
|
func (s *Server) waitUntil(r *http.Request, have int64, ready func() bool) bool {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
if s.st.Version > have {
|
if s.st.Version > have || (ready != nil && ready()) {
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|||||||
+172
-9
@@ -280,16 +280,11 @@ func (s *Server) runTerminal(ws *websocket.Conn, c *orgCtx, siteID string, cols,
|
|||||||
}
|
}
|
||||||
defer ws.Close()
|
defer ws.Close()
|
||||||
|
|
||||||
addr, err := s.terminalAddr(c, siteID)
|
|
||||||
if err != nil {
|
|
||||||
problem(err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
signer, err := s.terminalSignerLocked()
|
signer, err := s.terminalSignerLocked()
|
||||||
siteName := ""
|
siteName, viaAgent, agentCanRelay := "", false, false
|
||||||
if st := s.findSite(siteID); st != nil {
|
if st := s.findSite(siteID); st != nil {
|
||||||
siteName = st.Name
|
siteName, viaAgent, agentCanRelay = st.Name, st.SSH != "" && st.SSHRelay, st.SSHRelay
|
||||||
}
|
}
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -304,14 +299,39 @@ func (s *Server) runTerminal(ws *websocket.Conn, c *orgCtx, siteID string, cols,
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
client, err := ssh.Dial("tcp", addr, &ssh.ClientConfig{User: "hammerhead", Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)},
|
cfg := &ssh.ClientConfig{User: "hammerhead", Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)},
|
||||||
HostKeyCallback: s.trustHostKey(siteID, siteName), Timeout: termDialTimeout})
|
HostKeyCallback: s.trustHostKey(siteID, siteName), Timeout: termDialTimeout}
|
||||||
|
var client *ssh.Client
|
||||||
|
if viaAgent {
|
||||||
|
// Bamboo reports its SSH server, so the agent calls back and no port has to be open anywhere.
|
||||||
|
var release func()
|
||||||
|
client, release, err = s.dialViaAgent(siteID, cfg)
|
||||||
|
defer func() {
|
||||||
|
if release != nil {
|
||||||
|
release()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
} else {
|
||||||
|
addr, aerr := s.terminalAddr(c, siteID)
|
||||||
|
if aerr != nil {
|
||||||
|
switch {
|
||||||
|
case agentCanRelay: // a current Bamboo whose SSH server is not running
|
||||||
|
problem("That machine's SSH server isn't running. Start Bamboo there with -ssh, and add Hammerhead's public key to authorized_keys.")
|
||||||
|
default:
|
||||||
|
problem(aerr.Error() + " Updating Bamboo on that machine lets the terminal work without any port.")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
client, err = ssh.Dial("tcp", addr, cfg)
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
var hk *hostKeyChanged
|
var hk *hostKeyChanged
|
||||||
switch {
|
switch {
|
||||||
case errors.As(err, &hk):
|
case errors.As(err, &hk):
|
||||||
control(termControl{Type: "error", Message: hk.Error(), HostKeyChanged: true})
|
control(termControl{Type: "error", Message: hk.Error(), HostKeyChanged: true})
|
||||||
time.Sleep(200 * time.Millisecond)
|
time.Sleep(200 * time.Millisecond)
|
||||||
|
case errors.Is(err, errAgentSilent):
|
||||||
|
problem("The site didn't answer. Is Bamboo running there and connected to Hammerhead?")
|
||||||
case strings.Contains(err.Error(), "unable to authenticate"):
|
case strings.Contains(err.Error(), "unable to authenticate"):
|
||||||
problem("The site refused Hammerhead's key. Add Hammerhead's public key to authorized_keys on that machine.")
|
problem("The site refused Hammerhead's key. Add Hammerhead's public key to authorized_keys on that machine.")
|
||||||
default:
|
default:
|
||||||
@@ -340,6 +360,9 @@ func (s *Server) runTerminal(ws *websocket.Conn, c *orgCtx, siteID string, cols,
|
|||||||
problem(err.Error())
|
problem(err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// What the shell writes to its error output (including why it could not start) goes to the
|
||||||
|
// terminal like everything else, instead of being thrown away.
|
||||||
|
sess.Stderr = termWriter(func(p []byte) error { return send(termFrame{data: append([]byte(nil), p...)}) })
|
||||||
if err := sess.Shell(); err != nil {
|
if err := sess.Shell(); err != nil {
|
||||||
problem(fmt.Sprintf("Couldn't start a shell: %v", err))
|
problem(fmt.Sprintf("Couldn't start a shell: %v", err))
|
||||||
return
|
return
|
||||||
@@ -416,3 +439,143 @@ func (s *Server) runTerminal(ws *websocket.Conn, c *orgCtx, siteID string, cols,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- the call-back: a terminal without any open port ----------
|
||||||
|
|
||||||
|
// termCallBack is how long Hammerhead waits for an agent to answer a terminal request. The agent
|
||||||
|
// notices the request within a moment, because it is long-polling for its configuration.
|
||||||
|
const termCallBack = 15 * time.Second
|
||||||
|
|
||||||
|
var errAgentSilent = errors.New("the agent did not call back")
|
||||||
|
|
||||||
|
// pendingTerm is a terminal session waiting for the site's agent to call back.
|
||||||
|
type pendingTerm struct {
|
||||||
|
site string
|
||||||
|
created time.Time
|
||||||
|
claimed bool
|
||||||
|
offered bool // an agent has been sent this session in its configuration
|
||||||
|
conn chan net.Conn // the agent's call-back connection, once it arrives
|
||||||
|
done chan struct{} // closed when Hammerhead has finished with that connection
|
||||||
|
}
|
||||||
|
|
||||||
|
// wakeAgentsLocked ends every long poll now, so an agent hears about a change at once.
|
||||||
|
// The configuration version does not move, so nothing is saved or re-applied.
|
||||||
|
func (s *Server) wakeAgentsLocked() {
|
||||||
|
close(s.changed)
|
||||||
|
s.changed = make(chan struct{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitingTerminalsLocked lists the sessions an agent should answer, and forgets stale ones.
|
||||||
|
func (s *Server) waitingTerminalsLocked(siteID string) []string {
|
||||||
|
var ids []string
|
||||||
|
for id, p := range s.terms {
|
||||||
|
if time.Since(p.created) > 2*termCallBack {
|
||||||
|
delete(s.terms, id)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if p.site == siteID && !p.claimed {
|
||||||
|
ids = append(ids, id)
|
||||||
|
p.offered = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
// newTerminalsLocked reports whether the site has a session its agent has not been told about yet,
|
||||||
|
// which makes that agent's long poll return straight away instead of waiting for a change.
|
||||||
|
func (s *Server) newTerminalsLocked(siteID string) bool {
|
||||||
|
for _, p := range s.terms {
|
||||||
|
if p.site == siteID && !p.claimed && !p.offered {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// dialViaAgent asks the site's agent to call back and signs in to its SSH server over that
|
||||||
|
// connection. release must be called when the session is over.
|
||||||
|
func (s *Server) dialViaAgent(siteID string, cfg *ssh.ClientConfig) (*ssh.Client, func(), error) {
|
||||||
|
id := randHex(12)
|
||||||
|
p := &pendingTerm{site: siteID, created: time.Now(), conn: make(chan net.Conn, 1), done: make(chan struct{})}
|
||||||
|
s.mu.Lock()
|
||||||
|
s.terms[id] = p
|
||||||
|
s.wakeAgentsLocked()
|
||||||
|
s.mu.Unlock()
|
||||||
|
var once sync.Once
|
||||||
|
release := func() {
|
||||||
|
once.Do(func() {
|
||||||
|
s.mu.Lock()
|
||||||
|
delete(s.terms, id)
|
||||||
|
s.mu.Unlock()
|
||||||
|
close(p.done)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
var conn net.Conn
|
||||||
|
select {
|
||||||
|
case conn = <-p.conn:
|
||||||
|
case <-time.After(termCallBack):
|
||||||
|
release()
|
||||||
|
return nil, nil, errAgentSilent
|
||||||
|
}
|
||||||
|
conn.SetDeadline(time.Now().Add(termDialTimeout))
|
||||||
|
cc, chans, reqs, err := ssh.NewClientConn(conn, "site", cfg)
|
||||||
|
if err != nil {
|
||||||
|
conn.Close()
|
||||||
|
release()
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
conn.SetDeadline(time.Time{})
|
||||||
|
return ssh.NewClient(cc, chans, reqs), release, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// terminalCallBack is where an agent connects to answer a terminal request. The connection is
|
||||||
|
// handed to the session that asked for it and carries that session's SSH traffic.
|
||||||
|
func (s *Server) terminalCallBack(w http.ResponseWriter, r *http.Request) {
|
||||||
|
s.mu.Lock()
|
||||||
|
st := s.siteByToken(r)
|
||||||
|
var p *pendingTerm
|
||||||
|
if st != nil {
|
||||||
|
if q := s.terms[r.PathValue("id")]; q != nil && q.site == st.ID && !q.claimed {
|
||||||
|
q.claimed, p = true, q
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
switch {
|
||||||
|
case st == nil:
|
||||||
|
fail(w, 401, errors.New("bad site token"))
|
||||||
|
return
|
||||||
|
case p == nil:
|
||||||
|
fail(w, 404, errors.New("no such terminal"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
websocket.Server{
|
||||||
|
Handshake: func(*websocket.Config, *http.Request) error { return nil }, // authenticated by the site token
|
||||||
|
Handler: func(ws *websocket.Conn) {
|
||||||
|
ws.PayloadType = websocket.BinaryFrame
|
||||||
|
ws.MaxPayloadBytes = termMaxFrame
|
||||||
|
select {
|
||||||
|
case p.conn <- ws:
|
||||||
|
<-p.done // the websocket closes when this handler returns
|
||||||
|
case <-time.After(termCallBack):
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}.ServeHTTP(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// clipText cuts s to at most n bytes.
|
||||||
|
func clipText(s string, n int) string {
|
||||||
|
if len(s) > n {
|
||||||
|
return s[:n]
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// termWriter sends what is written to it to the browser as terminal output.
|
||||||
|
type termWriter func(p []byte) error
|
||||||
|
|
||||||
|
func (w termWriter) Write(p []byte) (int, error) {
|
||||||
|
if err := w(p); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return len(p), nil
|
||||||
|
}
|
||||||
+15
-5
@@ -1127,11 +1127,16 @@ async function sshAccess(s) {
|
|||||||
};
|
};
|
||||||
const cmds = h('div', {class:'stack'});
|
const cmds = h('div', {class:'stack'});
|
||||||
const connectBox = h('div', {}, h('h3', {style:'margin:0 0 var(--s2)'}, 'Connect with'), cmds, h('p', {class:'dim small'}, 'The user name is ignored: you get a shell as the user Bamboo runs as.'));
|
const connectBox = h('div', {}, h('h3', {style:'margin:0 0 var(--s2)'}, 'Connect with'), cmds, h('p', {class:'dim small'}, 'The user name is ignored: you get a shell as the user Bamboo runs as.'));
|
||||||
|
// Bamboo reports its SSH server; when it does, the terminal reaches it through the agent and needs no port
|
||||||
|
const viaAgent = !!(s.sshRelay && s.ssh);
|
||||||
const termBtn = h('button', {class:'btn', type:'button', onclick: act(async () => { close(); await openTerminal(s); })}, 'Open terminal');
|
const termBtn = h('button', {class:'btn', type:'button', onclick: act(async () => { close(); await openTerminal(s); })}, 'Open terminal');
|
||||||
|
const termNote = h('p', {class:'dim small', style:'margin:0'});
|
||||||
const draw = () => {
|
const draw = () => {
|
||||||
connectBox.style.display = ports.length ? '' : 'none';
|
connectBox.style.display = ports.length ? '' : 'none';
|
||||||
termBtn.disabled = !ports.length;
|
termBtn.disabled = !viaAgent && !ports.length;
|
||||||
termBtn.title = ports.length ? '' : 'Open an SSH port first';
|
termNote.textContent = viaAgent ? 'No port needs to be open: Bamboo on the site connects out to Hammerhead for the session.'
|
||||||
|
: ports.length ? 'This uses the SSH port below. Updating Bamboo or turning on its SSH server makes a port unnecessary.'
|
||||||
|
: 'Needs Bamboo\'s SSH server to be running (see above), or an SSH port below.';
|
||||||
cmds.replaceChildren(...ports.map(m => {
|
cmds.replaceChildren(...ports.map(m => {
|
||||||
const cmd = `ssh -p ${m.port} you@${edgeHost(byId(st.edges, m.edgeId), m)}`;
|
const cmd = `ssh -p ${m.port} you@${edgeHost(byId(st.edges, m.edgeId), m)}`;
|
||||||
return h('div', {}, h('div', {class:'small dim'}, m.name + (m.enabled ? '' : ' (paused)')), cmdBox(cmd),
|
return h('div', {}, h('div', {class:'small dim'}, m.name + (m.enabled ? '' : ' (paused)')), cmdBox(cmd),
|
||||||
@@ -1153,13 +1158,18 @@ async function sshAccess(s) {
|
|||||||
ports = [...ports, m]; draw(); toast('SSH port opened'); refresh();
|
ports = [...ports, m]; draw(); toast('SSH port opened'); refresh();
|
||||||
});
|
});
|
||||||
const close = modal('SSH into ' + s.name, h('div', {class:'stack'},
|
const close = modal('SSH into ' + s.name, h('div', {class:'stack'},
|
||||||
h('p', {class:'dim', style:'margin:0'}, 'Bamboo has a small SSH server, so you can open a shell on this machine from anywhere without installing sshd. Start Bamboo with -ssh (BAMBOO_SSH=1) and put the public keys that may sign in in authorized_keys in its data folder. Only keys are accepted, never passwords.'),
|
h('p', {class:'dim', style:'margin:0'}, 'Bamboo has a small SSH server, so you can open a shell on this machine without installing sshd. Start Bamboo with -ssh (BAMBOO_SSH=1) and put the public keys that may sign in in authorized_keys in its data folder. Only keys are accepted, never passwords.'),
|
||||||
connectBox,
|
h('div', {style:'display:flex;gap:var(--s2);align-items:center;flex-wrap:wrap'},
|
||||||
|
s.sshRelay ? (s.ssh ? badge('SSH server running', 'success') : badge('SSH server off', 'warning')) : badge('Bamboo needs an update', 'warning'),
|
||||||
|
h('span', {class:'small dim'}, s.sshRelay ? (s.ssh ? 'Listening on ' + s.ssh + ' on that machine.' : 'Start Bamboo there with -ssh.') : 'This Bamboo is too old to report its SSH server.')),
|
||||||
h('fieldset', {}, h('legend', {}, 'Terminal in Hammerhead'), h('div', {class:'stack'},
|
h('fieldset', {}, h('legend', {}, 'Terminal in Hammerhead'), h('div', {class:'stack'},
|
||||||
h('p', {class:'dim', style:'margin:0'}, 'Open a shell here in the dashboard. Hammerhead signs in with a key of its own, so add this line to authorized_keys on the site first (Bamboo picks it up without a restart).'),
|
h('p', {class:'dim', style:'margin:0'}, 'Open a shell here in the dashboard. Hammerhead signs in with a key of its own, so add this line to authorized_keys on the site first (Bamboo picks it up without a restart).'),
|
||||||
cmdBox(key.publicKey),
|
cmdBox(key.publicKey),
|
||||||
|
termNote,
|
||||||
h('div', {class:'actions'}, h('button', {class:'ghost', type:'button', onclick: act(async () => { await navigator.clipboard.writeText(key.publicKey); toast('Copied'); })}, 'Copy key'), termBtn))),
|
h('div', {class:'actions'}, h('button', {class:'ghost', type:'button', onclick: act(async () => { await navigator.clipboard.writeText(key.publicKey); toast('Copied'); })}, 'Copy key'), termBtn))),
|
||||||
h('fieldset', {}, h('legend', {}, 'Open an SSH port'), h('div', {class:'stack'},
|
connectBox,
|
||||||
|
h('fieldset', {}, h('legend', {}, 'Use an ordinary ssh client (optional)'), h('div', {class:'stack'},
|
||||||
|
h('p', {class:'dim small', style:'margin:0'}, 'Only needed to connect from your own computer. The terminal above does not use this port.'),
|
||||||
h('div', {class:'grid'}, field('Edge', edgeSel), field('Public IP', ipSel), field('Public port', port, 'Anyone can reach it, but only listed keys sign in')),
|
h('div', {class:'grid'}, field('Edge', edgeSel), field('Public IP', ipSel), field('Public port', port, 'Anyone can reach it, but only listed keys sign in')),
|
||||||
field('Bamboo\'s SSH server on the site', target, 'Leave as is unless you changed -ssh-listen'),
|
field('Bamboo\'s SSH server on the site', target, 'Leave as is unless you changed -ssh-listen'),
|
||||||
h('div', {class:'actions form-actions'}, h('button', {class:'btn', type:'button', onclick:create}, 'Open SSH port')))),
|
h('div', {class:'actions form-actions'}, h('button', {class:'btn', type:'button', onclick:create}, 'Open SSH port')))),
|
||||||
|
|||||||
Reference in new issue
Block a user