diff --git a/README.md b/README.md index 6855dd9..30b66e4 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 32caa87..3615416 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -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 diff --git a/internal/app/console.go b/internal/app/console.go index c9579f0..9150469 100644 --- a/internal/app/console.go +++ b/internal/app/console.go @@ -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 @@ -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, @@ -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) @@ -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 diff --git a/internal/app/split.go b/internal/app/split.go new file mode 100644 index 0000000..cac4e2b --- /dev/null +++ b/internal/app/split.go @@ -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) +})