Files
holdmysocks--ps5-tailscale/tsd/proxy.go
T
holdmysocks ed67b35b52 Add a password, settings page and several streaming hosts
The status page can now be protected with a password and edits the
settings itself, so the config file no longer has to be changed by hand.

- Password for everything on the status page that shows or changes
  something. The console's own browser is exempt. Connections to the page
  from the tailnet are served directly so they are not taken for local.
- Settings form: name, ports, forwards, proxy, priority, update checks.
  Most take effect at once; the page says which need a restart.
- Game streaming: several Sunshine hosts, each with its own port.
- The HTTP proxy is off by default.
- The page says when a newer release exists.
- Uninstall removes the home screen icon.
- Priority setting for streams that stutter under a demanding game.
- After the console's network is reconfigured, Tailscale is asked to
  rebind. Seen working across a short stay in rest mode.
- Favicon, and the device list can be collapsed.
2026-10-02 14:32:30 -04:00

123 lines
3.6 KiB
Go

package main
import (
"context"
"errors"
"io"
"net"
"net/http"
"net/netip"
"time"
)
// serveProxy runs a plain HTTP proxy whose outbound connections go through
// Tailscale's dialer: tailnet addresses and MagicDNS names are reached over
// the tailnet, everything else goes out directly. Setting it as the proxy
// server in the PS5's network settings lets the console's own apps, such as
// the browser, open tailnet hosts.
func (d *daemon) serveProxy(ln net.Listener) {
d.logf("http proxy listening on %s", ln.Addr())
transport := &http.Transport{
DialContext: d.proxyDial,
ForceAttemptHTTP2: false,
MaxIdleConns: 32,
IdleConnTimeout: 60 * time.Second,
ResponseHeaderTimeout: 60 * time.Second,
}
srv := &http.Server{
ReadHeaderTimeout: 15 * time.Second,
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodConnect {
d.proxyConnect(w, r)
return
}
if !r.URL.IsAbs() {
http.Error(w, "this is a proxy; requests must use an absolute URL", http.StatusBadRequest)
return
}
r.RequestURI = ""
r.Header.Del("Proxy-Connection")
r.Header.Del("Proxy-Authorization")
resp, err := transport.RoundTrip(r)
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
defer resp.Body.Close()
for k, vs := range resp.Header {
for _, v := range vs {
w.Header().Add(k, v)
}
}
w.WriteHeader(resp.StatusCode)
io.Copy(w, resp.Body)
}),
}
// Serve returns when the listener is closed, which is how the proxy is
// turned off or moved from the settings.
if err := srv.Serve(ln); err != nil && err != http.ErrServerClosed && !errors.Is(err, net.ErrClosed) && !d.stopping() {
d.logf("http proxy stopped: %v", err)
}
}
// proxyDial connects on behalf of a proxy client. Tailscale's dialer handles
// tailnet addresses and names but waits until Tailscale is connected, so it
// is only used once that is the case, and never for the console's own or
// LAN addresses: those must keep working whatever state Tailscale is in.
func (d *daemon) proxyDial(ctx context.Context, network, addr string) (net.Conn, error) {
d.mu.Lock()
running := d.state == "Running"
d.mu.Unlock()
if running && !isLocalDestination(addr) {
return d.srv.Dial(ctx, network, addr)
}
var direct net.Dialer
return direct.DialContext(ctx, network, addr)
}
// isLocalDestination reports whether addr is a literal loopback, private or
// link-local address, or "localhost".
func isLocalDestination(addr string) bool {
host, _, err := net.SplitHostPort(addr)
if err != nil {
host = addr
}
if host == "localhost" {
return true
}
ip, err := netip.ParseAddr(host)
if err != nil {
return false
}
return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsUnspecified()
}
func (d *daemon) proxyConnect(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
upstream, err := d.proxyDial(ctx, "tcp", r.Host)
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
defer upstream.Close()
hj, ok := w.(http.Hijacker)
if !ok {
http.Error(w, "hijacking not supported", http.StatusInternalServerError)
return
}
client, buf, err := hj.Hijack()
if err != nil {
return
}
defer client.Close()
io.WriteString(client, "HTTP/1.1 200 Connection established\r\n\r\n")
// Anything the client sent right after its CONNECT request is already
// sitting in the server's read buffer.
if n := buf.Reader.Buffered(); n > 0 {
io.CopyN(upstream, buf.Reader, int64(n))
}
pipe(client, upstream)
}