cast.go 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680
  1. package main
  2. /*
  3. Arozcast - Remote Projection Pub/Sub Backend
  4. author: tobychui
  5. Provides a simple room-based pub/sub WebSocket relay so that
  6. Arozcast (receiver) and Musicify (sender) can exchange playback
  7. commands in real time using a shared 4-digit room code.
  8. Endpoints (all require login):
  9. POST /api/arozcast/create --> {"code":"1234"}
  10. GET /api/arozcast/close?code=XXXX --> "OK"
  11. GET /api/arozcast/ping?code=XXXX --> {"exists":true/false}
  12. POST /api/arozcast/publish --> "OK" (code=, msg=)
  13. GET /api/arozcast/ws?code=XXXX --> WebSocket upgrade
  14. Room lifecycle / idle timeouts
  15. ──────────────────────────────
  16. A sweep goroutine runs every 15 seconds and closes rooms that meet
  17. either of the following conditions:
  18. 1. Receiver idle — the receiver (Arozcast page) sends a
  19. 'status.update' frame every 3 s. If no such frame has been
  20. seen for receiverIdleTimeout (30 s) the receiver is considered
  21. gone and the room is closed. This guard only activates after
  22. the receiver has connected at least once (lastStatusUpdate is
  23. non-zero), so a freshly-created room is not immediately culled.
  24. 2. Empty + idle — the room has no connected clients and has been
  25. idle for emptyRoomTimeout (10 min). This catches rooms whose
  26. owner never opened the Arozcast page.
  27. Before closing, the backend broadcasts {"topic":"room.closed"} to
  28. every connected sender so they can react immediately (emit 'giveup')
  29. instead of waiting for the watchdog cycle to fire.
  30. */
  31. import (
  32. "encoding/json"
  33. "fmt"
  34. "math/rand"
  35. "net"
  36. "net/http"
  37. "os"
  38. "path/filepath"
  39. "strconv"
  40. "strings"
  41. "sync"
  42. "time"
  43. "github.com/gorilla/websocket"
  44. "imuslab.com/arozos/mod/network/turn"
  45. prout "imuslab.com/arozos/mod/prouter"
  46. "imuslab.com/arozos/mod/utils"
  47. )
  48. // The built-in TURN relay lets the WebRTC screen-share feature traverse NAT and
  49. // work over the Internet. It can be toggled at runtime from System Settings, so
  50. // its lifecycle is mutex-guarded and its construction parameters are remembered
  51. // for restart. arozcastTurnServer is nil when the relay is stopped or failed to
  52. // start, in which case the ICE config falls back to STUN-only (LAN screen share
  53. // still works).
  54. const (
  55. arozcastDBTable = "arozcast" // system DB table for Arozcast preferences
  56. arozcastTurnEnabledKey = "turn_on" // persisted bool: relay on/off override
  57. )
  58. var (
  59. arozcastTurnMu sync.RWMutex // guards arozcastTurnServer
  60. arozcastTurnServer *turn.Server // running relay, or nil when stopped/unavailable
  61. arozcastTurnConfig turn.Config // remembered config used to (re)start the relay
  62. )
  63. // arozcastGetTurnServer returns the running relay, or nil when it is stopped or
  64. // unavailable. Callers must treat nil as "fall back to STUN-only".
  65. func arozcastGetTurnServer() *turn.Server {
  66. arozcastTurnMu.RLock()
  67. defer arozcastTurnMu.RUnlock()
  68. return arozcastTurnServer
  69. }
  70. // arozcastTurnRunning reports whether the relay is currently running.
  71. func arozcastTurnRunning() bool {
  72. return arozcastGetTurnServer() != nil
  73. }
  74. // arozcastStartTurnRelay starts the relay from the remembered config. It is a
  75. // no-op (returns nil) when the relay is already running.
  76. func arozcastStartTurnRelay() error {
  77. arozcastTurnMu.Lock()
  78. defer arozcastTurnMu.Unlock()
  79. if arozcastTurnServer != nil {
  80. return nil
  81. }
  82. ts, err := turn.NewServer(arozcastTurnConfig)
  83. if err != nil {
  84. return err
  85. }
  86. arozcastTurnServer = ts
  87. return nil
  88. }
  89. // arozcastStopTurnRelay stops the relay and releases its listeners. It is a
  90. // no-op when the relay is already stopped.
  91. func arozcastStopTurnRelay() {
  92. arozcastTurnMu.Lock()
  93. defer arozcastTurnMu.Unlock()
  94. if arozcastTurnServer == nil {
  95. return
  96. }
  97. arozcastTurnServer.Close()
  98. arozcastTurnServer = nil
  99. }
  100. // arozcastTurnEnabledPref returns the persisted relay on/off preference,
  101. // falling back to the -arozcast_turn flag default when nothing is stored.
  102. func arozcastTurnEnabledPref() bool {
  103. if sysdb != nil && sysdb.KeyExists(arozcastDBTable, arozcastTurnEnabledKey) {
  104. var stored bool
  105. if err := sysdb.Read(arozcastDBTable, arozcastTurnEnabledKey, &stored); err == nil {
  106. return stored
  107. }
  108. }
  109. return *arozcast_enable_turn
  110. }
  111. // arozcastSetTurnEnabledPref persists the relay on/off preference so it survives
  112. // a restart.
  113. func arozcastSetTurnEnabledPref(enabled bool) error {
  114. return sysdb.Write(arozcastDBTable, arozcastTurnEnabledKey, enabled)
  115. }
  116. // arozcastICEOverrideFile lets an operator fully replace the ICE server list
  117. // returned to clients (e.g. to point at an external coturn/Cloudflare TURN).
  118. // When present and valid it takes precedence over the built-in relay.
  119. var arozcastICEOverrideFile = filepath.Join("system", "arozcast", "iceservers.json")
  120. // acICEServer mirrors the RTCIceServer dictionary consumed by the browser.
  121. type acICEServer struct {
  122. URLs []string `json:"urls"`
  123. Username string `json:"username,omitempty"`
  124. Credential string `json:"credential,omitempty"`
  125. }
  126. type acICEConfig struct {
  127. ICEServers []acICEServer `json:"iceServers"`
  128. }
  129. // acDefaultSTUNServers are the public STUN servers used when no other config is
  130. // available. STUN alone is enough on a LAN but not across most NATs.
  131. var acDefaultSTUNServers = []acICEServer{
  132. {URLs: []string{"stun:stun.l.google.com:19302"}},
  133. {URLs: []string{"stun:stun1.l.google.com:19302"}},
  134. }
  135. // Idle-timeout constants. Adjust here if you need different behaviour.
  136. const (
  137. acReceiverIdleTimeout = 30 * time.Second // close room when receiver goes silent
  138. acEmptyRoomTimeout = 10 * time.Minute // clean up empty rooms
  139. acSweepInterval = 15 * time.Second // how often the sweep goroutine runs
  140. )
  141. // roomClosedMsg is broadcast to all senders before the room is torn down,
  142. // giving arozcast.js (and the built-in sender apps) a chance to emit
  143. // 'giveup' immediately rather than going through the full watchdog/retry cycle.
  144. var roomClosedMsg = []byte(`{"topic":"room.closed","payload":{}}`)
  145. // acClient is one WebSocket participant in a room.
  146. type acClient struct {
  147. conn *websocket.Conn
  148. send chan []byte
  149. once sync.Once
  150. }
  151. func (c *acClient) safeClose() {
  152. c.once.Do(func() { close(c.send) })
  153. }
  154. // acRoom holds all connected clients sharing a 4-digit code.
  155. type acRoom struct {
  156. code string
  157. owner string
  158. clients map[*acClient]struct{}
  159. mu sync.Mutex
  160. createdAt time.Time
  161. lastActivity time.Time // updated on every client join / leave
  162. lastStatusUpdate time.Time // updated each time a 'status.update' frame is relayed
  163. }
  164. func (r *acRoom) add(c *acClient) {
  165. r.mu.Lock()
  166. r.clients[c] = struct{}{}
  167. r.lastActivity = time.Now()
  168. r.mu.Unlock()
  169. }
  170. func (r *acRoom) remove(c *acClient) {
  171. r.mu.Lock()
  172. delete(r.clients, c)
  173. r.lastActivity = time.Now()
  174. r.mu.Unlock()
  175. }
  176. // broadcast sends msg to every client except exclude (nil = send to all).
  177. func (r *acRoom) broadcast(msg []byte, exclude *acClient) {
  178. r.mu.Lock()
  179. defer r.mu.Unlock()
  180. for c := range r.clients {
  181. if c == exclude {
  182. continue
  183. }
  184. select {
  185. case c.send <- append([]byte(nil), msg...):
  186. default: // drop frame if send buffer is full
  187. }
  188. }
  189. }
  190. // acManager owns all live rooms.
  191. type acManager struct {
  192. rooms map[string]*acRoom
  193. mu sync.RWMutex
  194. }
  195. var acUpgrader = websocket.Upgrader{
  196. ReadBufferSize: 4096,
  197. WriteBufferSize: 4096,
  198. CheckOrigin: func(r *http.Request) bool { return true },
  199. }
  200. // acCloseRoom removes a room from the manager and disconnects all its clients.
  201. // It first broadcasts roomClosedMsg so senders can react before the socket drops.
  202. // Safe to call even if the room no longer exists.
  203. func acCloseRoom(mgr *acManager, code string) {
  204. mgr.mu.Lock()
  205. room, exists := mgr.rooms[code]
  206. if exists {
  207. delete(mgr.rooms, code)
  208. }
  209. mgr.mu.Unlock()
  210. if !exists {
  211. return
  212. }
  213. // Notify senders that the room is going away.
  214. // broadcast() queues the message into each client's send channel;
  215. // the writer goroutine will deliver it before exiting when the channel
  216. // is closed below.
  217. room.broadcast(roomClosedMsg, nil)
  218. room.mu.Lock()
  219. for c := range room.clients {
  220. c.safeClose()
  221. }
  222. room.mu.Unlock()
  223. }
  224. func ArozcastInit() {
  225. mgr := &acManager{rooms: make(map[string]*acRoom)}
  226. // ── Built-in TURN relay ───────────────────────────────────────────────
  227. // Screen share uses a direct WebRTC peer-to-peer connection. Over the
  228. // Internet the peers are usually behind NAT, so a TURN relay is needed to
  229. // forward the media. ArozOS runs that relay itself (both peers already
  230. // reach this host for signalling). Failure is non-fatal: we just fall back
  231. // to STUN-only, which keeps LAN screen share working.
  232. //
  233. // The construction parameters are remembered so the relay can be toggled
  234. // from System Settings without a restart. Whether it starts now comes from
  235. // the persisted preference, which defaults to the -arozcast_turn flag.
  236. // When -arozcast_turn_tls is set and a TLS certificate is available it also
  237. // serves TURN-over-TLS (TURNS) using the system certificate, so screen share
  238. // can traverse firewalls that only allow outbound TLS. We only wire it up
  239. // when the cert actually exists so HTTP-only deployments stay silent.
  240. sysdb.NewTable(arozcastDBTable)
  241. arozcastTurnConfig = turn.Config{
  242. ListenPort: *arozcast_turn_port,
  243. Realm: "arozos",
  244. PublicIP: *arozcast_turn_publicip,
  245. }
  246. if *arozcast_turn_tls && utils.FileExists(*tls_cert) && utils.FileExists(*tls_key) {
  247. arozcastTurnConfig.TLSPort = *arozcast_turn_tls_port
  248. arozcastTurnConfig.TLSCertFile = *tls_cert
  249. arozcastTurnConfig.TLSKeyFile = *tls_key
  250. }
  251. if arozcastTurnEnabledPref() {
  252. if err := arozcastStartTurnRelay(); err != nil {
  253. systemWideLogger.PrintAndLog("Arozcast", "Built-in TURN relay unavailable; screen share will be LAN-only", err)
  254. } else {
  255. msg := "Built-in TURN relay started on port " + strconv.Itoa(*arozcast_turn_port)
  256. if ts := arozcastGetTurnServer(); ts != nil && ts.TLSEnabled() {
  257. msg += " (TURNS on port " + strconv.Itoa(ts.TLSPort()) + ")"
  258. }
  259. systemWideLogger.PrintAndLog("Arozcast", msg, nil)
  260. }
  261. }
  262. // ── Sweep goroutine ───────────────────────────────────────────────────
  263. // Runs every acSweepInterval and closes rooms that match either idle
  264. // condition. Rooms to close are collected while holding a read-lock,
  265. // then actually closed (write-lock + WS teardown) after releasing it.
  266. go func() {
  267. ticker := time.NewTicker(acSweepInterval)
  268. defer ticker.Stop()
  269. for range ticker.C {
  270. var toClose []string
  271. mgr.mu.RLock()
  272. for code, room := range mgr.rooms {
  273. room.mu.Lock()
  274. receiverDead := !room.lastStatusUpdate.IsZero() &&
  275. time.Since(room.lastStatusUpdate) > acReceiverIdleTimeout
  276. emptyIdle := len(room.clients) == 0 &&
  277. time.Since(room.lastActivity) > acEmptyRoomTimeout
  278. room.mu.Unlock()
  279. if receiverDead || emptyIdle {
  280. toClose = append(toClose, code)
  281. }
  282. }
  283. mgr.mu.RUnlock()
  284. for _, code := range toClose {
  285. acCloseRoom(mgr, code)
  286. }
  287. }
  288. }()
  289. router := prout.NewModuleRouter(prout.RouterOption{
  290. ModuleName: "Arozcast",
  291. AdminOnly: false,
  292. UserHandler: userHandler,
  293. DeniedHandler: func(w http.ResponseWriter, r *http.Request) {
  294. errorHandlePermissionDenied(w, r)
  295. },
  296. })
  297. // ── Admin: built-in TURN relay status & toggle (System Settings) ───────
  298. // Lets an administrator see the relay's state and turn it on/off at runtime
  299. // without restarting ArozOS. The on/off choice is persisted so it survives
  300. // a restart (overriding the -arozcast_turn flag default).
  301. adminRouter := prout.NewModuleRouter(prout.RouterOption{
  302. ModuleName: "System Settings",
  303. AdminOnly: true,
  304. UserHandler: userHandler,
  305. DeniedHandler: func(w http.ResponseWriter, r *http.Request) {
  306. utils.SendErrorResponse(w, "Permission Denied")
  307. },
  308. })
  309. registerSetting(settingModule{
  310. Name: "Screen Share Relay",
  311. Desc: "Built-in TURN relay for Arozcast screen share over the Internet",
  312. IconPath: "SystemAO/arozcast/img/small_icon.png",
  313. Group: "Network",
  314. StartDir: "SystemAO/arozcast/turn.html",
  315. RequireAdmin: true,
  316. })
  317. // Report the relay's current configuration and running state.
  318. adminRouter.HandleFunc("/system/arozcast/turn/status", func(w http.ResponseWriter, r *http.Request) {
  319. js, err := json.Marshal(acTurnStatus())
  320. if err != nil {
  321. utils.SendErrorResponse(w, "Failed to build status")
  322. return
  323. }
  324. utils.SendJSONResponse(w, string(js))
  325. })
  326. // Toggle the relay on/off at runtime and persist the preference.
  327. adminRouter.HandleFunc("/system/arozcast/turn/setEnabled", func(w http.ResponseWriter, r *http.Request) {
  328. enable, err := utils.GetBool(r, "enable")
  329. if err != nil {
  330. utils.SendErrorResponse(w, "Missing or invalid enable parameter")
  331. return
  332. }
  333. if err := arozcastSetTurnEnabledPref(enable); err != nil {
  334. utils.SendErrorResponse(w, "Failed to save preference")
  335. return
  336. }
  337. if enable {
  338. if err := arozcastStartTurnRelay(); err != nil {
  339. systemWideLogger.PrintAndLog("Arozcast", "Failed to start built-in TURN relay from System Settings", err)
  340. utils.SendErrorResponse(w, "Relay could not start: "+err.Error())
  341. return
  342. }
  343. } else {
  344. arozcastStopTurnRelay()
  345. }
  346. js, err := json.Marshal(acTurnStatus())
  347. if err != nil {
  348. utils.SendErrorResponse(w, "Failed to build status")
  349. return
  350. }
  351. utils.SendJSONResponse(w, string(js))
  352. })
  353. // Create a new room; returns {"code":"XXXX"}.
  354. router.HandleFunc("/api/arozcast/create", func(w http.ResponseWriter, r *http.Request) {
  355. userinfo, err := userHandler.GetUserInfoFromRequest(w, r)
  356. if err != nil {
  357. utils.SendErrorResponse(w, "Not logged in")
  358. return
  359. }
  360. mgr.mu.Lock()
  361. var code string
  362. for {
  363. code = fmt.Sprintf("%04d", rand.Intn(9000)+1000)
  364. if _, exists := mgr.rooms[code]; !exists {
  365. break
  366. }
  367. }
  368. mgr.rooms[code] = &acRoom{
  369. code: code,
  370. owner: userinfo.Username,
  371. clients: make(map[*acClient]struct{}),
  372. createdAt: time.Now(),
  373. lastActivity: time.Now(),
  374. }
  375. mgr.mu.Unlock()
  376. utils.SendJSONResponse(w, `{"code":"`+code+`"}`)
  377. })
  378. // Close a room and disconnect all its clients.
  379. router.HandleFunc("/api/arozcast/close", func(w http.ResponseWriter, r *http.Request) {
  380. code, err := utils.GetPara(r, "code")
  381. if err != nil {
  382. utils.SendErrorResponse(w, "Missing code")
  383. return
  384. }
  385. acCloseRoom(mgr, code)
  386. utils.SendOK(w)
  387. })
  388. // Check whether a room with the given code exists.
  389. router.HandleFunc("/api/arozcast/ping", func(w http.ResponseWriter, r *http.Request) {
  390. code, err := utils.GetPara(r, "code")
  391. if err != nil {
  392. utils.SendErrorResponse(w, "Missing code")
  393. return
  394. }
  395. mgr.mu.RLock()
  396. _, exists := mgr.rooms[code]
  397. mgr.mu.RUnlock()
  398. if exists {
  399. utils.SendJSONResponse(w, `{"exists":true}`)
  400. } else {
  401. utils.SendJSONResponse(w, `{"exists":false}`)
  402. }
  403. })
  404. // ICE servers for the WebRTC screen-share feature.
  405. // Returns the STUN/TURN configuration the browser should use. Built-in TURN
  406. // credentials are minted fresh per request and are short-lived.
  407. router.HandleFunc("/api/arozcast/iceservers", func(w http.ResponseWriter, r *http.Request) {
  408. identity := ""
  409. if userinfo, err := userHandler.GetUserInfoFromRequest(w, r); err == nil {
  410. identity = userinfo.Username
  411. }
  412. config := acBuildICEConfig(r, identity)
  413. js, err := json.Marshal(config)
  414. if err != nil {
  415. utils.SendErrorResponse(w, "Failed to build ICE config")
  416. return
  417. }
  418. utils.SendJSONResponse(w, string(js))
  419. })
  420. // HTTP publish: POST code=XXXX&msg=<json>
  421. // Useful for AGI scripts that cannot hold a WebSocket connection.
  422. router.HandleFunc("/api/arozcast/publish", func(w http.ResponseWriter, r *http.Request) {
  423. code, err := utils.PostPara(r, "code")
  424. if err != nil {
  425. utils.SendErrorResponse(w, "Missing code")
  426. return
  427. }
  428. msg, err := utils.PostPara(r, "msg")
  429. if err != nil {
  430. utils.SendErrorResponse(w, "Missing msg")
  431. return
  432. }
  433. mgr.mu.RLock()
  434. room, exists := mgr.rooms[code]
  435. mgr.mu.RUnlock()
  436. if !exists {
  437. utils.SendErrorResponse(w, "Room not found")
  438. return
  439. }
  440. room.broadcast([]byte(msg), nil)
  441. utils.SendOK(w)
  442. })
  443. // WebSocket: GET /api/arozcast/ws?code=XXXX
  444. // Each frame sent by a client is relayed to all OTHER clients in the room.
  445. router.HandleFunc("/api/arozcast/ws", func(w http.ResponseWriter, r *http.Request) {
  446. code, err := utils.GetPara(r, "code")
  447. if err != nil {
  448. http.Error(w, "Missing code", http.StatusBadRequest)
  449. return
  450. }
  451. mgr.mu.RLock()
  452. room, exists := mgr.rooms[code]
  453. mgr.mu.RUnlock()
  454. if !exists {
  455. http.Error(w, "Room not found", http.StatusNotFound)
  456. return
  457. }
  458. conn, err := acUpgrader.Upgrade(w, r, nil)
  459. if err != nil {
  460. return
  461. }
  462. client := &acClient{
  463. conn: conn,
  464. send: make(chan []byte, 128),
  465. }
  466. room.add(client)
  467. // Writer goroutine: drains client.send and writes to the socket.
  468. // When the send channel is closed (safeClose), the goroutine delivers
  469. // any queued messages (e.g. roomClosedMsg) before closing the connection.
  470. go func() {
  471. defer conn.Close()
  472. for msg := range client.send {
  473. if err := conn.WriteMessage(websocket.TextMessage, msg); err != nil {
  474. return
  475. }
  476. }
  477. }()
  478. // Reader loop: relay incoming frames to all other room members.
  479. // Also inspects each frame: if it is a 'status.update' from the
  480. // receiver, refresh lastStatusUpdate so the sweep goroutine knows
  481. // the receiver is still alive.
  482. defer func() {
  483. room.remove(client)
  484. client.safeClose()
  485. }()
  486. var topicCheck struct {
  487. Topic string `json:"topic"`
  488. }
  489. for {
  490. _, msg, err := conn.ReadMessage()
  491. if err != nil {
  492. break
  493. }
  494. if json.Unmarshal(msg, &topicCheck) == nil && topicCheck.Topic == "status.update" {
  495. room.mu.Lock()
  496. room.lastStatusUpdate = time.Now()
  497. room.mu.Unlock()
  498. }
  499. room.broadcast(msg, client)
  500. }
  501. })
  502. }
  503. // acTurnStatusInfo is the JSON status of the built-in TURN relay shown on the
  504. // admin System Settings page.
  505. type acTurnStatusInfo struct {
  506. Enabled bool `json:"enabled"` // persisted on/off preference
  507. Running bool `json:"running"` // relay actually running right now
  508. Port int `json:"port"` // UDP/TCP relay port
  509. TLS bool `json:"tls"` // TURN-over-TLS (TURNS) listener active
  510. TLSPort int `json:"tlsPort"` // TURNS port
  511. PublicIP string `json:"publicIP"` // configured advertise host ("" = auto-detect)
  512. AdvertiseHost string `json:"advertiseHost"` // host actually advertised ("" = derived per request)
  513. }
  514. // acTurnStatus snapshots the built-in TURN relay's configuration and live state.
  515. func acTurnStatus() acTurnStatusInfo {
  516. info := acTurnStatusInfo{
  517. Enabled: arozcastTurnEnabledPref(),
  518. Port: arozcastTurnConfig.ListenPort,
  519. TLSPort: arozcastTurnConfig.TLSPort,
  520. PublicIP: strings.TrimSpace(arozcastTurnConfig.PublicIP),
  521. }
  522. if ts := arozcastGetTurnServer(); ts != nil {
  523. info.Running = true
  524. info.AdvertiseHost = ts.AdvertiseHost()
  525. info.TLS = ts.TLSEnabled()
  526. info.TLSPort = ts.TLSPort()
  527. }
  528. return info
  529. }
  530. // acBuildICEConfig assembles the ICE server list returned to the browser for
  531. // WebRTC screen share. Order of precedence:
  532. // 1. An operator override file (system/arozcast/iceservers.json), if valid.
  533. // 2. Public STUN servers plus the built-in TURN relay when it is running.
  534. // 3. Public STUN servers only (LAN screen share still works).
  535. func acBuildICEConfig(r *http.Request, identity string) acICEConfig {
  536. if servers, ok := acLoadICEOverride(); ok {
  537. return acICEConfig{ICEServers: servers}
  538. }
  539. servers := make([]acICEServer, len(acDefaultSTUNServers))
  540. copy(servers, acDefaultSTUNServers)
  541. if ts := arozcastGetTurnServer(); ts != nil {
  542. host := ts.AdvertiseHost()
  543. if host == "" {
  544. host = acDeriveTURNHost(r)
  545. }
  546. if host != "" {
  547. base := net.JoinHostPort(host, strconv.Itoa(ts.ListenPort()))
  548. urls := []string{
  549. "turn:" + base + "?transport=udp",
  550. "turn:" + base + "?transport=tcp",
  551. }
  552. // TURN-over-TLS rides on TCP only; advertise it so clients behind
  553. // TLS-only firewalls can still relay.
  554. if ts.TLSEnabled() {
  555. tlsBase := net.JoinHostPort(host, strconv.Itoa(ts.TLSPort()))
  556. urls = append(urls, "turns:"+tlsBase+"?transport=tcp")
  557. }
  558. username, credential := ts.Credentials(identity)
  559. servers = append(servers, acICEServer{
  560. URLs: urls,
  561. Username: username,
  562. Credential: credential,
  563. })
  564. }
  565. }
  566. return acICEConfig{ICEServers: servers}
  567. }
  568. // acLoadICEOverride reads the optional operator override file. It returns
  569. // ok=false when the file is absent, unreadable, malformed, or empty.
  570. func acLoadICEOverride() ([]acICEServer, bool) {
  571. data, err := os.ReadFile(arozcastICEOverrideFile)
  572. if err != nil {
  573. return nil, false
  574. }
  575. var parsed acICEConfig
  576. if err := json.Unmarshal(data, &parsed); err != nil {
  577. systemWideLogger.PrintAndLog("Arozcast", "Ignoring malformed "+arozcastICEOverrideFile, err)
  578. return nil, false
  579. }
  580. if len(parsed.ICEServers) == 0 {
  581. return nil, false
  582. }
  583. return parsed.ICEServers, true
  584. }
  585. // acDeriveTURNHost determines the host clients should dial for the TURN relay,
  586. // using the same host the client used to reach ArozOS (honouring a reverse
  587. // proxy's X-Forwarded-Host) so it stays reachable. The port is stripped — the
  588. // relay listens on its own port.
  589. func acDeriveTURNHost(r *http.Request) string {
  590. host := r.Host
  591. if xfh := r.Header.Get("X-Forwarded-Host"); xfh != "" {
  592. if idx := strings.Index(xfh, ","); idx >= 0 {
  593. xfh = xfh[:idx] // first entry is the original client-facing host
  594. }
  595. host = strings.TrimSpace(xfh)
  596. }
  597. if h, _, err := net.SplitHostPort(host); err == nil {
  598. host = h
  599. }
  600. return host
  601. }