mirror of
https://github.com/holdmysocks/ps5-tailscale.git
synced 2026-10-06 09:00:19 +02:00
Forward UDP from the tailnet for Remote Play
The console's Remote Play service uses UDP 9295, 9296, 9297 and 9302 next to TCP 9295. tsnet has no catch-all for UDP, so the daemon now listens on the ports in the new udpPorts setting (those four by default) on its tailnet addresses and relays them to localhost. The UDP relay is shared with the local forwards.
This commit is contained in:
1 parent
0156e4e210
commit
01b6381444
13 files changed
+502
-148
No files matched your search
@@ -5,6 +5,9 @@ Puts a jailbroken PS5 on your [Tailscale](https://tailscale.com) network.
|
||||
- **Reach the console from anywhere.** FTP, the payload loader, web tools:
|
||||
whatever listens on the console is available at its tailnet address from
|
||||
your other Tailscale devices.
|
||||
- **Remote Play over Tailscale.** Play the PS5 from anywhere with a Remote
|
||||
Play client, at the console's tailnet address, with no port forwarding on
|
||||
your router.
|
||||
- **Stream games to the console over Tailscale.** A Moonlight client on the
|
||||
PS5 (such as ProsperoLight) can connect to a Sunshine host on your tailnet.
|
||||
- **Home screen icon** that opens its status page.
|
||||
@@ -82,9 +85,35 @@ want, for example FTP on 2121 or the payload loader on 9021.
|
||||
|
||||
- Every TCP port that something on the console listens on is forwarded.
|
||||
Ports with no listener refuse the connection.
|
||||
- UDP is not forwarded in this direction.
|
||||
- To keep a port off the tailnet, add it to `blockedPorts` in the
|
||||
[configuration](#configuration).
|
||||
- UDP ports have to be listed, in `udpPorts` in the
|
||||
[configuration](#configuration). The default list is Remote Play's.
|
||||
- To keep a TCP port off the tailnet, add it to `blockedPorts`.
|
||||
|
||||
### Remote Play
|
||||
|
||||
The console's own Remote Play service is reachable at its tailnet address, so
|
||||
a Remote Play client on any of your Tailscale devices can connect from
|
||||
anywhere. Any client that lets you enter the console's address works.
|
||||
|
||||
1. On the console, enable Remote Play (Settings > System > Remote Play).
|
||||
2. Register your Remote Play client with the console as usual. This is
|
||||
easiest at home on the same network; see the client's documentation.
|
||||
3. In the client, add the console manually with its **tailnet address**
|
||||
(shown on the status page).
|
||||
4. Connect.
|
||||
|
||||
Tested and working with Chiaki, and with Asobi on iOS and Android.
|
||||
|
||||
How it works: Remote Play uses TCP 9295 and UDP 9295, 9296, 9297 and 9302.
|
||||
The TCP port is forwarded like any other; the daemon listens on the UDP
|
||||
ports on the console's tailnet addresses and relays them to the service.
|
||||
|
||||
Notes:
|
||||
|
||||
- The video passes through the daemon, which runs at the lowest priority so
|
||||
that it never takes time from a game. Under a demanding game that may show
|
||||
as stutter.
|
||||
- Waking the console from rest mode does not work: nothing is running then.
|
||||
|
||||
### The status page
|
||||
|
||||
@@ -159,6 +188,7 @@ optional. Restart Tailscale (send the payload again) to apply edits.
|
||||
"forwards": [
|
||||
{"proto": "tcp", "listen": "127.0.0.1:8096", "target": "my-nas:8096"}
|
||||
],
|
||||
"udpPorts": [9295, 9296, 9297, 9302],
|
||||
"blockedPorts": [],
|
||||
"verbose": false
|
||||
}
|
||||
@@ -173,6 +203,7 @@ optional. Restart Tailscale (send the payload again) to apply edits.
|
||||
| `controlURL` | A coordination server other than Tailscale's. |
|
||||
| `sunshineHost` | The Sunshine host; set from the status page. |
|
||||
| `forwards` | Extra local forwards: `proto` is `tcp` or `udp`, `listen` a localhost address, `target` a tailnet host and port. |
|
||||
| `udpPorts` | The console's UDP ports reachable from the tailnet. Default `[9295, 9296, 9297, 9302]` (Remote Play). `[]` turns inbound UDP off. |
|
||||
| `blockedPorts` | Local TCP ports that are never exposed to the tailnet. |
|
||||
| `verbose` | Put Tailscale's own log in the main log as well. |
|
||||
|
||||
@@ -214,9 +245,10 @@ Two things are left to do by hand:
|
||||
## Security
|
||||
|
||||
- The status page and its controls are unauthenticated.
|
||||
- All listening TCP ports on the console become reachable from your tailnet,
|
||||
including the payload loader, which runs anything sent to it. Use Tailscale
|
||||
ACLs if other people share your tailnet.
|
||||
- All listening TCP ports on the console, and the UDP ports in `udpPorts`,
|
||||
become reachable from your tailnet. That includes the payload loader, which
|
||||
runs anything sent to it. Use Tailscale ACLs if other people share your
|
||||
tailnet.
|
||||
- The local forwards and the proxy listen on `127.0.0.1` only and are not
|
||||
exposed to the tailnet.
|
||||
|
||||
@@ -232,6 +264,9 @@ starting again after a reboot with the saved login, reaching the console over
|
||||
the tailnet, a ProsperoLight stream from a Sunshine host through the forward,
|
||||
the HTTP proxy, the home screen icon.
|
||||
|
||||
Remote Play through the tailnet address works with Chiaki and with Asobi on
|
||||
iOS and Android.
|
||||
|
||||
Not tested: rest mode, Uninstall on a console, other firmware versions,
|
||||
coordination servers other than Tailscale's.
|
||||
|
||||
|
||||
+1
-1
@@ -52,7 +52,7 @@ These folders are not in the repository.
|
||||
|
||||
```powershell
|
||||
# daemon payload: C launcher + Go program -> out\tailscale.elf
|
||||
.\tools\build-payload.ps1 -GoDir tsd -Name tailscale -Version 0.3.0
|
||||
.\tools\build-payload.ps1 -GoDir tsd -Name tailscale -Version 0.4.0
|
||||
|
||||
# installer -> out\tailscale-installer.elf (embeds out\tailscale.elf)
|
||||
.\tools\build-installer.ps1
|
||||
|
||||
@@ -27,6 +27,13 @@ specification.
|
||||
- Inbound: tsnet's fallback TCP handler pipes each tailnet connection to
|
||||
`127.0.0.1:<same port>`. It dials the local port before accepting, so
|
||||
ports with no listener are refused properly.
|
||||
- Inbound UDP (`inboundudp.go`): tsnet has no catch-all for UDP, so the ports
|
||||
in `udpPorts` are listened on with `tsnet.Server.ListenPacket` on the
|
||||
node's tailnet addresses and relayed to `127.0.0.1`. The default list is
|
||||
PS5 Remote Play's (9295, 9296, 9297, 9302); its service answers clients
|
||||
that arrive from loopback. Both UDP directions share `udprelay.go`: one
|
||||
connection to the target per client address, dropped after two idle
|
||||
minutes.
|
||||
- Outbound: local forwards (`localforward.go`) listen on localhost and relay
|
||||
TCP and UDP to a tailnet host through `tsnet.Server.Dial`. UDP is relayed
|
||||
per client address with an idle timeout. The Sunshine setting is a preset
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 60 KiB |
@@ -0,0 +1,39 @@
|
||||
/* Ask the console's Remote Play service for its discovery reply over
|
||||
* loopback, to confirm it answers clients that arrive from 127.0.0.1 (which
|
||||
* is how the daemon's UDP relay reaches it). Read-only. */
|
||||
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <arpa/inet.h>
|
||||
#include <netinet/in.h>
|
||||
#include <sys/socket.h>
|
||||
#include <sys/time.h>
|
||||
|
||||
int
|
||||
main(void) {
|
||||
static const char req[] = "SRCH * HTTP/1.1\ndevice-discovery-protocol-version:00030010\n";
|
||||
struct sockaddr_in addr = {0};
|
||||
struct timeval tv = {3, 0};
|
||||
char buf[1024];
|
||||
int fd = socket(AF_INET, SOCK_DGRAM, 0);
|
||||
ssize_t n;
|
||||
|
||||
setvbuf(stdout, 0, _IONBF, 0);
|
||||
addr.sin_family = AF_INET;
|
||||
addr.sin_port = htons(9302);
|
||||
addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
|
||||
setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
|
||||
sendto(fd, req, sizeof(req) - 1, 0, (struct sockaddr *)&addr, sizeof(addr));
|
||||
n = recv(fd, buf, sizeof(buf) - 1, 0);
|
||||
if (n <= 0) {
|
||||
printf("discovery via 127.0.0.1:9302: no reply\n");
|
||||
} else {
|
||||
buf[n] = 0;
|
||||
buf[strcspn(buf, "\r\n")] = 0;
|
||||
printf("discovery via 127.0.0.1:9302: %s\n", buf);
|
||||
}
|
||||
close(fd);
|
||||
return 0;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"io/fs"
|
||||
"os"
|
||||
"slices"
|
||||
)
|
||||
|
||||
// config is read from /data/tailscale/config.json. Every field is optional.
|
||||
@@ -28,6 +29,10 @@ type config struct {
|
||||
// Forwards are extra local forwards: a localhost port on the console
|
||||
// relayed to a host on the tailnet.
|
||||
Forwards []forwardRule `json:"forwards,omitempty"`
|
||||
// UDPPorts lists the console's UDP ports that are reachable from the
|
||||
// tailnet. The default is what PS5 Remote Play uses. An empty list turns
|
||||
// inbound UDP off.
|
||||
UDPPorts []uint16 `json:"udpPorts"`
|
||||
// BlockedPorts lists local TCP ports that are never exposed to the tailnet.
|
||||
BlockedPorts []uint16 `json:"blockedPorts,omitempty"`
|
||||
// Verbose turns on Tailscale's own (very chatty) logging.
|
||||
@@ -39,6 +44,7 @@ func defaultConfig() config {
|
||||
Hostname: "ps5",
|
||||
WebAddr: ":8090",
|
||||
HTTPProxyAddr: "127.0.0.1:8118",
|
||||
UDPPorts: slices.Clone(remotePlayUDPPorts),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/netip"
|
||||
"slices"
|
||||
"strconv"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Inbound UDP: tailnet devices reaching UDP services on the console.
|
||||
//
|
||||
// TCP needs no configuration, because tsnet asks about every incoming
|
||||
// connection and it can be passed to localhost on the spot. UDP has no such
|
||||
// hook, so the ports have to be listed and listened on, on the console's
|
||||
// tailnet addresses. The default list is what PS5 Remote Play uses.
|
||||
|
||||
// remotePlayUDPPorts are the UDP ports of the console's Remote Play service:
|
||||
// registration (9295), the stream (9296), the connection test (9297) and
|
||||
// discovery (9302). Its session port, TCP 9295, is covered by the TCP
|
||||
// forwarding.
|
||||
var remotePlayUDPPorts = []uint16{9295, 9296, 9297, 9302}
|
||||
|
||||
// udpExposer keeps a set of the console's UDP ports reachable on its tailnet
|
||||
// addresses.
|
||||
type udpExposer struct {
|
||||
// listen opens a UDP socket on a tailnet address
|
||||
// (tsnet.Server.ListenPacket).
|
||||
listen func(network, addr string) (net.PacketConn, error)
|
||||
logf func(format string, args ...any)
|
||||
// targetHost is where the console's services are reached.
|
||||
targetHost string
|
||||
|
||||
mu sync.Mutex
|
||||
addrs []netip.Addr
|
||||
ports []uint16
|
||||
stops []func()
|
||||
active []uint16
|
||||
}
|
||||
|
||||
// update makes ports reachable on addrs, replacing whatever was exposed
|
||||
// before. It does nothing if neither has changed.
|
||||
func (e *udpExposer) update(addrs []netip.Addr, ports []uint16) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
if slices.Equal(addrs, e.addrs) && slices.Equal(ports, e.ports) {
|
||||
return
|
||||
}
|
||||
for _, stop := range e.stops {
|
||||
stop()
|
||||
}
|
||||
e.stops, e.active = nil, nil
|
||||
e.addrs, e.ports = slices.Clone(addrs), slices.Clone(ports)
|
||||
|
||||
for _, port := range ports {
|
||||
target := net.JoinHostPort(e.targetHost, strconv.Itoa(int(port)))
|
||||
ok := false
|
||||
for _, addr := range addrs {
|
||||
network := "udp4"
|
||||
if addr.Is6() {
|
||||
network = "udp6"
|
||||
}
|
||||
listenAddr := netip.AddrPortFrom(addr, port).String()
|
||||
stop, err := startUDPRelay(udpRelayConfig{
|
||||
name: "udp " + listenAddr,
|
||||
listen: func() (net.PacketConn, error) { return e.listen(network, listenAddr) },
|
||||
dial: func(ctx context.Context) (net.Conn, error) {
|
||||
var d net.Dialer
|
||||
return d.DialContext(ctx, "udp", target)
|
||||
},
|
||||
logf: e.logf,
|
||||
})
|
||||
if err != nil {
|
||||
e.logf("udp %s: %v", listenAddr, err)
|
||||
continue
|
||||
}
|
||||
e.stops = append(e.stops, stop)
|
||||
ok = true
|
||||
}
|
||||
if ok {
|
||||
e.active = append(e.active, port)
|
||||
}
|
||||
}
|
||||
if len(e.active) > 0 {
|
||||
e.logf("UDP ports reachable from the tailnet: %v", e.active)
|
||||
}
|
||||
}
|
||||
|
||||
// activePorts returns the ports currently exposed.
|
||||
func (e *udpExposer) activePorts() []uint16 {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
return slices.Clone(e.active)
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/netip"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The exposer must relay datagrams arriving on a "tailnet" socket to the same
|
||||
// port on the target host and bring the replies back to the sender.
|
||||
func TestUDPExposer(t *testing.T) {
|
||||
// The console's service: echoes with a prefix.
|
||||
service, err := net.ListenPacket("udp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer service.Close()
|
||||
go func() {
|
||||
buf := make([]byte, 2048)
|
||||
for {
|
||||
n, from, err := service.ReadFrom(buf)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
service.WriteTo(append([]byte("ps5:"), buf[:n]...), from)
|
||||
}
|
||||
}()
|
||||
port := uint16(service.LocalAddr().(*net.UDPAddr).Port)
|
||||
|
||||
// Stand-in for tsnet: "listening on the tailnet address" is a loopback
|
||||
// socket on some other port, whose address the test then sends to.
|
||||
listening := make(chan net.Addr, 4)
|
||||
var asked []string
|
||||
e := &udpExposer{
|
||||
targetHost: "127.0.0.1",
|
||||
logf: t.Logf,
|
||||
listen: func(network, addr string) (net.PacketConn, error) {
|
||||
asked = append(asked, network+" "+addr)
|
||||
pc, err := net.ListenPacket("udp", "127.0.0.1:0")
|
||||
if err == nil {
|
||||
listening <- pc.LocalAddr()
|
||||
}
|
||||
return pc, err
|
||||
},
|
||||
}
|
||||
tailnetIP := netip.MustParseAddr("100.64.0.5")
|
||||
e.update([]netip.Addr{tailnetIP}, []uint16{port})
|
||||
defer e.update(nil, nil)
|
||||
|
||||
want := "udp4 100.64.0.5:" + strconv.Itoa(int(port))
|
||||
if len(asked) != 1 || asked[0] != want {
|
||||
t.Fatalf("listened on %v, want [%s]", asked, want)
|
||||
}
|
||||
if got := e.activePorts(); len(got) != 1 || got[0] != port {
|
||||
t.Fatalf("activePorts = %v", got)
|
||||
}
|
||||
|
||||
client, err := net.Dial("udp", (<-listening).String())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer client.Close()
|
||||
for _, msg := range []string{"SRCH", "again"} {
|
||||
client.Write([]byte(msg))
|
||||
buf := make([]byte, 100)
|
||||
client.SetReadDeadline(time.Now().Add(5 * time.Second))
|
||||
n, err := client.Read(buf)
|
||||
if err != nil || string(buf[:n]) != "ps5:"+msg {
|
||||
t.Fatalf("%q: got %q, %v", msg, buf[:n], err)
|
||||
}
|
||||
}
|
||||
|
||||
// Unchanged input must not reopen anything.
|
||||
e.update([]netip.Addr{tailnetIP}, []uint16{port})
|
||||
if len(asked) != 1 {
|
||||
t.Errorf("update with the same addresses and ports listened again: %v", asked)
|
||||
}
|
||||
// An empty port list turns it off.
|
||||
e.update([]netip.Addr{tailnetIP}, nil)
|
||||
if got := e.activePorts(); len(got) != 0 {
|
||||
t.Errorf("activePorts after clearing = %v", got)
|
||||
}
|
||||
}
|
||||
+6
-140
@@ -7,7 +7,6 @@ import (
|
||||
"net"
|
||||
"strconv"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -152,144 +151,11 @@ func (f *forwarder) serveTCP(c net.Conn, r forwardRule) {
|
||||
pipe(c, up)
|
||||
}
|
||||
|
||||
// UDP flows that have been silent this long are forgotten.
|
||||
const udpIdleTimeout = 2 * time.Minute
|
||||
|
||||
// udpFlow is the relay state for one local client address.
|
||||
type udpFlow struct {
|
||||
out chan []byte // datagrams from the client waiting to go upstream
|
||||
lastSeen atomic.Int64
|
||||
}
|
||||
|
||||
func (fl *udpFlow) touch() { fl.lastSeen.Store(time.Now().UnixNano()) }
|
||||
|
||||
func (fl *udpFlow) idle() bool {
|
||||
return time.Since(time.Unix(0, fl.lastSeen.Load())) > udpIdleTimeout
|
||||
}
|
||||
|
||||
func (f *forwarder) startUDP(r forwardRule) (stop func(), err error) {
|
||||
pc, err := net.ListenPacket("udp", r.Listen)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var (
|
||||
mu sync.Mutex
|
||||
current = pc
|
||||
closed bool
|
||||
flows = map[string]*udpFlow{}
|
||||
)
|
||||
socket := func() (net.PacketConn, bool) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return current, closed
|
||||
}
|
||||
|
||||
go func() {
|
||||
buf := make([]byte, 65535)
|
||||
for {
|
||||
sock, stopped := socket()
|
||||
if stopped {
|
||||
return
|
||||
}
|
||||
n, from, err := sock.ReadFrom(buf)
|
||||
if err != nil {
|
||||
if _, stopped := socket(); stopped {
|
||||
return
|
||||
}
|
||||
// Like TCP listeners, a UDP socket can die when the
|
||||
// PS5's network is reconfigured. Open a new one.
|
||||
f.logf("forward %v: %v; reopening", r, err)
|
||||
sock.Close()
|
||||
time.Sleep(time.Second)
|
||||
if reopened, err := net.ListenPacket("udp", r.Listen); err == nil {
|
||||
mu.Lock()
|
||||
if closed {
|
||||
reopened.Close()
|
||||
} else {
|
||||
current = reopened
|
||||
}
|
||||
mu.Unlock()
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
key := from.String()
|
||||
mu.Lock()
|
||||
fl := flows[key]
|
||||
if fl == nil {
|
||||
fl = &udpFlow{out: make(chan []byte, 256)}
|
||||
flows[key] = fl
|
||||
go f.serveUDPFlow(r, fl, from, socket, func() {
|
||||
mu.Lock()
|
||||
if flows[key] == fl {
|
||||
delete(flows, key)
|
||||
}
|
||||
mu.Unlock()
|
||||
})
|
||||
}
|
||||
mu.Unlock()
|
||||
|
||||
fl.touch()
|
||||
select {
|
||||
case fl.out <- append([]byte(nil), buf[:n]...):
|
||||
default: // upstream is not keeping up; UDP may drop
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return func() {
|
||||
mu.Lock()
|
||||
closed = true
|
||||
current.Close()
|
||||
mu.Unlock()
|
||||
}, nil
|
||||
}
|
||||
|
||||
// serveUDPFlow relays one client's datagrams to the target and the replies
|
||||
// back, until the flow goes quiet or the forward is stopped.
|
||||
func (f *forwarder) serveUDPFlow(r forwardRule, fl *udpFlow, client net.Addr, socket func() (net.PacketConn, bool), done func()) {
|
||||
defer done()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
up, err := f.dial(ctx, "udp", r.Target)
|
||||
cancel()
|
||||
if err != nil {
|
||||
f.logf("forward %v: %v", r, err)
|
||||
return
|
||||
}
|
||||
defer up.Close()
|
||||
|
||||
// Replies: target -> client.
|
||||
go func() {
|
||||
buf := make([]byte, 65535)
|
||||
for {
|
||||
up.SetReadDeadline(time.Now().Add(udpIdleTimeout))
|
||||
n, err := up.Read(buf)
|
||||
if err != nil {
|
||||
var ne net.Error
|
||||
if errors.As(err, &ne) && ne.Timeout() && !fl.idle() {
|
||||
continue
|
||||
}
|
||||
return
|
||||
}
|
||||
fl.touch()
|
||||
if pc, closed := socket(); !closed {
|
||||
pc.WriteTo(buf[:n], client)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
idle := time.NewTicker(udpIdleTimeout / 4)
|
||||
defer idle.Stop()
|
||||
for {
|
||||
select {
|
||||
case b := <-fl.out:
|
||||
if _, err := up.Write(b); err != nil {
|
||||
return
|
||||
}
|
||||
case <-idle.C:
|
||||
if _, closed := socket(); closed || fl.idle() {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
return startUDPRelay(udpRelayConfig{
|
||||
name: "forward " + r.String(),
|
||||
listen: func() (net.PacketConn, error) { return net.ListenPacket("udp", r.Listen) },
|
||||
dial: func(ctx context.Context) (net.Conn, error) { return f.dial(ctx, "udp", r.Target) },
|
||||
logf: f.logf,
|
||||
})
|
||||
}
|
||||
+36
@@ -11,9 +11,11 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
@@ -105,6 +107,7 @@ type daemon struct {
|
||||
srv *tsnet.Server
|
||||
lc *local.Client
|
||||
fwd *forwarder
|
||||
udp *udpExposer
|
||||
|
||||
mu sync.Mutex
|
||||
state string // ipn backend state, e.g. "NeedsLogin", "Running"
|
||||
@@ -174,8 +177,10 @@ func (d *daemon) run() error {
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
d.udp = &udpExposer{listen: d.srv.ListenPacket, logf: d.logf, targetHost: "127.0.0.1"}
|
||||
go d.watch(ctx)
|
||||
go d.recoverLogin(ctx)
|
||||
go d.exposeUDP(ctx)
|
||||
|
||||
sigc := make(chan os.Signal, 1)
|
||||
signal.Notify(sigc, syscall.SIGTERM, syscall.SIGINT)
|
||||
@@ -214,6 +219,37 @@ func (d *daemon) localForwardRules() []forwardRule {
|
||||
return append(sunshineRules(d.cfg.SunshineHost), d.cfg.Forwards...)
|
||||
}
|
||||
|
||||
// exposeUDP keeps the configured UDP ports listening on the console's tailnet
|
||||
// addresses. Those are only known once Tailscale is connected and can change,
|
||||
// so they are checked periodically.
|
||||
func (d *daemon) exposeUDP(ctx context.Context) {
|
||||
ticker := time.NewTicker(5 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
d.mu.Lock()
|
||||
running := d.state == "Running"
|
||||
ports := slices.Clone(d.cfg.UDPPorts)
|
||||
d.mu.Unlock()
|
||||
if running {
|
||||
var addrs []netip.Addr
|
||||
v4, v6 := d.srv.TailscaleIPs()
|
||||
for _, a := range []netip.Addr{v4, v6} {
|
||||
if a.IsValid() {
|
||||
addrs = append(addrs, a)
|
||||
}
|
||||
}
|
||||
if len(addrs) > 0 {
|
||||
d.udp.update(addrs, ports)
|
||||
}
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// tsnetLogf receives tsnet's messages for the user. While it waits for a
|
||||
// login it repeats the same line every few seconds, which would drown the
|
||||
// log, so a message is only logged again when it changes.
|
||||
|
||||
@@ -161,6 +161,7 @@ async function refresh() {
|
||||
if (s.ips.length) row(dl, 'Tailnet address', s.ips.join(', '), true);
|
||||
if (s.tailnet) row(dl, 'Tailnet', s.tailnet);
|
||||
if (s.proxy) row(dl, 'HTTP proxy', s.proxy, true);
|
||||
if (s.ips.length) row(dl, 'Reachable from tailnet', 'every open TCP port' + (s.udpPorts.length ? '; UDP ' + s.udpPorts.join(', ') + ' (Remote Play)' : ''));
|
||||
|
||||
const health = $('health');
|
||||
health.replaceChildren(...(s.health || []).map(h => { const li = document.createElement('li'); li.textContent = h; return li; }));
|
||||
|
||||
+178
@@ -0,0 +1,178 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A UDP relay sits between a listening socket and a target. Every client
|
||||
// address that sends to the socket gets its own connection to the target, so
|
||||
// the target's replies find their way back to the right client. It is used in
|
||||
// both directions: console apps to a tailnet host (local forwards) and
|
||||
// tailnet devices to a service on the console (inbound UDP).
|
||||
|
||||
// UDP flows that have been silent this long are forgotten.
|
||||
const udpIdleTimeout = 2 * time.Minute
|
||||
|
||||
type udpRelayConfig struct {
|
||||
name string
|
||||
// listen opens the socket clients send to. It is called again if the
|
||||
// socket fails, which on the PS5 happens when the network is
|
||||
// reconfigured.
|
||||
listen func() (net.PacketConn, error)
|
||||
// dial opens the connection to the target for one client.
|
||||
dial func(ctx context.Context) (net.Conn, error)
|
||||
logf func(format string, args ...any)
|
||||
}
|
||||
|
||||
// udpFlow is the relay state for one client address.
|
||||
type udpFlow struct {
|
||||
out chan []byte // datagrams from the client waiting to go to the target
|
||||
lastSeen atomic.Int64
|
||||
}
|
||||
|
||||
func (fl *udpFlow) touch() { fl.lastSeen.Store(time.Now().UnixNano()) }
|
||||
|
||||
func (fl *udpFlow) idle() bool {
|
||||
return time.Since(time.Unix(0, fl.lastSeen.Load())) > udpIdleTimeout
|
||||
}
|
||||
|
||||
type udpRelay struct {
|
||||
cfg udpRelayConfig
|
||||
|
||||
mu sync.Mutex
|
||||
sock net.PacketConn
|
||||
closed bool
|
||||
flows map[string]*udpFlow
|
||||
}
|
||||
|
||||
// startUDPRelay opens the listening socket and relays until stop is called.
|
||||
func startUDPRelay(cfg udpRelayConfig) (stop func(), err error) {
|
||||
sock, err := cfg.listen()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r := &udpRelay{cfg: cfg, sock: sock, flows: map[string]*udpFlow{}}
|
||||
go r.readLoop()
|
||||
return r.stop, nil
|
||||
}
|
||||
|
||||
func (r *udpRelay) stop() {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.closed = true
|
||||
r.sock.Close()
|
||||
}
|
||||
|
||||
// socket returns the current listening socket and whether the relay has been
|
||||
// stopped.
|
||||
func (r *udpRelay) socket() (net.PacketConn, bool) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return r.sock, r.closed
|
||||
}
|
||||
|
||||
func (r *udpRelay) readLoop() {
|
||||
buf := make([]byte, 65535)
|
||||
for {
|
||||
sock, stopped := r.socket()
|
||||
if stopped {
|
||||
return
|
||||
}
|
||||
n, from, err := sock.ReadFrom(buf)
|
||||
if err != nil {
|
||||
if _, stopped := r.socket(); stopped {
|
||||
return
|
||||
}
|
||||
r.cfg.logf("%s: %v; reopening", r.cfg.name, err)
|
||||
sock.Close()
|
||||
time.Sleep(time.Second)
|
||||
if reopened, err := r.cfg.listen(); err == nil {
|
||||
r.mu.Lock()
|
||||
if r.closed {
|
||||
reopened.Close()
|
||||
} else {
|
||||
r.sock = reopened
|
||||
}
|
||||
r.mu.Unlock()
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
key := from.String()
|
||||
r.mu.Lock()
|
||||
fl := r.flows[key]
|
||||
if fl == nil {
|
||||
fl = &udpFlow{out: make(chan []byte, 256)}
|
||||
r.flows[key] = fl
|
||||
go r.serveFlow(key, fl, from)
|
||||
}
|
||||
r.mu.Unlock()
|
||||
|
||||
fl.touch()
|
||||
select {
|
||||
case fl.out <- append([]byte(nil), buf[:n]...):
|
||||
default: // the target is not keeping up; UDP may drop
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// serveFlow relays one client's datagrams to the target and the replies
|
||||
// back, until the flow goes quiet or the relay is stopped.
|
||||
func (r *udpRelay) serveFlow(key string, fl *udpFlow, client net.Addr) {
|
||||
defer func() {
|
||||
r.mu.Lock()
|
||||
if r.flows[key] == fl {
|
||||
delete(r.flows, key)
|
||||
}
|
||||
r.mu.Unlock()
|
||||
}()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
up, err := r.cfg.dial(ctx)
|
||||
cancel()
|
||||
if err != nil {
|
||||
r.cfg.logf("%s: %v", r.cfg.name, err)
|
||||
return
|
||||
}
|
||||
defer up.Close()
|
||||
|
||||
// Replies: target -> client.
|
||||
go func() {
|
||||
buf := make([]byte, 65535)
|
||||
for {
|
||||
up.SetReadDeadline(time.Now().Add(udpIdleTimeout))
|
||||
n, err := up.Read(buf)
|
||||
if err != nil {
|
||||
var ne net.Error
|
||||
if errors.As(err, &ne) && ne.Timeout() && !fl.idle() {
|
||||
continue
|
||||
}
|
||||
return
|
||||
}
|
||||
fl.touch()
|
||||
if sock, stopped := r.socket(); !stopped {
|
||||
sock.WriteTo(buf[:n], client)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
idle := time.NewTicker(udpIdleTimeout / 4)
|
||||
defer idle.Stop()
|
||||
for {
|
||||
select {
|
||||
case b := <-fl.out:
|
||||
if _, err := up.Write(b); err != nil {
|
||||
return
|
||||
}
|
||||
case <-idle.C:
|
||||
if _, stopped := r.socket(); stopped || fl.idle() {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+7
-1
@@ -46,7 +46,9 @@ type statusInfo struct {
|
||||
// SunshineHost and Forwards describe the local forwards.
|
||||
SunshineHost string `json:"sunshineHost"`
|
||||
Forwards []string `json:"forwards"`
|
||||
Uptime int64 `json:"uptimeSeconds"`
|
||||
// UDPPorts are the console's UDP ports reachable from the tailnet.
|
||||
UDPPorts []uint16 `json:"udpPorts"`
|
||||
Uptime int64 `json:"uptimeSeconds"`
|
||||
}
|
||||
|
||||
func (d *daemon) serveWeb(ln net.Listener) {
|
||||
@@ -139,6 +141,10 @@ func (d *daemon) handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
info.SunshineHost = d.cfg.SunshineHost
|
||||
d.mu.Unlock()
|
||||
info.UDPPorts = []uint16{}
|
||||
if d.udp != nil {
|
||||
info.UDPPorts = append(info.UDPPorts, d.udp.activePorts()...)
|
||||
}
|
||||
info.Forwards = []string{}
|
||||
for _, r := range d.fwd.rules() {
|
||||
info.Forwards = append(info.Forwards, r.String())
|
||||
|
||||
Reference in new issue
Block a user