Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,8 @@ LAN access is off by default. To turn it on, set your own password first
- start it with `-lan`.

The console restarts on every network interface, on the same port, over
HTTPS with a self-signed certificate (browsers warn about it once). The
HTTPS with a self-signed certificate (browsers warn about it once). Typing
the address without `https://` works too; you're sent to the HTTPS page. The
address to use, such as `https://192.168.1.50:8787`, is shown under
Settings, in the sidebar's Network card and in the terminal. The choice is
remembered across restarts; untick the box or run with `-lan=false` to go
Expand Down
22 changes: 22 additions & 0 deletions internal/app/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,28 @@ func TestSelfSignedTLS(t *testing.T) {
if !strings.HasPrefix(url, "https://") {
t.Fatalf("url %s", url)
}

// Plain http:// on the same port is sent to https://, not refused.
c := &http.Client{Timeout: 5 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
resp, err := c.Get("http://" + strings.TrimPrefix(url, "https://") + "devices?x=1")
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusFound || resp.Header.Get("Location") != url+"devices?x=1" {
t.Fatalf("plain HTTP: %d, Location %q", resp.StatusCode, resp.Header.Get("Location"))
}

// HTTPS still works.
c.Transport = &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}
resp, err = c.Get(url)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != 200 {
t.Fatalf("HTTPS: %d", resp.StatusCode)
}
}

// lanConfig is testConfig on a free port, which -lan keeps; -addr can't be
Expand Down
20 changes: 20 additions & 0 deletions internal/app/console.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ type console struct {
lan bool
ln net.Listener
srv *http.Server
plain *http.Server // redirects http:// to https:// on a TLS port
tlsCfg *tls.Config
moving bool
closed bool
Expand Down Expand Up @@ -83,6 +84,16 @@ func (c *console) bind(lan bool, port int, fallback bool) error {
// serve serves the console on the bound listener in the background.
func (c *console) serve() {
ln, tlsCfg := c.ln, c.tlsCfg
if tlsCfg != nil {
var plainLn net.Listener
ln, plainLn = splitTLS(ln)
c.plain = &http.Server{
Handler: redirectHTTPS,
ReadHeaderTimeout: 10 * time.Second,
ErrorLog: slog.NewLogLogger(c.log.Handler(), slog.LevelWarn),
}
go c.plain.Serve(plainLn) // ends when the console's listener closes
}
srv := &http.Server{
Handler: c.handler(ln.Addr().(*net.TCPAddr).IP.IsLoopback(), tlsCfg),
ReadHeaderTimeout: 10 * time.Second,
Expand Down Expand Up @@ -176,6 +187,7 @@ func (c *console) move(st *store.Store, on bool) {
}
port := c.ln.Addr().(*net.TCPAddr).Port
c.srv.Close()
c.closePlain()
if err := c.bind(on, port, false); err != nil {
c.log.Error("couldn't move the console", "lan", on, "err", err)
fmt.Fprintf(c.stderr, "Couldn't move the console: %v\n", err)
Expand All @@ -198,6 +210,14 @@ func (c *console) stop(ctx context.Context) {
} else {
c.ln.Close()
}
c.closePlain()
}

func (c *console) closePlain() {
if c.plain != nil {
c.plain.Close()
c.plain = nil
}
}

// consoleInfo is what the dashboard and the terminal are told about the
Expand Down
113 changes: 113 additions & 0 deletions internal/app/split.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
package app

import (
"bufio"
"errors"
"net"
"net/http"
"sync"
"time"
)

// splitTLS lets an HTTPS port answer plain HTTP too. Phones try http://
// first when you type 192.168.1.50:8787, and Go's TLS server would answer
// that with "Client sent an HTTP request to an HTTPS server". Connections
// that start with a TLS handshake go to tlsLn, everything else to plainLn,
// where redirectHTTPS sends the browser to the https:// address.
func splitTLS(ln net.Listener) (tlsLn, plainLn net.Listener) {
s := &split{inner: ln, done: make(chan struct{})}
s.tls = &chanListener{s: s, conns: make(chan net.Conn)}
s.plain = &chanListener{s: s, conns: make(chan net.Conn)}
go s.run()
return s.tls, s.plain
}

type split struct {
inner net.Listener
tls, plain *chanListener
done chan struct{}
once sync.Once
}

func (s *split) close() error {
var err error
s.once.Do(func() {
close(s.done)
err = s.inner.Close()
})
return err
}

func (s *split) run() {
for {
c, err := s.inner.Accept()
if err != nil {
if errors.Is(err, net.ErrClosed) {
s.close()
return
}
time.Sleep(50 * time.Millisecond) // e.g. out of file descriptors
continue
}
go s.route(c)
}
}

// tlsHandshake is the first byte of every TLS connection.
const tlsHandshake = 0x16

func (s *split) route(c net.Conn) {
c.SetReadDeadline(time.Now().Add(10 * time.Second))
br := bufio.NewReader(c)
b, err := br.Peek(1)
if err != nil {
c.Close()
return
}
c.SetReadDeadline(time.Time{})
dest := s.plain
if b[0] == tlsHandshake {
dest = s.tls
}
select {
case dest.conns <- &peekedConn{Conn: c, r: br}:
case <-s.done:
c.Close()
}
}

type chanListener struct {
s *split
conns chan net.Conn
}

func (l *chanListener) Accept() (net.Conn, error) {
select {
case c := <-l.conns:
return c, nil
case <-l.s.done:
return nil, net.ErrClosed
}
}

func (l *chanListener) Close() error { return l.s.close() }
func (l *chanListener) Addr() net.Addr { return l.s.inner.Addr() }

// peekedConn reads the bytes route looked at before the rest.
type peekedConn struct {
net.Conn
r *bufio.Reader
}

func (c *peekedConn) Read(p []byte) (int, error) { return c.r.Read(p) }

// redirectHTTPS sends plain HTTP requests to the same address over HTTPS.
// It's temporary, so a browser doesn't remember it if the console goes
// back to plain HTTP on this machine.
var redirectHTTPS = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Host == "" {
http.Error(w, "this console only speaks HTTPS", http.StatusBadRequest)
return
}
http.Redirect(w, r, "https://"+r.Host+r.URL.RequestURI(), http.StatusFound)
})
Loading