mirror of
https://github.com/holdmysocks/ps5-tailscale.git
synced 2026-10-11 09:00:26 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
01b6381444 |
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