Quellcode durchsuchen

TURN update for Arozcast screen share over Internet (#259)

* Bump github.com/go-git/go-git/v5 from 5.16.5 to 5.19.1 in /src (#255)

Bumps [github.com/go-git/go-git/v5](https://github.com/go-git/go-git) from 5.16.5 to 5.19.1.
- [Release notes](https://github.com/go-git/go-git/releases)
- [Changelog](https://github.com/go-git/go-git/blob/main/HISTORY.md)
- [Commits](https://github.com/go-git/go-git/compare/v5.16.5...v5.19.1)

---
updated-dependencies:
- dependency-name: github.com/go-git/go-git/v5
  dependency-version: 5.19.1
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Add Reminders WebApp (macOS Reminders-style)

A self-contained ArozOS WebApp that mimics the macOS Reminders app.

Front-end (web/Reminders/index.html), single-page, no framework:
- Sidebar with the smart-list grid — Today, Scheduled, All, Flagged and a
  Completed card — plus custom "My Lists" with colour + SF-style icons and
  live incomplete counts, an Add List action and a light/dark toggle.
- Reminder rows with circle checkboxes, inline title editing, sub-tasks
  (Tab/Shift+Tab to indent), notes, due date/time chips (red when overdue),
  flags, priority (!/!!/!!!) and clickable URLs.
- Smart-view grouping: Today by time-of-day (All-Day/Morning/Afternoon/
  Tonight), Scheduled by date (with an Overdue group), All/Search by list.
- A detail editor popover (On a Day / At a Time, priority, list, URL, flag)
  and a list create/edit modal with colour and icon pickers.
- Native macOS look with full light/dark theming; sticky UI prefs via
  localStorage; responsive off-canvas sidebar on narrow screens.

Back-end AGI scripts (web/Reminders/backend/*.agi) persist a per-user
{ lists, reminders } store to user:/Document/Reminders/data.json via filelib,
following the Calendar app's pattern: init (load + seed default list),
saveReminder/deleteReminder, saveList/deleteList and a full saveData for
re-ordering and clearing completed items.

https://claude.ai/code/session_018JqHkXkuYjj5hz4kjiS1fQ

* Add built-in TURN relay so Arozcast screen share works over the Internet

Arozcast screen share is a direct WebRTC peer-to-peer connection that only
used public STUN servers, so it could not traverse NAT and failed over the
Internet. Add a built-in TURN relay (pion/turn, MIT, pure Go) that both peers
reach through the same ArozOS host, plus an /api/arozcast/iceservers endpoint
that hands the browser STUN + TURN with short-lived HMAC-signed credentials.
The relay is optional/configurable, and operators can override the ICE list
entirely with system/arozcast/iceservers.json to use an external TURN.

- mod/network/turn: pion/turn wrapper + ephemeral credential helpers (tested)
- cast.go: start the relay in ArozcastInit; serve /api/arozcast/iceservers
- main.go: close the relay on shutdown
- flags.go: -arozcast_turn, -arozcast_turn_port, -arozcast_turn_publicip
- screenshare.html / index.html: fetch ICE servers (STUN fallback)
- Arozcast README: endpoint + Internet/TURN operations docs

Media casting (Musicify/Movie/Photo) already worked over the Internet via the
existing relay and is unchanged.

https://claude.ai/code/session_01P68sFxtXuRXfzDnwtpFrnU

* Add TURN-over-TLS and a System Settings toggle for the Arozcast relay

Builds on the built-in Arozcast TURN relay with two operator-facing
enhancements.

TURN-over-TLS (TURNS):
- turn.NewServer now optionally starts a TURNS listener (TLSPort +
  TLSCertFile/TLSKeyFile) alongside the plain UDP/TCP relay, reusing the
  system TLS certificate. Restrictive firewalls that only permit outbound
  TLS (commonly 443) can then relay screen share, since the media rides
  inside a connection indistinguishable from HTTPS.
- Listener setup tracks every opened conn so a later failure cleans up; a
  TURNS failure is non-fatal (logged, plain relay keeps running).
- /api/arozcast/iceservers advertises turns:host:port?transport=tcp when
  the listener is up. New flags: -arozcast_turn_tls (default on, no-op
  without a usable cert) and -arozcast_turn_tls_port (default 5349; set to
  443 for maximum reach).

System Settings toggle & status:
- The relay is now runtime-managed (mutex-guarded start/stop with a
  remembered config) and its on/off state is persisted in the system
  database, overriding the -arozcast_turn flag default across restarts.
- New admin-only endpoints /system/arozcast/turn/{status,setEnabled} via
  the permission router, plus a "Screen Share Relay" page under
  System Settings -> Network & Connection (SystemAO/arozcast/turn.html)
  showing running state, port, TURNS and advertised host.

Tests cover the TURNS listener (self-signed cert handshake) and the
non-fatal missing-cert path. README documents both features.

* Drop the SNI-routing proxy caveat from the TURNS 443 docs

The TURN-over-TLS note suggested fronting port 443 with a TLS/SNI-routing
proxy to share it with the web server. That over-complicated the guidance;
replace it with a plain statement that 5349 is the default and 443 is only
viable when free on the relay's address.

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
Alan Yeung vor 4 Tagen
Ursprung
Commit
adc5937ae6

+ 330 - 0
src/cast.go

@@ -40,15 +40,120 @@ import (
 	"encoding/json"
 	"fmt"
 	"math/rand"
+	"net"
 	"net/http"
+	"os"
+	"path/filepath"
+	"strconv"
+	"strings"
 	"sync"
 	"time"
 
 	"github.com/gorilla/websocket"
+	"imuslab.com/arozos/mod/network/turn"
 	prout "imuslab.com/arozos/mod/prouter"
 	"imuslab.com/arozos/mod/utils"
 )
 
+// The built-in TURN relay lets the WebRTC screen-share feature traverse NAT and
+// work over the Internet. It can be toggled at runtime from System Settings, so
+// its lifecycle is mutex-guarded and its construction parameters are remembered
+// for restart. arozcastTurnServer is nil when the relay is stopped or failed to
+// start, in which case the ICE config falls back to STUN-only (LAN screen share
+// still works).
+const (
+	arozcastDBTable        = "arozcast" // system DB table for Arozcast preferences
+	arozcastTurnEnabledKey = "turn_on"  // persisted bool: relay on/off override
+)
+
+var (
+	arozcastTurnMu     sync.RWMutex // guards arozcastTurnServer
+	arozcastTurnServer *turn.Server // running relay, or nil when stopped/unavailable
+	arozcastTurnConfig turn.Config  // remembered config used to (re)start the relay
+)
+
+// arozcastGetTurnServer returns the running relay, or nil when it is stopped or
+// unavailable. Callers must treat nil as "fall back to STUN-only".
+func arozcastGetTurnServer() *turn.Server {
+	arozcastTurnMu.RLock()
+	defer arozcastTurnMu.RUnlock()
+	return arozcastTurnServer
+}
+
+// arozcastTurnRunning reports whether the relay is currently running.
+func arozcastTurnRunning() bool {
+	return arozcastGetTurnServer() != nil
+}
+
+// arozcastStartTurnRelay starts the relay from the remembered config. It is a
+// no-op (returns nil) when the relay is already running.
+func arozcastStartTurnRelay() error {
+	arozcastTurnMu.Lock()
+	defer arozcastTurnMu.Unlock()
+	if arozcastTurnServer != nil {
+		return nil
+	}
+	ts, err := turn.NewServer(arozcastTurnConfig)
+	if err != nil {
+		return err
+	}
+	arozcastTurnServer = ts
+	return nil
+}
+
+// arozcastStopTurnRelay stops the relay and releases its listeners. It is a
+// no-op when the relay is already stopped.
+func arozcastStopTurnRelay() {
+	arozcastTurnMu.Lock()
+	defer arozcastTurnMu.Unlock()
+	if arozcastTurnServer == nil {
+		return
+	}
+	arozcastTurnServer.Close()
+	arozcastTurnServer = nil
+}
+
+// arozcastTurnEnabledPref returns the persisted relay on/off preference,
+// falling back to the -arozcast_turn flag default when nothing is stored.
+func arozcastTurnEnabledPref() bool {
+	if sysdb != nil && sysdb.KeyExists(arozcastDBTable, arozcastTurnEnabledKey) {
+		var stored bool
+		if err := sysdb.Read(arozcastDBTable, arozcastTurnEnabledKey, &stored); err == nil {
+			return stored
+		}
+	}
+	return *arozcast_enable_turn
+}
+
+// arozcastSetTurnEnabledPref persists the relay on/off preference so it survives
+// a restart.
+func arozcastSetTurnEnabledPref(enabled bool) error {
+	return sysdb.Write(arozcastDBTable, arozcastTurnEnabledKey, enabled)
+}
+
+// arozcastICEOverrideFile lets an operator fully replace the ICE server list
+// returned to clients (e.g. to point at an external coturn/Cloudflare TURN).
+// When present and valid it takes precedence over the built-in relay.
+var arozcastICEOverrideFile = filepath.Join("system", "arozcast", "iceservers.json")
+
+// acICEServer mirrors the RTCIceServer dictionary consumed by the browser.
+type acICEServer struct {
+	URLs       []string `json:"urls"`
+	Username   string   `json:"username,omitempty"`
+	Credential string   `json:"credential,omitempty"`
+}
+
+type acICEConfig struct {
+	ICEServers []acICEServer `json:"iceServers"`
+}
+
+// acDefaultSTUNServers are the public STUN servers used when no other config is
+// available. STUN alone is enough on a LAN but not across most NATs.
+var acDefaultSTUNServers = []acICEServer{
+	{URLs: []string{"stun:stun.l.google.com:19302"}},
+	{URLs: []string{"stun:stun1.l.google.com:19302"}},
+}
+
 // Idle-timeout constants.  Adjust here if you need different behaviour.
 const (
 	acReceiverIdleTimeout = 30 * time.Second // close room when receiver goes silent
@@ -155,6 +260,44 @@ func acCloseRoom(mgr *acManager, code string) {
 func ArozcastInit() {
 	mgr := &acManager{rooms: make(map[string]*acRoom)}
 
+	// ── Built-in TURN relay ───────────────────────────────────────────────
+	// Screen share uses a direct WebRTC peer-to-peer connection. Over the
+	// Internet the peers are usually behind NAT, so a TURN relay is needed to
+	// forward the media. ArozOS runs that relay itself (both peers already
+	// reach this host for signalling). Failure is non-fatal: we just fall back
+	// to STUN-only, which keeps LAN screen share working.
+	//
+	// The construction parameters are remembered so the relay can be toggled
+	// from System Settings without a restart. Whether it starts now comes from
+	// the persisted preference, which defaults to the -arozcast_turn flag.
+	// When -arozcast_turn_tls is set and a TLS certificate is available it also
+	// serves TURN-over-TLS (TURNS) using the system certificate, so screen share
+	// can traverse firewalls that only allow outbound TLS. We only wire it up
+	// when the cert actually exists so HTTP-only deployments stay silent.
+	sysdb.NewTable(arozcastDBTable)
+	arozcastTurnConfig = turn.Config{
+		ListenPort: *arozcast_turn_port,
+		Realm:      "arozos",
+		PublicIP:   *arozcast_turn_publicip,
+	}
+	if *arozcast_turn_tls && utils.FileExists(*tls_cert) && utils.FileExists(*tls_key) {
+		arozcastTurnConfig.TLSPort = *arozcast_turn_tls_port
+		arozcastTurnConfig.TLSCertFile = *tls_cert
+		arozcastTurnConfig.TLSKeyFile = *tls_key
+	}
+
+	if arozcastTurnEnabledPref() {
+		if err := arozcastStartTurnRelay(); err != nil {
+			systemWideLogger.PrintAndLog("Arozcast", "Built-in TURN relay unavailable; screen share will be LAN-only", err)
+		} else {
+			msg := "Built-in TURN relay started on port " + strconv.Itoa(*arozcast_turn_port)
+			if ts := arozcastGetTurnServer(); ts != nil && ts.TLSEnabled() {
+				msg += " (TURNS on port " + strconv.Itoa(ts.TLSPort()) + ")"
+			}
+			systemWideLogger.PrintAndLog("Arozcast", msg, nil)
+		}
+	}
+
 	// ── Sweep goroutine ───────────────────────────────────────────────────
 	// Runs every acSweepInterval and closes rooms that match either idle
 	// condition.  Rooms to close are collected while holding a read-lock,
@@ -195,6 +338,69 @@ func ArozcastInit() {
 		},
 	})
 
+	// ── Admin: built-in TURN relay status & toggle (System Settings) ───────
+	// Lets an administrator see the relay's state and turn it on/off at runtime
+	// without restarting ArozOS. The on/off choice is persisted so it survives
+	// a restart (overriding the -arozcast_turn flag default).
+	adminRouter := prout.NewModuleRouter(prout.RouterOption{
+		ModuleName:  "System Settings",
+		AdminOnly:   true,
+		UserHandler: userHandler,
+		DeniedHandler: func(w http.ResponseWriter, r *http.Request) {
+			utils.SendErrorResponse(w, "Permission Denied")
+		},
+	})
+
+	registerSetting(settingModule{
+		Name:         "Screen Share Relay",
+		Desc:         "Built-in TURN relay for Arozcast screen share over the Internet",
+		IconPath:     "SystemAO/arozcast/img/small_icon.png",
+		Group:        "Network",
+		StartDir:     "SystemAO/arozcast/turn.html",
+		RequireAdmin: true,
+	})
+
+	// Report the relay's current configuration and running state.
+	adminRouter.HandleFunc("/system/arozcast/turn/status", func(w http.ResponseWriter, r *http.Request) {
+		js, err := json.Marshal(acTurnStatus())
+		if err != nil {
+			utils.SendErrorResponse(w, "Failed to build status")
+			return
+		}
+		utils.SendJSONResponse(w, string(js))
+	})
+
+	// Toggle the relay on/off at runtime and persist the preference.
+	adminRouter.HandleFunc("/system/arozcast/turn/setEnabled", func(w http.ResponseWriter, r *http.Request) {
+		enable, err := utils.GetBool(r, "enable")
+		if err != nil {
+			utils.SendErrorResponse(w, "Missing or invalid enable parameter")
+			return
+		}
+
+		if err := arozcastSetTurnEnabledPref(enable); err != nil {
+			utils.SendErrorResponse(w, "Failed to save preference")
+			return
+		}
+
+		if enable {
+			if err := arozcastStartTurnRelay(); err != nil {
+				systemWideLogger.PrintAndLog("Arozcast", "Failed to start built-in TURN relay from System Settings", err)
+				utils.SendErrorResponse(w, "Relay could not start: "+err.Error())
+				return
+			}
+		} else {
+			arozcastStopTurnRelay()
+		}
+
+		js, err := json.Marshal(acTurnStatus())
+		if err != nil {
+			utils.SendErrorResponse(w, "Failed to build status")
+			return
+		}
+		utils.SendJSONResponse(w, string(js))
+	})
+
 	// Create a new room; returns {"code":"XXXX"}.
 	router.HandleFunc("/api/arozcast/create", func(w http.ResponseWriter, r *http.Request) {
 		userinfo, err := userHandler.GetUserInfoFromRequest(w, r)
@@ -253,6 +459,23 @@ func ArozcastInit() {
 		}
 	})
 
+	// ICE servers for the WebRTC screen-share feature.
+	// Returns the STUN/TURN configuration the browser should use. Built-in TURN
+	// credentials are minted fresh per request and are short-lived.
+	router.HandleFunc("/api/arozcast/iceservers", func(w http.ResponseWriter, r *http.Request) {
+		identity := ""
+		if userinfo, err := userHandler.GetUserInfoFromRequest(w, r); err == nil {
+			identity = userinfo.Username
+		}
+		config := acBuildICEConfig(r, identity)
+		js, err := json.Marshal(config)
+		if err != nil {
+			utils.SendErrorResponse(w, "Failed to build ICE config")
+			return
+		}
+		utils.SendJSONResponse(w, string(js))
+	})
+
 	// HTTP publish: POST code=XXXX&msg=<json>
 	// Useful for AGI scripts that cannot hold a WebSocket connection.
 	router.HandleFunc("/api/arozcast/publish", func(w http.ResponseWriter, r *http.Request) {
@@ -348,3 +571,110 @@ func ArozcastInit() {
 		}
 	})
 }
+
+// acTurnStatusInfo is the JSON status of the built-in TURN relay shown on the
+// admin System Settings page.
+type acTurnStatusInfo struct {
+	Enabled       bool   `json:"enabled"`       // persisted on/off preference
+	Running       bool   `json:"running"`       // relay actually running right now
+	Port          int    `json:"port"`          // UDP/TCP relay port
+	TLS           bool   `json:"tls"`           // TURN-over-TLS (TURNS) listener active
+	TLSPort       int    `json:"tlsPort"`       // TURNS port
+	PublicIP      string `json:"publicIP"`      // configured advertise host ("" = auto-detect)
+	AdvertiseHost string `json:"advertiseHost"` // host actually advertised ("" = derived per request)
+}
+
+// acTurnStatus snapshots the built-in TURN relay's configuration and live state.
+func acTurnStatus() acTurnStatusInfo {
+	info := acTurnStatusInfo{
+		Enabled:  arozcastTurnEnabledPref(),
+		Port:     arozcastTurnConfig.ListenPort,
+		TLSPort:  arozcastTurnConfig.TLSPort,
+		PublicIP: strings.TrimSpace(arozcastTurnConfig.PublicIP),
+	}
+	if ts := arozcastGetTurnServer(); ts != nil {
+		info.Running = true
+		info.AdvertiseHost = ts.AdvertiseHost()
+		info.TLS = ts.TLSEnabled()
+		info.TLSPort = ts.TLSPort()
+	}
+	return info
+}
+
+// acBuildICEConfig assembles the ICE server list returned to the browser for
+// WebRTC screen share. Order of precedence:
+//  1. An operator override file (system/arozcast/iceservers.json), if valid.
+//  2. Public STUN servers plus the built-in TURN relay when it is running.
+//  3. Public STUN servers only (LAN screen share still works).
+func acBuildICEConfig(r *http.Request, identity string) acICEConfig {
+	if servers, ok := acLoadICEOverride(); ok {
+		return acICEConfig{ICEServers: servers}
+	}
+
+	servers := make([]acICEServer, len(acDefaultSTUNServers))
+	copy(servers, acDefaultSTUNServers)
+
+	if ts := arozcastGetTurnServer(); ts != nil {
+		host := ts.AdvertiseHost()
+		if host == "" {
+			host = acDeriveTURNHost(r)
+		}
+		if host != "" {
+			base := net.JoinHostPort(host, strconv.Itoa(ts.ListenPort()))
+			urls := []string{
+				"turn:" + base + "?transport=udp",
+				"turn:" + base + "?transport=tcp",
+			}
+			// TURN-over-TLS rides on TCP only; advertise it so clients behind
+			// TLS-only firewalls can still relay.
+			if ts.TLSEnabled() {
+				tlsBase := net.JoinHostPort(host, strconv.Itoa(ts.TLSPort()))
+				urls = append(urls, "turns:"+tlsBase+"?transport=tcp")
+			}
+			username, credential := ts.Credentials(identity)
+			servers = append(servers, acICEServer{
+				URLs:       urls,
+				Username:   username,
+				Credential: credential,
+			})
+		}
+	}
+
+	return acICEConfig{ICEServers: servers}
+}
+
+// acLoadICEOverride reads the optional operator override file. It returns
+// ok=false when the file is absent, unreadable, malformed, or empty.
+func acLoadICEOverride() ([]acICEServer, bool) {
+	data, err := os.ReadFile(arozcastICEOverrideFile)
+	if err != nil {
+		return nil, false
+	}
+	var parsed acICEConfig
+	if err := json.Unmarshal(data, &parsed); err != nil {
+		systemWideLogger.PrintAndLog("Arozcast", "Ignoring malformed "+arozcastICEOverrideFile, err)
+		return nil, false
+	}
+	if len(parsed.ICEServers) == 0 {
+		return nil, false
+	}
+	return parsed.ICEServers, true
+}
+
+// acDeriveTURNHost determines the host clients should dial for the TURN relay,
+// using the same host the client used to reach ArozOS (honouring a reverse
+// proxy's X-Forwarded-Host) so it stays reachable. The port is stripped — the
+// relay listens on its own port.
+func acDeriveTURNHost(r *http.Request) string {
+	host := r.Host
+	if xfh := r.Header.Get("X-Forwarded-Host"); xfh != "" {
+		if idx := strings.Index(xfh, ","); idx >= 0 {
+			xfh = xfh[:idx] // first entry is the original client-facing host
+		}
+		host = strings.TrimSpace(xfh)
+	}
+	if h, _, err := net.SplitHostPort(host); err == nil {
+		host = h
+	}
+	return host
+}

+ 8 - 1
src/flags.go

@@ -66,6 +66,13 @@ var force_mac = flag.String("force_mac", "", "Force MAC address to be used for d
 var disable_ip_resolve_services = flag.Bool("disable_ip_resolver", false, "Disable IP resolving if the system is running under reverse proxy environment")
 var enable_gzip = flag.Bool("gzip", true, "Enable gzip compress on file server")
 
+// Flags related to Arozcast (remote projection / screen share)
+var arozcast_enable_turn = flag.Bool("arozcast_turn", true, "Enable the built-in Arozcast TURN relay so WebRTC screen share works over the Internet / behind NAT")
+var arozcast_turn_port = flag.Int("arozcast_turn_port", 3478, "Listening port (UDP and TCP) for the built-in Arozcast TURN relay")
+var arozcast_turn_publicip = flag.String("arozcast_turn_publicip", "", "Public IP or hostname advertised by the Arozcast TURN relay. Leave empty to auto-detect the outbound interface address (set this when the host is behind NAT)")
+var arozcast_turn_tls = flag.Bool("arozcast_turn_tls", true, "Also serve TURN-over-TLS (TURNS) so screen share traverses restrictive firewalls that only allow outbound TLS. Uses the system TLS certificate (-cert/-key); a no-op when no certificate can be loaded")
+var arozcast_turn_tls_port = flag.Int("arozcast_turn_tls_port", 5349, "Listening TCP port for the Arozcast TURN-over-TLS (TURNS) relay. Set to 443 to share the standard HTTPS port for maximum firewall traversal")
+
 // Flags related to Security
 var use_tls = flag.Bool("tls", false, "Enable TLS on HTTP serving (HTTPS Mode)")
 var disable_http = flag.Bool("disable_http", false, "Disable HTTP server, require tls=true")
@@ -76,7 +83,7 @@ var session_key = flag.String("session_key", "", "Session key, must be 16, 24 or
 // Flags related to hardware or interfaces
 var allow_hardware_management = flag.Bool("enable_hwman", true, "Enable hardware management functions in system")
 var allow_power_management = flag.Bool("enable_pwman", true, "Enable power management of the host system")
-var wpa_supplicant_path = flag.String("wpa_supplicant_config", "/etc/wpa_supplicant/wpa_supplicant.conf", "Path for the wpa_supplicant config")
+var wpa_supplicant_path = flag.String("wpa_supplicant_config", "/etc/wpa_supplicant/wpa_supplicant.conf", "Path for the wpa_supplicant config") // arozos-lint-ignore: Linux-only wpa_supplicant default; overridable by flag
 var wan_interface_name = flag.String("wlan_interface_name", "wlan0", "The default wireless interface for connecting to an AP")
 var skip_mdadm_reload = flag.Bool("skip_mdadm_reload", false, "Skip mdadm reload config during startup, might result in werid RAID device ID in some Linux distro")
 

+ 11 - 4
src/go.mod

@@ -16,8 +16,8 @@ require (
 	github.com/fogleman/fauxgl v0.0.0-20250110135958-abf826acbbbd
 	github.com/gabriel-vasile/mimetype v1.4.10
 	github.com/glebarez/go-sqlite v1.22.0
-	github.com/go-git/go-billy/v5 v5.6.2
-	github.com/go-git/go-git/v5 v5.16.5
+	github.com/go-git/go-billy/v5 v5.9.0
+	github.com/go-git/go-git/v5 v5.19.1
 	github.com/go-ldap/ldap v3.0.3+incompatible
 	github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0
 	github.com/gorilla/sessions v1.4.0
@@ -31,6 +31,8 @@ require (
 	github.com/oliamb/cutter v0.2.2
 	github.com/oov/psd v0.0.0-20220121172623-5db5eafcecbb
 	github.com/pin/tftp/v3 v3.1.0
+	github.com/pion/logging v0.2.4
+	github.com/pion/turn/v4 v4.1.4
 	github.com/pkg/sftp v1.13.9
 	github.com/robertkrimen/otto v0.5.1
 	github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd
@@ -95,17 +97,22 @@ require (
 	github.com/miekg/dns v1.1.68 // indirect
 	github.com/nwaples/rardecode v1.1.3 // indirect
 	github.com/pierrec/lz4/v4 v4.1.26 // indirect
-	github.com/pjbgf/sha1cd v0.5.0 // indirect
+	github.com/pion/dtls/v3 v3.0.7 // indirect
+	github.com/pion/randutil v0.1.0 // indirect
+	github.com/pion/stun/v3 v3.0.1 // indirect
+	github.com/pion/transport/v3 v3.0.8 // indirect
+	github.com/pion/transport/v4 v4.0.1 // indirect
+	github.com/pjbgf/sha1cd v0.6.0 // indirect
 	github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
 	github.com/sergi/go-diff v1.4.0 // indirect
 	github.com/skeema/knownhosts v1.3.2 // indirect
 	github.com/stangelandcl/ppmd v0.1.0 // indirect
 	github.com/ulikunitz/xz v0.5.15 // indirect
+	github.com/wlynxg/anet v0.0.5 // indirect
 	github.com/xanzy/ssh-agent v0.3.3 // indirect
 	github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect
 	gitlab.com/NebulousLabs/fastrand v0.0.0-20181126182046-603482d69e40 // indirect
 	go4.org v0.0.0-20260112195520-a5071408f32f // indirect
-	golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 // indirect
 	golang.org/x/mod v0.35.0 // indirect
 	golang.org/x/net v0.53.0 // indirect
 	golang.org/x/sys v0.43.0 // indirect

+ 26 - 8
src/go.sum

@@ -101,12 +101,12 @@ github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
 github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
 github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
 github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
-github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM=
-github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU=
+github.com/go-git/go-billy/v5 v5.9.0 h1:jItGXszUDRtR/AlferWPTMN4j38BQ88XnXKbilmmBPA=
+github.com/go-git/go-billy/v5 v5.9.0/go.mod h1:jCnQMLj9eUgGU7+ludSTYoZL/GGmii14RxKFj7ROgHw=
 github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
 github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
-github.com/go-git/go-git/v5 v5.16.5 h1:mdkuqblwr57kVfXri5TTH+nMFLNUxIj9Z7F5ykFbw5s=
-github.com/go-git/go-git/v5 v5.16.5/go.mod h1:QOMLpNf1qxuSY4StA/ArOdfFR2TrKEjJiye2kel2m+M=
+github.com/go-git/go-git/v5 v5.19.1 h1:nX27AnaU43/K5bKktKwgBmR9lawoYVe1Ckg0rgzzN00=
+github.com/go-git/go-git/v5 v5.19.1/go.mod h1:Pb1v0c7/g8aGQJwx9Us09W85yGoyvSwuhEGMH7zjDKQ=
 github.com/go-kit/log v0.2.1 h1:MRVx0/zhvdseW+Gza6N9rVzU/IVzaeE1SFI4raAhmBU=
 github.com/go-kit/log v0.2.1/go.mod h1:NwTd00d/i8cPZ3xOwwiv2PO5MOcx78fFErGNcVmBjv0=
 github.com/go-ldap/ldap v3.0.3+incompatible h1:HTeSZO8hWMS1Rgb2Ziku6b8a7qRIZZMHjsvuZyatzwk=
@@ -199,8 +199,22 @@ github.com/pierrec/lz4/v4 v4.1.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY
 github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
 github.com/pin/tftp/v3 v3.1.0 h1:rQaxd4pGwcAJnpId8zC+O2NX3B2/NscjDZQaqEjuE7c=
 github.com/pin/tftp/v3 v3.1.0/go.mod h1:xwQaN4viYL019tM4i8iecm++5cGxSqen6AJEOEyEI0w=
-github.com/pjbgf/sha1cd v0.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0=
-github.com/pjbgf/sha1cd v0.5.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
+github.com/pion/dtls/v3 v3.0.7 h1:bItXtTYYhZwkPFk4t1n3Kkf5TDrfj6+4wG+CZR8uI9Q=
+github.com/pion/dtls/v3 v3.0.7/go.mod h1:uDlH5VPrgOQIw59irKYkMudSFprY9IEFCqz/eTz16f8=
+github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8=
+github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so=
+github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
+github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
+github.com/pion/stun/v3 v3.0.1 h1:jx1uUq6BdPihF0yF33Jj2mh+C9p0atY94IkdnW174kA=
+github.com/pion/stun/v3 v3.0.1/go.mod h1:RHnvlKFg+qHgoKIqtQWMOJF52wsImCAf/Jh5GjX+4Tw=
+github.com/pion/transport/v3 v3.0.8 h1:oI3myyYnTKUSTthu/NZZ8eu2I5sHbxbUNNFW62olaYc=
+github.com/pion/transport/v3 v3.0.8/go.mod h1:+c2eewC5WJQHiAA46fkMMzoYZSuGzA/7E2FPrOYHctQ=
+github.com/pion/transport/v4 v4.0.1 h1:sdROELU6BZ63Ab7FrOLn13M6YdJLY20wldXW2Cu2k8o=
+github.com/pion/transport/v4 v4.0.1/go.mod h1:nEuEA4AD5lPdcIegQDpVLgNoDGreqM/YqmEx3ovP4jM=
+github.com/pion/turn/v4 v4.1.4 h1:EU11yMXKIsK43FhcUnjLlrhE4nboHZq+TXBIi3QpcxQ=
+github.com/pion/turn/v4 v4.1.4/go.mod h1:ES1DXVFKnOhuDkqn9hn5VJlSWmZPaRJLyBXoOeO/BmQ=
+github.com/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU=
+github.com/pjbgf/sha1cd v0.6.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
 github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
 github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
 github.com/pkg/sftp v1.13.9 h1:4NGkvGudBL7GteO3m6qnaQ4pC0Kvf0onSVc9gR3EWBw=
@@ -248,6 +262,8 @@ github.com/ulikunitz/xz v0.5.8/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oW
 github.com/ulikunitz/xz v0.5.9/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
 github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY=
 github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
+github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
+github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
 github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
 github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
 github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 h1:nIPpBwaJSVYIxUFsDv3M8ofmx9yWTog9BfvIu0q41lo=
@@ -273,8 +289,8 @@ golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v
 golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
 golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
 golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
-golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 h1:zfMcR1Cs4KNuomFFgGefv5N0czO2XZpUbxGUy8i8ug0=
-golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0=
+golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
+golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
 golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
 golang.org/x/image v0.33.0 h1:LXRZRnv1+zGd5XBUVRFmYEphyyKJjQjCRiOuAP3sZfQ=
 golang.org/x/image v0.33.0/go.mod h1:DD3OsTYT9chzuzTQt+zMcOlBHgfoKQb1gry8p76Y1sc=
@@ -357,6 +373,8 @@ golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
 golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
 golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
 golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
+golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
+golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
 golang.org/x/tools v0.0.0-20191216052735-49a3e744a425/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=

+ 6 - 0
src/main.go

@@ -56,6 +56,12 @@ func executeShutdownSequence() {
 	//Shutdown network services
 	StopNetworkServices()
 
+	//Shutdown Arozcast TURN relay
+	if arozcastTurnRunning() {
+		systemWideLogger.PrintAndLog("System", "<!> Shutting down Arozcast TURN relay", nil)
+		arozcastStopTurnRelay()
+	}
+
 	//Shutdown FTP Server
 	if FTPManager != nil {
 		systemWideLogger.PrintAndLog("System", "<!> Shutting down FTP Server", nil)

+ 370 - 0
src/mod/network/turn/turn.go

@@ -0,0 +1,370 @@
+/*
+Package turn provides the built-in Arozcast TURN relay.
+
+Arozcast's screen-share feature establishes a direct WebRTC peer-to-peer
+connection between the sender (screenshare.html) and the receiver
+(index.html). On a LAN this works using host candidates, but across the
+Internet the peers are usually behind NAT (home routers, carrier-grade NAT
+on mobile) and a direct connection cannot be established with STUN alone —
+a TURN relay is required to forward the media.
+
+Because both peers already reach the same ArozOS host for signalling, the
+cleanest place to run that relay is ArozOS itself. This package wraps a
+pion/turn server (pure Go, MIT licensed, no system dependencies) and issues
+short-lived, HMAC-signed credentials in the coturn "TURN REST API" style so
+the relay is not an open proxy: only logged-in users that fetch
+/api/arozcast/iceservers receive a credential, and each credential expires.
+*/
+package turn
+
+import (
+	"crypto/hmac"
+	"crypto/rand"
+	"crypto/sha1" //nolint:gosec // SHA1-HMAC is the credential format coturn/RFC clients expect
+	"crypto/tls"
+	"encoding/base64"
+	"errors"
+	"fmt"
+	"io"
+	"net"
+	"strconv"
+	"strings"
+	"time"
+
+	"github.com/pion/logging"
+	pionturn "github.com/pion/turn/v4"
+	"imuslab.com/arozos/mod/info/logger"
+)
+
+// defaultCredentialTTL is the lifetime of an issued TURN credential when the
+// caller does not specify one. Screen-share sessions are usually short, but a
+// generous window avoids mid-session credential expiry.
+const defaultCredentialTTL = 12 * time.Hour
+
+// defaultRealm is the TURN realm advertised when none is configured.
+const defaultRealm = "arozos"
+
+// Config configures the built-in TURN relay.
+type Config struct {
+	// ListenPort is the UDP and TCP port the relay listens on (e.g. 3478).
+	ListenPort int
+
+	// Realm is the TURN realm. Defaults to "arozos" when empty.
+	Realm string
+
+	// PublicIP is the public IP or hostname advertised to peers as the relay
+	// address. When empty, the outbound interface address is auto-detected,
+	// which is correct for LAN use and for hosts with a routable IP. Hosts
+	// behind NAT should set this to their public IP (and forward ListenPort).
+	PublicIP string
+
+	// CredentialTTL is the lifetime of issued credentials. Zero uses the
+	// package default.
+	CredentialTTL time.Duration
+
+	// TLSPort, when greater than zero, additionally starts a TURN-over-TLS
+	// (TURNS) listener on that TCP port using the certificate at TLSCertFile /
+	// TLSKeyFile. TURNS lets screen share traverse restrictive firewalls that
+	// only permit outbound TLS (commonly port 443): the relayed media rides
+	// inside a TLS connection that is indistinguishable from ordinary HTTPS.
+	// When no certificate is configured, or it cannot be loaded, the TURNS
+	// listener is skipped and the plain relay still runs.
+	TLSPort     int
+	TLSCertFile string
+	TLSKeyFile  string
+}
+
+// Server wraps a pion TURN server together with the shared secret used to mint
+// and validate short-lived credentials.
+type Server struct {
+	server        *pionturn.Server
+	secret        []byte
+	realm         string
+	relayIP       net.IP
+	advertiseHost string // original PublicIP string (may be a hostname); empty = derive from request
+	listenPort    int
+	ttl           time.Duration
+	tlsEnabled    bool // true when the TURN-over-TLS (TURNS) listener is running
+	tlsPort       int  // port of the TURNS listener; valid only when tlsEnabled
+}
+
+// NewServer starts a TURN relay listening on config.ListenPort (UDP and TCP).
+// The returned Server must be Close()d on shutdown. It is safe for the caller
+// to treat a non-nil error as "relay unavailable" and fall back to STUN-only.
+func NewServer(config Config) (*Server, error) {
+	if config.ListenPort <= 0 || config.ListenPort > 65535 {
+		return nil, fmt.Errorf("invalid TURN listen port: %d", config.ListenPort)
+	}
+
+	realm := config.Realm
+	if realm == "" {
+		realm = defaultRealm
+	}
+
+	ttl := config.CredentialTTL
+	if ttl <= 0 {
+		ttl = defaultCredentialTTL
+	}
+
+	relayIP, err := resolveRelayIP(config.PublicIP)
+	if err != nil {
+		return nil, err
+	}
+
+	// Random per-process secret for signing ephemeral credentials.
+	secret := make([]byte, 32)
+	if _, err := rand.Read(secret); err != nil {
+		return nil, err
+	}
+
+	s := &Server{
+		secret:        secret,
+		realm:         realm,
+		relayIP:       relayIP,
+		advertiseHost: strings.TrimSpace(config.PublicIP),
+		listenPort:    config.ListenPort,
+		ttl:           ttl,
+	}
+
+	relayGenerator := &pionturn.RelayAddressGeneratorStatic{
+		RelayAddress: relayIP,
+		Address:      "0.0.0.0",
+	}
+
+	listenAddr := "0.0.0.0:" + strconv.Itoa(config.ListenPort)
+
+	// pion only takes ownership of the conns/listeners when NewServer succeeds,
+	// so track everything we open and release it if a later step fails.
+	var opened []io.Closer
+	closeOpened := func() {
+		for _, c := range opened {
+			_ = c.Close()
+		}
+	}
+
+	udpConn, err := net.ListenPacket("udp4", listenAddr)
+	if err != nil {
+		return nil, fmt.Errorf("turn udp listen on %s: %w", listenAddr, err)
+	}
+	opened = append(opened, udpConn)
+
+	tcpListener, err := net.Listen("tcp4", listenAddr)
+	if err != nil {
+		closeOpened()
+		return nil, fmt.Errorf("turn tcp listen on %s: %w", listenAddr, err)
+	}
+	opened = append(opened, tcpListener)
+
+	listenerConfigs := []pionturn.ListenerConfig{{
+		Listener:              tcpListener,
+		RelayAddressGenerator: relayGenerator,
+	}}
+
+	// Optional TURN-over-TLS (TURNS) listener. A failure here is non-fatal: log
+	// it and keep serving the plain relay rather than denying screen share to
+	// everyone over a misconfigured certificate or busy port.
+	if config.TLSPort > 0 {
+		tlsListener, err := newTLSListener(config)
+		if err != nil {
+			logger.PrintAndLog("Arozcast TURN", "TURN-over-TLS (TURNS) listener disabled", err)
+		} else {
+			opened = append(opened, tlsListener)
+			listenerConfigs = append(listenerConfigs, pionturn.ListenerConfig{
+				Listener:              tlsListener,
+				RelayAddressGenerator: relayGenerator,
+			})
+			s.tlsEnabled = true
+			s.tlsPort = config.TLSPort
+		}
+	}
+
+	server, err := pionturn.NewServer(pionturn.ServerConfig{
+		Realm:         realm,
+		AuthHandler:   s.authHandler,
+		LoggerFactory: pionLoggerFactory{},
+		PacketConnConfigs: []pionturn.PacketConnConfig{{
+			PacketConn:            udpConn,
+			RelayAddressGenerator: relayGenerator,
+		}},
+		ListenerConfigs: listenerConfigs,
+	})
+	if err != nil {
+		closeOpened()
+		return nil, err
+	}
+
+	s.server = server
+	return s, nil
+}
+
+// newTLSListener builds the TLS listener backing the TURN-over-TLS (TURNS)
+// endpoint from the certificate configured in config. The caller takes
+// ownership of the returned listener.
+func newTLSListener(config Config) (net.Listener, error) {
+	if config.TLSCertFile == "" || config.TLSKeyFile == "" {
+		return nil, errors.New("no TLS certificate configured")
+	}
+	cert, err := tls.LoadX509KeyPair(config.TLSCertFile, config.TLSKeyFile)
+	if err != nil {
+		return nil, fmt.Errorf("cannot load TLS certificate: %w", err)
+	}
+	tlsAddr := "0.0.0.0:" + strconv.Itoa(config.TLSPort)
+	listener, err := tls.Listen("tcp4", tlsAddr, &tls.Config{
+		Certificates: []tls.Certificate{cert},
+		MinVersion:   tls.VersionTLS12,
+	})
+	if err != nil {
+		return nil, fmt.Errorf("turns tcp listen on %s: %w", tlsAddr, err)
+	}
+	return listener, nil
+}
+
+// authHandler validates an incoming TURN credential. It is the pion AuthHandler
+// callback bound to this server's secret and realm.
+func (s *Server) authHandler(username, realm string, _ net.Addr) ([]byte, bool) {
+	return validateCredential(s.secret, realm, username, time.Now())
+}
+
+// Credentials mints a fresh ephemeral username/password pair for the given
+// identity (typically the logged-in username). The credential is valid for the
+// server's configured TTL.
+func (s *Server) Credentials(identity string) (username, password string) {
+	return buildCredential(s.secret, identity, time.Now().Add(s.ttl))
+}
+
+// AdvertiseHost returns the configured public host/IP that clients should dial
+// for the relay, or an empty string when it should be derived from the request
+// (i.e. the host the client used to reach ArozOS).
+func (s *Server) AdvertiseHost() string { return s.advertiseHost }
+
+// ListenPort returns the port the relay listens on.
+func (s *Server) ListenPort() int { return s.listenPort }
+
+// TLSEnabled reports whether the TURN-over-TLS (TURNS) listener is running.
+func (s *Server) TLSEnabled() bool { return s != nil && s.tlsEnabled }
+
+// TLSPort returns the port of the TURN-over-TLS (TURNS) listener, or 0 when
+// TLS is not enabled.
+func (s *Server) TLSPort() int { return s.tlsPort }
+
+// Realm returns the TURN realm.
+func (s *Server) Realm() string { return s.realm }
+
+// Close stops the relay and releases its listeners.
+func (s *Server) Close() error {
+	if s == nil || s.server == nil {
+		return nil
+	}
+	return s.server.Close()
+}
+
+// ── credential helpers (pure, unit-tested) ─────────────────────────────────
+
+// buildCredential returns a coturn-style TURN REST API credential pair:
+//
+//	username = "<unix-expiry>[:identity]"
+//	password = base64(HMAC-SHA1(secret, username))
+func buildCredential(secret []byte, identity string, expiry time.Time) (username, password string) {
+	username = strconv.FormatInt(expiry.Unix(), 10)
+	if identity != "" {
+		username += ":" + identity
+	}
+	return username, signCredential(secret, username)
+}
+
+// signCredential computes the base64 HMAC-SHA1 of username keyed by secret.
+func signCredential(secret []byte, username string) string {
+	mac := hmac.New(sha1.New, secret)
+	mac.Write([]byte(username))
+	return base64.StdEncoding.EncodeToString(mac.Sum(nil))
+}
+
+// validateCredential parses a username's embedded expiry and derives the auth
+// key pion expects (key = GenerateAuthKey(username, realm, HMAC(secret))).
+// ok is false only when the username is malformed or expired. A forged or
+// wrong-secret credential yields a key the client's password cannot match, so
+// pion's STUN MESSAGE-INTEGRITY check rejects it.
+func validateCredential(secret []byte, realm, username string, now time.Time) (key []byte, ok bool) {
+	expiryField := username
+	if idx := strings.Index(username, ":"); idx >= 0 {
+		expiryField = username[:idx]
+	}
+
+	expiry, err := strconv.ParseInt(expiryField, 10, 64)
+	if err != nil {
+		return nil, false
+	}
+	if now.Unix() > expiry {
+		return nil, false
+	}
+
+	password := signCredential(secret, username)
+	return pionturn.GenerateAuthKey(username, realm, password), true
+}
+
+// resolveRelayIP turns the configured public address into the IP advertised to
+// peers. An empty value auto-detects the outbound interface address; a hostname
+// is resolved (IPv4 preferred).
+func resolveRelayIP(configured string) (net.IP, error) {
+	configured = strings.TrimSpace(configured)
+	if configured == "" {
+		return outboundIP()
+	}
+
+	if ip := net.ParseIP(configured); ip != nil {
+		return ip, nil
+	}
+
+	ips, err := net.LookupIP(configured)
+	if err != nil {
+		return nil, fmt.Errorf("cannot resolve TURN public address %q: %w", configured, err)
+	}
+	for _, ip := range ips {
+		if v4 := ip.To4(); v4 != nil {
+			return v4, nil
+		}
+	}
+	if len(ips) > 0 {
+		return ips[0], nil
+	}
+	return nil, fmt.Errorf("cannot resolve TURN public address %q", configured)
+}
+
+// outboundIP returns the address of the interface used to reach the Internet.
+func outboundIP() (net.IP, error) {
+	conn, err := net.Dial("udp", "8.8.8.8:80")
+	if err != nil {
+		return nil, errors.New("could not determine outbound IP for TURN relay: " + err.Error())
+	}
+	defer conn.Close()
+
+	if addr, ok := conn.LocalAddr().(*net.UDPAddr); ok {
+		return addr.IP, nil
+	}
+	return nil, errors.New("could not determine outbound IP for TURN relay")
+}
+
+// ── pion logging bridge ────────────────────────────────────────────────────
+// Routes the relay's own warnings/errors into the managed system log and drops
+// the verbose trace/debug/info chatter.
+
+type pionLoggerFactory struct{}
+
+func (pionLoggerFactory) NewLogger(string) logging.LeveledLogger { return pionLogger{} }
+
+type pionLogger struct{}
+
+func (pionLogger) Trace(string)          {}
+func (pionLogger) Tracef(string, ...any) {}
+func (pionLogger) Debug(string)          {}
+func (pionLogger) Debugf(string, ...any) {}
+func (pionLogger) Info(string)           {}
+func (pionLogger) Infof(string, ...any)  {}
+func (pionLogger) Warn(msg string)       { logger.PrintAndLog("Arozcast TURN", msg, nil) }
+func (pionLogger) Warnf(format string, args ...any) {
+	logger.PrintAndLog("Arozcast TURN", fmt.Sprintf(format, args...), nil)
+}
+func (pionLogger) Error(msg string) { logger.PrintAndLog("Arozcast TURN", msg, nil) }
+func (pionLogger) Errorf(format string, args ...any) {
+	logger.PrintAndLog("Arozcast TURN", fmt.Sprintf(format, args...), nil)
+}

+ 283 - 0
src/mod/network/turn/turn_test.go

@@ -0,0 +1,283 @@
+package turn
+
+import (
+	"bytes"
+	"crypto/ecdsa"
+	"crypto/elliptic"
+	"crypto/rand"
+	"crypto/tls"
+	"crypto/x509"
+	"crypto/x509/pkix"
+	"encoding/pem"
+	"math/big"
+	"net"
+	"os"
+	"path/filepath"
+	"strconv"
+	"testing"
+	"time"
+
+	pionturn "github.com/pion/turn/v4"
+)
+
+// writeSelfSignedCert generates a throwaway self-signed certificate/key pair in
+// a temp dir and returns their file paths, for exercising the TURNS listener.
+func writeSelfSignedCert(t *testing.T) (certFile, keyFile string) {
+	t.Helper()
+
+	key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+	if err != nil {
+		t.Fatalf("generate key: %v", err)
+	}
+
+	template := x509.Certificate{
+		SerialNumber: big.NewInt(1),
+		Subject:      pkix.Name{CommonName: "arozos-turn-test"},
+		NotBefore:    time.Now().Add(-time.Hour),
+		NotAfter:     time.Now().Add(time.Hour),
+		IPAddresses:  []net.IP{net.ParseIP("127.0.0.1")},
+	}
+	der, err := x509.CreateCertificate(rand.Reader, &template, &template, &key.PublicKey, key)
+	if err != nil {
+		t.Fatalf("create certificate: %v", err)
+	}
+
+	dir := t.TempDir()
+	certFile = filepath.Join(dir, "cert.pem")
+	keyFile = filepath.Join(dir, "key.pem")
+
+	certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
+	if err := os.WriteFile(certFile, certPEM, 0600); err != nil {
+		t.Fatalf("write cert: %v", err)
+	}
+
+	keyDER, err := x509.MarshalECPrivateKey(key)
+	if err != nil {
+		t.Fatalf("marshal key: %v", err)
+	}
+	keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
+	if err := os.WriteFile(keyFile, keyPEM, 0600); err != nil {
+		t.Fatalf("write key: %v", err)
+	}
+	return certFile, keyFile
+}
+
+func TestBuildAndValidateCredential(t *testing.T) {
+	secret := []byte("a-test-secret-value-1234567890ab")
+	realm := "arozos"
+	now := time.Unix(1_700_000_000, 0)
+
+	tests := []struct {
+		name     string
+		identity string
+	}{
+		{"with identity", "alice"},
+		{"empty identity", ""},
+		{"identity with colon", "user:with:colons"},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			username, password := buildCredential(secret, tt.identity, now.Add(time.Hour))
+			if username == "" || password == "" {
+				t.Fatalf("buildCredential returned empty username/password")
+			}
+
+			key, ok := validateCredential(secret, realm, username, now)
+			if !ok {
+				t.Fatalf("validateCredential rejected a freshly minted credential")
+			}
+			if len(key) == 0 {
+				t.Fatalf("validateCredential returned an empty auth key")
+			}
+		})
+	}
+}
+
+func TestValidateCredentialExpired(t *testing.T) {
+	secret := []byte("secret")
+	realm := "arozos"
+	issuedAt := time.Unix(1_700_000_000, 0)
+
+	username, _ := buildCredential(secret, "bob", issuedAt.Add(time.Minute))
+
+	// One second after expiry the credential must be rejected.
+	after := issuedAt.Add(time.Minute + time.Second)
+	if _, ok := validateCredential(secret, realm, username, after); ok {
+		t.Fatalf("expected expired credential to be rejected")
+	}
+
+	// Exactly at the issuing moment it must still be valid.
+	if _, ok := validateCredential(secret, realm, username, issuedAt); !ok {
+		t.Fatalf("expected non-expired credential to be accepted")
+	}
+}
+
+// validateCredential returns the auth key pion compares against the client's
+// STUN MESSAGE-INTEGRITY. It does not itself reject a wrong secret — instead it
+// returns a key bound to the secret, so a forged credential yields a key the
+// genuine password cannot match and pion rejects it. These tests assert that
+// key binding rather than the ok flag.
+func TestValidateCredentialKeyBinding(t *testing.T) {
+	secret := []byte("real-secret")
+	realm := "arozos"
+	now := time.Unix(1_700_000_000, 0)
+
+	username, password := buildCredential(secret, "carol", now.Add(time.Hour))
+	want := pionturn.GenerateAuthKey(username, realm, password)
+
+	key, ok := validateCredential(secret, realm, username, now)
+	if !ok {
+		t.Fatalf("valid credential rejected")
+	}
+	if !bytes.Equal(key, want) {
+		t.Fatalf("auth key does not match the issued password; a genuine client would fail to authenticate")
+	}
+
+	// A server holding a different secret derives a different key, so the
+	// genuine password no longer matches and pion's integrity check fails.
+	otherKey, _ := validateCredential([]byte("other-secret"), realm, username, now)
+	if bytes.Equal(otherKey, want) {
+		t.Fatalf("expected a different key under a different secret")
+	}
+
+	// Tampering with the username (e.g. extending the expiry) changes the key
+	// the server expects, while the attacker only holds the original password.
+	tampered := username + "0"
+	tamperedKey, _ := validateCredential(secret, realm, tampered, now)
+	if bytes.Equal(tamperedKey, want) {
+		t.Fatalf("expected a tampered username to derive a different key")
+	}
+}
+
+func TestValidateCredentialMalformed(t *testing.T) {
+	secret := []byte("secret")
+	realm := "arozos"
+	now := time.Unix(1_700_000_000, 0)
+
+	for _, username := range []string{"", "not-a-number", "notanumber:identity", ":identity"} {
+		if _, ok := validateCredential(secret, realm, username, now); ok {
+			t.Fatalf("expected malformed username %q to be rejected", username)
+		}
+	}
+}
+
+func TestResolveRelayIP(t *testing.T) {
+	t.Run("explicit IPv4", func(t *testing.T) {
+		ip, err := resolveRelayIP("203.0.113.7")
+		if err != nil {
+			t.Fatalf("unexpected error: %v", err)
+		}
+		if !ip.Equal(net.ParseIP("203.0.113.7")) {
+			t.Fatalf("got %v, want 203.0.113.7", ip)
+		}
+	})
+
+	t.Run("empty auto-detects", func(t *testing.T) {
+		ip, err := resolveRelayIP("")
+		if err != nil {
+			t.Skipf("outbound IP detection unavailable in this environment: %v", err)
+		}
+		if ip == nil {
+			t.Fatalf("expected a non-nil auto-detected IP")
+		}
+	})
+
+	t.Run("unresolvable hostname errors", func(t *testing.T) {
+		if _, err := resolveRelayIP("this-host-does-not-exist.invalid"); err == nil {
+			t.Fatalf("expected an error for an unresolvable hostname")
+		}
+	})
+}
+
+func TestNewServerInvalidPort(t *testing.T) {
+	for _, port := range []int{0, -1, 70000} {
+		if _, err := NewServer(Config{ListenPort: port}); err == nil {
+			t.Fatalf("expected error for invalid port %d", port)
+		}
+	}
+}
+
+func TestServerCredentialsRoundTrip(t *testing.T) {
+	// Bind on an ephemeral-ish high port; skip gracefully if unavailable.
+	srv, err := NewServer(Config{ListenPort: 34780, PublicIP: "127.0.0.1", Realm: "arozos"})
+	if err != nil {
+		t.Skipf("could not start TURN server in this environment: %v", err)
+	}
+	defer srv.Close()
+
+	username, password := srv.Credentials("eve")
+	if username == "" || password == "" {
+		t.Fatalf("Credentials returned empty values")
+	}
+
+	// The server must accept its own freshly minted credential.
+	key, ok := srv.authHandler(username, srv.Realm(), nil)
+	if !ok || len(key) == 0 {
+		t.Fatalf("server rejected its own credential")
+	}
+
+	// Without a TLS port configured the TURNS listener must stay off.
+	if srv.TLSEnabled() {
+		t.Fatalf("TLSEnabled should be false when no TLS port is configured")
+	}
+	if srv.TLSPort() != 0 {
+		t.Fatalf("TLSPort should be 0 when TLS is disabled, got %d", srv.TLSPort())
+	}
+}
+
+func TestServerWithTLSListener(t *testing.T) {
+	certFile, keyFile := writeSelfSignedCert(t)
+
+	const tlsPort = 35349
+	srv, err := NewServer(Config{
+		ListenPort:  34781,
+		PublicIP:    "127.0.0.1",
+		Realm:       "arozos",
+		TLSPort:     tlsPort,
+		TLSCertFile: certFile,
+		TLSKeyFile:  keyFile,
+	})
+	if err != nil {
+		t.Skipf("could not start TURN server in this environment: %v", err)
+	}
+	defer srv.Close()
+
+	if !srv.TLSEnabled() {
+		t.Fatalf("expected TLSEnabled to be true")
+	}
+	if srv.TLSPort() != tlsPort {
+		t.Fatalf("TLSPort = %d, want %d", srv.TLSPort(), tlsPort)
+	}
+
+	// The TURNS listener must complete a TLS handshake on its port.
+	conn, err := tls.DialWithDialer(
+		&net.Dialer{Timeout: 3 * time.Second},
+		"tcp",
+		net.JoinHostPort("127.0.0.1", strconv.Itoa(tlsPort)),
+		&tls.Config{InsecureSkipVerify: true}, //nolint:gosec // self-signed cert in test
+	)
+	if err != nil {
+		t.Fatalf("TLS dial to TURNS listener failed: %v", err)
+	}
+	_ = conn.Close()
+}
+
+func TestServerTLSListenerMissingCertIsNonFatal(t *testing.T) {
+	// A TLS port is requested but no certificate is configured: the server must
+	// still start (plain relay), with TLS reported as disabled.
+	srv, err := NewServer(Config{
+		ListenPort: 34782,
+		PublicIP:   "127.0.0.1",
+		Realm:      "arozos",
+		TLSPort:    35350,
+	})
+	if err != nil {
+		t.Skipf("could not start TURN server in this environment: %v", err)
+	}
+	defer srv.Close()
+
+	if srv.TLSEnabled() {
+		t.Fatalf("expected TLSEnabled to be false when no certificate is configured")
+	}
+}

+ 113 - 0
src/web/Arozcast/README.md

@@ -15,6 +15,7 @@ Any ArozOS webapp can become a sender by using the HTTP and WebSocket APIs docum
    - [GET /api/arozcast/close](#get-apiarozcastclose)
    - [POST /api/arozcast/publish](#post-apiarozcastpublish)
    - [GET /api/arozcast/ws](#get-apiarozcastws)
+   - [GET /api/arozcast/iceservers](#get-apiarozcasticeservers)
 3. [WebSocket Message Protocol](#websocket-message-protocol)
    - [Message Envelope](#message-envelope)
    - [Sender → Receiver Topics](#sender--receiver-topics)
@@ -174,6 +175,50 @@ Frames are plain text containing JSON. See [Message Protocol](#websocket-message
 
 ---
 
+### GET /api/arozcast/iceservers
+
+Returns the ICE server list the **screen-share** feature feeds to
+`RTCPeerConnection`. Screen share is a direct WebRTC peer-to-peer connection;
+STUN alone is enough on a LAN, but crossing the Internet (peers behind NAT)
+needs a TURN relay. This endpoint supplies both.
+
+**Request:** No parameters.
+
+**Response:** an `RTCConfiguration`-shaped object:
+```json
+{
+  "iceServers": [
+    { "urls": ["stun:stun.l.google.com:19302"] },
+    {
+      "urls": ["turn:cloud.example.com:3478?transport=udp",
+               "turn:cloud.example.com:3478?transport=tcp",
+               "turns:cloud.example.com:5349?transport=tcp"],
+      "username":   "1718540000:alice",
+      "credential": "h6Yc…base64-hmac…="
+    }
+  ]
+}
+```
+
+The `turns:` (TURN-over-TLS) URL is included only when the TLS listener is
+running (`-arozcast_turn_tls`), giving clients behind TLS-only firewalls a path
+that looks like ordinary HTTPS.
+
+**Example:**
+```javascript
+const res = await fetch(ao_root + 'api/arozcast/iceservers');
+const cfg = await res.json();              // { iceServers: [...] }
+const pc  = new RTCPeerConnection(cfg);    // pass straight to RTCPeerConnection
+```
+
+The TURN entry is present only when the built-in relay is running. Its
+credentials are minted per request, HMAC-signed and short-lived, so the relay is
+never an open proxy. The TURN host mirrors the host the client used to reach
+ArozOS (honouring `X-Forwarded-Host`). See
+[Screen Share over the Internet](#screen-share-over-the-internet).
+
+---
+
 ## WebSocket Message Protocol
 
 ### Message Envelope
@@ -759,6 +804,74 @@ The **30-second receiver idle** guard is the second line of defence against zomb
 3. All WebSocket connections are closed.
 4. Any sender that receives `room.closed` should give up immediately; any sender that misses it will discover the room is gone when its next reconnect attempt gets a **404 Room not found** response.
 
+### Screen Share over the Internet
+
+Media casting (Musicify / Movie / Photo) already works over the Internet: both
+sender and receiver relay through this ArozOS host, and the receiver loads media
+from the host it reached, so nothing extra is required beyond the host being
+reachable.
+
+**Screen share is different** — it is a direct WebRTC peer-to-peer connection.
+On a LAN the peers connect with host candidates, but across the Internet they
+are usually behind NAT and need a **TURN relay** to forward the stream. ArozOS
+ships a built-in TURN relay so this works without a third-party service:
+
+| Flag | Default | Purpose |
+|------|---------|---------|
+| `-arozcast_turn` | `true` | Enable the built-in TURN relay. |
+| `-arozcast_turn_port` | `3478` | UDP **and** TCP port the relay listens on. |
+| `-arozcast_turn_publicip` | *(auto)* | Public IP/hostname advertised to peers. Auto-detected from the outbound interface; **set this when the host is behind NAT**. |
+| `-arozcast_turn_tls` | `true` | Also serve **TURN-over-TLS (TURNS)** for firewall traversal (see below). A no-op when no TLS certificate can be loaded. |
+| `-arozcast_turn_tls_port` | `5349` | TCP port for the TURNS listener. **Set to `443`** to share the standard HTTPS port for maximum reach. |
+
+For screen share to work across the Internet, the relay port must be reachable
+by both peers:
+
+- **Host with a public IP (VPS / port-forwarded):** forward `-arozcast_turn_port`
+  (UDP + TCP). Behind NAT, also set `-arozcast_turn_publicip` to your public IP.
+- **Behind a reverse proxy:** the proxy only carries HTTP(S); expose the TURN
+  port separately (it does not go through the proxy).
+- The relay is **non-fatal**: if it cannot start, screen share silently falls
+  back to STUN-only (LAN works, Internet may not).
+
+**TURN over TLS (firewall traversal).** Restrictive networks (corporate
+firewalls, some mobile carriers) often block UDP 3478 and every port except
+443/TLS. With `-arozcast_turn_tls`, ArozOS also exposes the relay as **TURNS** —
+TURN wrapped in TLS — so the relayed media is indistinguishable from ordinary
+HTTPS and rides straight through. It reuses the system TLS certificate
+(`-cert` / `-key`); if no certificate loads, TURNS is skipped and the plain
+relay still runs. The default port `5349` is fine in most cases; point
+`-arozcast_turn_tls_port` at `443` only when that port is free on the relay's
+address (the web server and TURNS both speak TLS, so they cannot share one
+port). The TURNS URL is advertised to clients automatically as
+`turns:host:port?transport=tcp`.
+
+**Toggle & status in System Settings.** Admins can turn the relay on or off at
+runtime — and see its live state (running, port, TURNS, advertised host) —
+under **System Settings → Network & Connection → Screen Share Relay**
+(`SystemAO/arozcast/turn.html`, backed by `/system/arozcast/turn/status` and
+`/system/arozcast/turn/setEnabled`). The toggle is persisted in the system
+database and **overrides** the `-arozcast_turn` flag default, so it survives a
+restart without changing launch flags.
+
+**Using an external TURN instead.** Drop a `system/arozcast/iceservers.json`
+file to fully replace the ICE list returned by `/api/arozcast/iceservers`
+(e.g. to point at coturn or a managed TURN provider). When present and valid it
+takes precedence over the built-in relay:
+
+```json
+{
+  "iceServers": [
+    { "urls": ["stun:stun.l.google.com:19302"] },
+    {
+      "urls": ["turn:turn.example.com:3478"],
+      "username": "myuser",
+      "credential": "mypassword"
+    }
+  ]
+}
+```
+
 ### HTTP publish for non-WS contexts
 AGI scripts and server-side code that cannot hold a WebSocket can use `/api/arozcast/publish` to inject any message into a live room. This is useful for automation (e.g. skip to next track on a timer) without modifying the frontend.
 

+ 21 - 6
src/web/Arozcast/index.html

@@ -455,6 +455,13 @@ function arozcastApp() {
         // Screen share (WebRTC)
         _pc: null,
         _iceQueue: [],
+        // ICE servers for screen share. Defaults to STUN (LAN only); replaced
+        // by the server-provided STUN+TURN list in _loadIceServers() so screen
+        // share can connect over the Internet.
+        rtcConfig: { iceServers: [
+            { urls: 'stun:stun.l.google.com:19302' },
+            { urls: 'stun:stun1.l.google.com:19302' },
+        ] },
 
         get progressPct() {
             if (!this.duration) return 0;
@@ -476,6 +483,9 @@ function arozcastApp() {
             // Auto-hide toolbar
             document.addEventListener('mousemove', () => self.showToolbar());
 
+            // Load ICE servers (STUN + TURN) so screen share works over the Internet
+            this._loadIceServers();
+
             // Create room
             try {
                 const res = await fetch(ao_root + 'api/arozcast/create', { method: 'POST' });
@@ -763,6 +773,16 @@ function arozcastApp() {
         },
 
         // ── Screen share (WebRTC) ─────────────────────────────────────
+        async _loadIceServers() {
+            try {
+                const res  = await fetch(ao_root + 'api/arozcast/iceservers');
+                const data = await res.json();
+                if (data && Array.isArray(data.iceServers) && data.iceServers.length) {
+                    this.rtcConfig = { iceServers: data.iceServers };
+                }
+            } catch (e) { /* keep STUN defaults */ }
+        },
+
         _prepareScreen() {
             if (this._pc) { this._pc.close(); this._pc = null; }
             this._iceQueue = [];
@@ -782,12 +802,7 @@ function arozcastApp() {
             if (this._pc) { this._pc.close(); }
             this._iceQueue = [];
 
-            const pc = new RTCPeerConnection({
-                iceServers: [
-                    { urls: 'stun:stun.l.google.com:19302' },
-                    { urls: 'stun:stun1.l.google.com:19302' },
-                ]
-            });
+            const pc = new RTCPeerConnection(this.rtcConfig);
             // Assign early so arriving webrtc.ice messages are queued correctly
             this._pc = pc;
 

+ 20 - 0
src/web/Arozcast/screenshare.html

@@ -186,6 +186,8 @@
 </div>
 <script>
 // ── WebRTC configuration ───────────────────────────────────────────────────
+// Default STUN-only config (works on a LAN). The TURN relay needed to traverse
+// NAT over the Internet is fetched from the server in loadIceServers().
 const RTC_CONFIG = {
     iceServers: [
         { urls: 'stun:stun.l.google.com:19302' },
@@ -193,6 +195,19 @@ const RTC_CONFIG = {
     ]
 };
 
+// Fetch the server-provided ICE servers (STUN + built-in/configured TURN) so
+// screen share can connect over the Internet. Falls back to the STUN defaults
+// above if the request fails.
+async function loadIceServers(root) {
+    try {
+        const res  = await fetch(root + 'api/arozcast/iceservers');
+        const data = await res.json();
+        if (data && Array.isArray(data.iceServers) && data.iceServers.length) {
+            RTC_CONFIG.iceServers = data.iceServers;
+        }
+    } catch (e) { /* keep STUN defaults */ }
+}
+
 // ── State ─────────────────────────────────────────────────────────────────
 let ws               = null;
 let pc               = null;   // RTCPeerConnection
@@ -270,6 +285,11 @@ async function connectRoom() {
 
     setConnStatus('', 'Connecting…');
 
+    // Load ICE servers now (while we have a network round-trip to spare) so the
+    // peer connection in startShare() — which must run inside the user gesture
+    // — has the TURN relay ready without an extra await before getDisplayMedia.
+    await loadIceServers(root);
+
     const wsUrl = new URL(root + 'api/arozcast/ws?code=' + code, window.location.href);
     wsUrl.protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
     ws = new WebSocket(wsUrl.toString());

BIN
src/web/SystemAO/arozcast/img/small_icon.png


+ 445 - 0
src/web/SystemAO/arozcast/turn.html

@@ -0,0 +1,445 @@
+<!DOCTYPE html>
+<html>
+<head>
+    <meta name="apple-mobile-web-app-capable" content="yes">
+    <meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
+    <meta charset="UTF-8">
+    <link rel="icon" href="img/small_icon.png">
+    <!-- ao_module is provided by the parent frame; ao_module.js is not needed standalone -->
+    <script src="../../../script/jquery.min.js"></script>
+    <title>Screen Share Relay</title>
+    <style>
+    *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
+
+    body { background: transparent; overflow-x: hidden; }
+
+    /* ── Design tokens ── */
+    #ac-root {
+        --bg:        #f2f2f7;
+        --card:      #ffffff;
+        --border:    rgba(0,0,0,0.10);
+        --text:      #1d1d1f;
+        --dim:       #6e6e73;
+        --muted:     #aeaeb2;
+        --accent:    #007AFF;
+        --accentH:   #0063d1;
+        --success:   #34c759;
+        --danger:    #ff3b30;
+        --warn-bg:   #fff7ed;
+        --warn-text: #7c4e00;
+        --warn-bdr:  rgba(255,149,0,0.28);
+        --radius:    12px;
+        --shadow:    0 1px 3px rgba(0,0,0,0.07), 0 1px 8px rgba(0,0,0,0.04);
+
+        font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue", Arial, sans-serif;
+        font-size: 13px;
+        color: var(--text);
+        background: var(--bg);
+        min-height: 100vh;
+        padding-bottom: 32px;
+    }
+
+    /* ── Dark mode ── */
+    #ac-root.dark {
+        --bg:        #000000;
+        --card:      #1c1c1e;
+        --border:    rgba(255,255,255,0.10);
+        --text:      #f2f2f7;
+        --dim:       #98989d;
+        --muted:     #636366;
+        --accent:    #0a84ff;
+        --accentH:   #409cff;
+        --success:   #30d158;
+        --danger:    #ff453a;
+        --warn-bg:   #2d1f00;
+        --warn-text: #ffd60a;
+        --warn-bdr:  rgba(255,214,10,0.22);
+        --shadow:    0 1px 3px rgba(0,0,0,0.4), 0 1px 8px rgba(0,0,0,0.3);
+    }
+
+    /* ── Page header ── */
+    .ac-page-hd {
+        padding: 18px 16px 6px;
+        display: flex;
+        align-items: center;
+        gap: 12px;
+    }
+    .ac-page-hd-icon {
+        width: 36px;
+        height: 36px;
+        background: linear-gradient(145deg, #007AFF, #5ac8fa);
+        border-radius: 9px;
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        flex-shrink: 0;
+        box-shadow: 0 2px 8px rgba(0,122,255,0.30);
+    }
+    .ac-page-hd-icon svg { display: block; }
+    .ac-page-hd-text h1 {
+        font-size: 17px;
+        font-weight: 600;
+        color: var(--text);
+        letter-spacing: -0.2px;
+    }
+    .ac-page-hd-text p {
+        font-size: 12px;
+        color: var(--dim);
+        margin-top: 2px;
+    }
+
+    /* ── Section cards ── */
+    .ac-card {
+        background: var(--card);
+        border-radius: var(--radius);
+        box-shadow: var(--shadow);
+        margin: 10px 12px;
+        overflow: hidden;
+    }
+    .ac-card-hd {
+        display: flex;
+        align-items: center;
+        gap: 8px;
+        padding: 12px 16px 11px;
+        border-bottom: 1px solid var(--border);
+    }
+    .ac-card-hd-icon { color: var(--dim); flex-shrink: 0; }
+    .ac-card-title {
+        font-size: 12px;
+        font-weight: 700;
+        text-transform: uppercase;
+        letter-spacing: 0.06em;
+        color: var(--dim);
+        flex: 1;
+    }
+    .ac-card-body { padding: 16px; }
+
+    /* ── Info list ── */
+    .ac-info-list { list-style: none; display: flex; flex-direction: column; gap: 9px; }
+    .ac-info-list li {
+        display: flex;
+        gap: 10px;
+        font-size: 12.5px;
+        color: var(--dim);
+        line-height: 1.5;
+    }
+    .ac-info-list li::before {
+        content: '';
+        width: 5px;
+        height: 5px;
+        border-radius: 50%;
+        background: var(--accent);
+        flex-shrink: 0;
+        margin-top: 6px;
+    }
+    .ac-info-list code {
+        font-family: "SF Mono", "Menlo", "Courier New", monospace;
+        font-size: 11.5px;
+        background: rgba(0,0,0,0.05);
+        padding: 1px 5px;
+        border-radius: 5px;
+        color: var(--text);
+        word-break: break-all;
+    }
+    #ac-root.dark .ac-info-list code { background: rgba(255,255,255,0.08); }
+
+    /* ── Setting rows ── */
+    .ac-row {
+        display: flex;
+        align-items: center;
+        gap: 12px;
+        padding: 13px 0;
+        border-bottom: 1px solid var(--border);
+    }
+    .ac-row:last-of-type { border-bottom: none; }
+    .ac-row-text { flex: 1; min-width: 0; }
+    .ac-row-label { font-size: 13px; font-weight: 500; color: var(--text); }
+    .ac-row-desc { font-size: 11.5px; color: var(--dim); margin-top: 2px; }
+    .ac-row-value {
+        font-size: 12.5px;
+        color: var(--dim);
+        font-family: "SF Mono", "Menlo", "Courier New", monospace;
+        text-align: right;
+        flex-shrink: 0;
+        max-width: 55%;
+        word-break: break-all;
+    }
+
+    /* ── Status badge ── */
+    .ac-badge {
+        display: inline-flex;
+        align-items: center;
+        gap: 6px;
+        font-size: 12px;
+        font-weight: 600;
+        padding: 4px 10px;
+        border-radius: 999px;
+        flex-shrink: 0;
+    }
+    .ac-badge .dot { width: 7px; height: 7px; border-radius: 50%; }
+    .ac-badge.on  { background: rgba(52,199,89,0.14);  color: var(--success); }
+    .ac-badge.on .dot  { background: var(--success); }
+    .ac-badge.off { background: rgba(142,142,147,0.18); color: var(--muted); }
+    .ac-badge.off .dot { background: var(--muted); }
+
+    /* ── Toggle switch ── */
+    .ac-toggle { position: relative; width: 51px; height: 31px; flex-shrink: 0; cursor: pointer; }
+    .ac-toggle input { opacity: 0; width: 0; height: 0; position: absolute; }
+    .ac-toggle-track {
+        position: absolute; inset: 0;
+        background: var(--muted);
+        border-radius: 15.5px;
+        transition: background 0.22s;
+    }
+    .ac-toggle-track::after {
+        content: '';
+        position: absolute; left: 3px; top: 3px;
+        width: 25px; height: 25px;
+        background: #fff; border-radius: 50%;
+        transition: left 0.2s cubic-bezier(.4,0,.2,1);
+        box-shadow: 0 2px 6px rgba(0,0,0,0.22);
+    }
+    .ac-toggle input:checked + .ac-toggle-track { background: var(--accent); }
+    .ac-toggle input:checked + .ac-toggle-track::after { left: 23px; }
+    .ac-toggle input:disabled + .ac-toggle-track { opacity: 0.5; }
+
+    /* ── Warning banner ── */
+    .ac-warn {
+        display: flex;
+        gap: 9px;
+        align-items: flex-start;
+        background: var(--warn-bg);
+        border: 1px solid var(--warn-bdr);
+        border-radius: 8px;
+        padding: 10px 13px;
+        font-size: 12px;
+        color: var(--warn-text);
+        line-height: 1.5;
+        margin-top: 12px;
+    }
+    .ac-warn svg { flex-shrink: 0; margin-top: 1px; }
+
+    /* ── Toast ── */
+    #ac-toast {
+        display: none;
+        position: fixed;
+        left: 50%; bottom: 20px;
+        transform: translateX(-50%);
+        z-index: 9100;
+        background: rgba(30,30,32,0.92);
+        backdrop-filter: blur(14px);
+        -webkit-backdrop-filter: blur(14px);
+        border-radius: 12px;
+        padding: 10px 18px;
+        box-shadow: 0 8px 28px rgba(0,0,0,0.22);
+        max-width: min(340px, calc(100vw - 24px));
+        text-align: center;
+        pointer-events: none;
+    }
+    #ac-toast.err { background: rgba(255,59,48,0.92); }
+    #ac-toast-msg { font-size: 13px; font-weight: 500; color: #fff; }
+    </style>
+</head>
+<body>
+
+<div id="ac-toast"><div id="ac-toast-msg"></div></div>
+
+<div id="ac-root">
+
+    <!-- ── Page header ── -->
+    <div class="ac-page-hd">
+        <div class="ac-page-hd-icon">
+            <svg width="20" height="20" viewBox="0 0 24 24" fill="none">
+                <rect x="3" y="4" width="18" height="13" rx="2" stroke="rgba(255,255,255,0.9)" stroke-width="1.6"/>
+                <path d="M8 20h8M12 17v3" stroke="rgba(255,255,255,0.6)" stroke-width="1.6" stroke-linecap="round"/>
+            </svg>
+        </div>
+        <div class="ac-page-hd-text">
+            <h1>Screen Share Relay</h1>
+            <p>Built-in TURN relay for Arozcast screen share over the Internet</p>
+        </div>
+    </div>
+
+    <!-- ── Settings card ── -->
+    <div class="ac-card">
+        <div class="ac-card-hd">
+            <svg class="ac-card-hd-icon" width="14" height="14" viewBox="0 0 16 16" fill="none">
+                <circle cx="8" cy="8" r="2.5" stroke="currentColor" stroke-width="1.4"/>
+                <path d="M8 1v2M8 13v2M1 8h2M13 8h2M3.22 3.22l1.41 1.41M11.37 11.37l1.41 1.41M3.22 12.78l1.41-1.41M11.37 4.63l1.41-1.41"
+                      stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
+            </svg>
+            <span class="ac-card-title">Relay</span>
+        </div>
+        <div class="ac-card-body">
+            <div class="ac-row">
+                <div class="ac-row-text">
+                    <div class="ac-row-label">Enable relay</div>
+                    <div class="ac-row-desc">Lets screen share connect across NAT / the Internet. When off, screen share works on the local network only.</div>
+                </div>
+                <label class="ac-toggle">
+                    <input type="checkbox" id="ac-enable" onchange="setEnabled(this.checked);" disabled>
+                    <span class="ac-toggle-track"></span>
+                </label>
+            </div>
+        </div>
+    </div>
+
+    <!-- ── Status card ── -->
+    <div class="ac-card">
+        <div class="ac-card-hd">
+            <svg class="ac-card-hd-icon" width="14" height="14" viewBox="0 0 16 16" fill="none">
+                <circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.4"/>
+                <path d="M8 7v5M8 5v.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
+            </svg>
+            <span class="ac-card-title">Status</span>
+        </div>
+        <div class="ac-card-body">
+            <div class="ac-row">
+                <div class="ac-row-text"><div class="ac-row-label">State</div></div>
+                <span id="ac-state" class="ac-badge off"><span class="dot"></span><span id="ac-state-text">—</span></span>
+            </div>
+            <div class="ac-row">
+                <div class="ac-row-text"><div class="ac-row-label">Relay port</div><div class="ac-row-desc">UDP &amp; TCP</div></div>
+                <div class="ac-row-value" id="ac-port">—</div>
+            </div>
+            <div class="ac-row">
+                <div class="ac-row-text"><div class="ac-row-label">TURN over TLS</div><div class="ac-row-desc">Traverses TLS-only firewalls</div></div>
+                <div class="ac-row-value" id="ac-tls">—</div>
+            </div>
+            <div class="ac-row">
+                <div class="ac-row-text"><div class="ac-row-label">Advertised address</div><div class="ac-row-desc">Host peers dial for the relay</div></div>
+                <div class="ac-row-value" id="ac-host">—</div>
+            </div>
+        </div>
+    </div>
+
+    <!-- ── Info card ── -->
+    <div class="ac-card">
+        <div class="ac-card-hd">
+            <svg class="ac-card-hd-icon" width="14" height="14" viewBox="0 0 16 16" fill="none">
+                <path d="M2 12.5V11c0-1.5 1-2.5 3-2.5h2M11 6.5a2.5 2.5 0 10-5 0 2.5 2.5 0 005 0z" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
+                <path d="M10 9l1.5 1.5L14 8" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
+            </svg>
+            <span class="ac-card-title">How it works</span>
+        </div>
+        <div class="ac-card-body">
+            <ul class="ac-info-list">
+                <li>Screen share is a direct WebRTC connection between two browsers. Across the Internet the peers are usually behind NAT, so a TURN relay is needed to forward the video.</li>
+                <li>ArozOS runs that relay itself. Credentials handed to the browser are short-lived and signed per session &mdash; the relay is not an open proxy.</li>
+                <li>For Internet use, forward the relay port to this host (and set a public IP/hostname with <code>-arozcast_turn_publicip</code> when behind NAT).</li>
+                <li>To use an external TURN service instead, drop an <code>iceServers</code> list at <code>system/arozcast/iceservers.json</code> &mdash; it overrides this relay.</li>
+            </ul>
+            <div class="ac-warn">
+                <svg width="14" height="14" viewBox="0 0 16 16" fill="none">
+                    <path d="M8 1.5L14.5 13H1.5L8 1.5z" stroke="currentColor" stroke-width="1.3" stroke-linejoin="round"/>
+                    <path d="M8 6v3.5M8 11v.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
+                </svg>
+                <span>Turning the relay off does not break local-network screen share, but remote (Internet) sessions will fail to connect.</span>
+            </div>
+        </div>
+    </div>
+
+</div><!-- /#ac-root -->
+
+<script>
+    /* ── Theme ── */
+    function applyTheme(dark) {
+        document.getElementById('ac-root').classList.toggle('dark', dark);
+    }
+    try {
+        var _t = (typeof preferredTheme !== 'undefined' && preferredTheme)
+               || (typeof parent !== 'undefined' && parent.preferredTheme);
+        if (_t) {
+            applyTheme(_t === 'dark' || _t === 'darkTheme');
+        } else {
+            ao_module_getSystemThemeColor(function(c) { applyTheme(c !== 'whiteTheme'); });
+        }
+    } catch(e) {
+        try { ao_module_getSystemThemeColor(function(c) { applyTheme(c !== 'whiteTheme'); }); } catch(_){}
+    }
+    window.desktopThemeChanged = function(theme) { applyTheme(theme === 'dark' || theme === 'darkTheme'); };
+    window.detailPageThemeCallback = function(isDark) { applyTheme(isDark); };
+
+    /* ── Toast ── */
+    function showToast(msg, isErr) {
+        var $t = $('#ac-toast');
+        $t.toggleClass('err', !!isErr);
+        $('#ac-toast-msg').text(isErr ? '✕  ' + msg : msg);
+        $t.stop(true, true).fadeIn(160).delay(3200).fadeOut(380);
+    }
+
+    var ignoreChange = true; // suppress onchange while we set the toggle programmatically
+
+    $(document).ready(loadStatus);
+
+    /* ── Load current status ── */
+    function loadStatus() {
+        $.get("../../../system/arozcast/turn/status", function(data) {
+            if (!data || data.error !== undefined) {
+                showToast(data && data.error ? data.error : "Could not load relay status", true);
+                return;
+            }
+            renderStatus(data);
+            ignoreChange = false;
+            $("#ac-enable").prop('disabled', false);
+        }).fail(function() { showToast("Server communication error", true); });
+    }
+
+    /* ── Paint the UI from a status object ── */
+    function renderStatus(s) {
+        $("#ac-enable").prop('checked', !!s.enabled);
+
+        var $state = $("#ac-state");
+        if (s.running) {
+            $state.removeClass('off').addClass('on');
+            $("#ac-state-text").text("Running");
+        } else {
+            $state.removeClass('on').addClass('off');
+            $("#ac-state-text").text(s.enabled ? "Stopped (failed to start)" : "Stopped");
+        }
+
+        $("#ac-port").text(s.port ? String(s.port) : "—");
+
+        if (!s.running) {
+            $("#ac-tls").text("—");
+        } else if (s.tls && s.tlsPort) {
+            $("#ac-tls").text("On · port " + s.tlsPort);
+        } else if (s.tlsPort) {
+            $("#ac-tls").text("Off · no usable certificate");
+        } else {
+            $("#ac-tls").text("Disabled");
+        }
+
+        var host = (s.advertiseHost && s.advertiseHost.length) ? s.advertiseHost
+                 : (s.publicIP && s.publicIP.length) ? s.publicIP
+                 : "Auto (host used to reach ArozOS)";
+        $("#ac-host").text(host);
+    }
+
+    /* ── Toggle enable / disable ── */
+    function setEnabled(enabled) {
+        if (ignoreChange) return;
+        $("#ac-enable").prop('disabled', true);
+        $.ajax({
+            url: "../../../system/arozcast/turn/setEnabled",
+            data: { enable: enabled },
+            success: function(data) {
+                if (!data || data.error !== undefined) {
+                    showToast(data && data.error ? data.error : "Update failed", true);
+                    // Re-sync UI with the real server state.
+                    loadStatus();
+                    return;
+                }
+                renderStatus(data);
+                $("#ac-enable").prop('disabled', false);
+                showToast(enabled ? "Screen share relay enabled" : "Screen share relay disabled");
+            },
+            error: function() {
+                showToast("Server communication error", true);
+                loadStatus();
+            }
+        });
+    }
+</script>
+</body>
+</html>