Add LAN-reachable mode to control surface and OSC bridge
Some checks failed
CI / build-and-test (push) Failing after 27s

When the new ControlSurfaceLanReachable preference is on, both the REST/WebSocket control surface and the OSC bridge bind to all interfaces (http://+:port/ via HttpListener wildcard, IPAddress.Any for OSC) instead of loopback. The settings VM persists the toggle, restarts both surfaces when flipped, and surfaces a ControlSurfaceUrl computed from the first non-loopback IPv4 + a Copy button so operators can paste the URL onto a control PC.

Use case: a headless host PC runs Teams + TeamsISO; a thin client on the same LAN drives it via /ui or a Stream Deck. Closed-network deployment, no auth — documented as a trusted-LAN-only mode in docs/CONTROL-SURFACE.md, including the one-time 'netsh http add urlacl url=http://+:9755/ user=Everyone' requirement and the firewall rule.
This commit is contained in:
Zac Gaetano 2026-05-10 10:01:32 -04:00
parent 63bd93d0c2
commit 6d9407a61f
6 changed files with 224 additions and 44 deletions

View file

@ -9,18 +9,42 @@ node-RED flows, command-line scripts) can drive it without a UI binding.
1. Open TeamsISO → Settings → DISPLAY tab. 1. Open TeamsISO → Settings → DISPLAY tab.
2. Tick "Control surface (Stream Deck / Companion)". 2. Tick "Control surface (Stream Deck / Companion)".
3. Default port is **9755**; change it via the port textbox if needed. 3. Default port is **9755**; change it via the port textbox if needed.
4. The server binds to `127.0.0.1` only — it is NOT reachable from the LAN. 4. By default the server binds to `127.0.0.1` only — it is NOT reachable
If you need LAN access (e.g. a Stream Deck on a separate control PC), from the LAN.
front it with `ssh -L 9755:127.0.0.1:9755` or a localhost TCP bridge. 5. To allow other machines on the same network to drive TeamsISO (the
"headless host PC + thin client" scenario), tick the nested
"LAN-reachable" checkbox underneath. The settings panel will display
the LAN URL (e.g. `http://192.168.1.42:9755/ui`) with a Copy button.
When enabled, the toast confirms `Control surface listening on When enabled, the toast confirms `Control surface listening on
http://127.0.0.1:9755/`. http://127.0.0.1:9755/` (or the all-interfaces equivalent in LAN mode).
### One-time setup for LAN-reachable mode
Windows requires elevated permission to bind a non-loopback HTTP listener.
If you turn on LAN-reachable mode and don't see a connection from another
machine, run this **once** in an Administrator PowerShell (replace `9755`
if you've changed the port):
```powershell
netsh http add urlacl url=http://+:9755/ user=Everyone
```
Also confirm the Windows Firewall is letting inbound traffic to that port
through — `New-NetFirewallRule -DisplayName "TeamsISO Control Surface" -Direction Inbound -Protocol TCP -LocalPort 9755 -Action Allow`
in an elevated PowerShell, or add it through Windows Defender Firewall →
Advanced Settings → Inbound Rules.
## Authentication ## Authentication
None. The localhost-only bind is the security model. Any process on the None — by design. In localhost-only mode, the loopback bind is the
operator's machine can hit these endpoints, which is the same threat model security model: any process on the operator's machine can hit these
as a Stream Deck's USB connection. endpoints, the same threat model as a Stream Deck's USB connection.
In LAN-reachable mode, the assumption is a closed/trusted network (a
production-control LAN, a dedicated show subnet, a private vlan). Any
machine that can route to the host on the listener port can drive
TeamsISO. **Do not enable LAN-reachable mode on an untrusted network.**
## Response shape ## Response shape
@ -222,7 +246,9 @@ Client→server messages are ignored for v1 — all commands go through REST.
Same command surface, different transport. Enable the OSC bridge in the Same command surface, different transport. Enable the OSC bridge in the
DISPLAY tab (default port **9000** — TouchOSC's default). Bound to DISPLAY tab (default port **9000** — TouchOSC's default). Bound to
`127.0.0.1` only. `127.0.0.1` by default; honors the same LAN-reachable toggle as the REST
surface — when LAN mode is on, OSC binds to `0.0.0.0` so a TouchOSC tablet
on the same network can talk to the host directly.
Address vocabulary: Address vocabulary:
@ -266,11 +292,7 @@ on the appropriate endpoint above.
## Future work ## Future work
- **OSC bridge** over UDP 9000 with `/teamsiso/iso {id} {0|1}` etc. — same - **HTTPS / token auth** — for deployments that don't have a closed
command surface, different transport. Adapter sits in front of the REST network, layer TLS termination + a shared bearer token in front of the
handlers. HttpListener. Out of scope for v1; the LAN-reachable mode is a
- **Bidirectional state** via WebSocket — push `participants` updates so trusted-network feature only.
controllers can light a button when an ISO is live without polling.
- **REST apply-preset** — duplicate the dialog's apply logic into
`IIsoController.ApplyPreset(name)` so the `/presets/{name}/apply`
endpoint becomes a real action.

View file

@ -1235,9 +1235,34 @@
<Separator Margin="0,16,0,8"/> <Separator Margin="0,16,0,8"/>
<CheckBox Content="Control surface (Stream Deck / Companion)" <CheckBox Content="Control surface (Stream Deck / Companion / web)"
IsChecked="{Binding Settings.ControlSurfaceEnabled}" IsChecked="{Binding Settings.ControlSurfaceEnabled}"
ToolTip="Start a localhost-only HTTP server so external controllers (Bitfocus Companion, Stream Deck plugins, Bome MIDI Translator) can drive TeamsISO. Bound to 127.0.0.1; not reachable from LAN."/> ToolTip="Start an HTTP server so external controllers (Bitfocus Companion, Stream Deck plugins, Bome MIDI Translator) or a thin-client browser can drive TeamsISO."/>
<CheckBox Content="LAN-reachable (allow other machines on your network)"
IsChecked="{Binding Settings.ControlSurfaceLanReachable}"
Margin="20,4,0,0"
ToolTip="When checked, the control surface binds to all interfaces (0.0.0.0) instead of 127.0.0.1, so a phone or laptop on your LAN can drive TeamsISO. First time you turn this on, run this in an Administrator PowerShell once: netsh http add urlacl url=http://+:9755/ user=Everyone"/>
<Grid Margin="20,4,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0"
Text="{Binding Settings.ControlSurfaceUrl}"
Style="{StaticResource Wd.Text.Mono}"
FontSize="11"
Foreground="{DynamicResource Wd.Accent.Cyan}"
VerticalAlignment="Center"/>
<Button Grid.Column="1"
Style="{StaticResource Wd.Button.Ghost}"
Content="Copy"
Command="{Binding Settings.CopyControlSurfaceUrlCommand}"
Padding="10,2"
FontSize="11"
ToolTip="Copy this URL to the clipboard so you can paste it into a phone browser or controller."/>
</Grid>
<Grid Margin="0,8,0,0"> <Grid Margin="0,8,0,0">
<Grid.ColumnDefinitions> <Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/> <ColumnDefinition Width="Auto"/>

View file

@ -59,6 +59,8 @@ public sealed class ControlSurfaceServer : IAsyncDisposable
public bool IsRunning { get; private set; } public bool IsRunning { get; private set; }
public int Port { get; private set; } = DefaultPort; public int Port { get; private set; } = DefaultPort;
/// <summary>True when the listener is bound to all interfaces (LAN-reachable) rather than just 127.0.0.1.</summary>
public bool BoundToLan { get; private set; }
/// <summary> /// <summary>
/// JSON serializer options shared across all responses. Camel-case property /// JSON serializer options shared across all responses. Camel-case property
@ -81,24 +83,45 @@ public sealed class ControlSurfaceServer : IAsyncDisposable
} }
/// <summary> /// <summary>
/// Start listening on the given port. Idempotent: if already running on a /// Start listening on the given port. Idempotent: if already running on the
/// different port, stop + restart on the new one. /// same (port, bindToLan) combination, no-op; otherwise stop + restart.
/// </summary> /// </summary>
public void Start(int port) /// <param name="port">TCP port to listen on.</param>
/// <param name="bindToLan">
/// When true, binds to all interfaces (<c>http://+:port/</c>) so other
/// machines on the LAN can reach the control surface — typical for
/// "headless show machine + thin client controller" setups. When false
/// (default), binds to <c>127.0.0.1</c> only.
///
/// LAN binding requires either running TeamsISO as Administrator OR a
/// one-time URL ACL reservation at the OS level:
/// <code>netsh http add urlacl url=http://+:9755/ user=Everyone</code>
/// If neither is in place the listener throws AccessDeniedException
/// which we catch and surface as a logger warning.
/// </summary>
public void Start(int port, bool bindToLan = false)
{ {
if (IsRunning && Port == port) return; if (IsRunning && Port == port && BoundToLan == bindToLan) return;
Stop(); Stop();
Port = port; Port = port;
BoundToLan = bindToLan;
_listener = new HttpListener(); _listener = new HttpListener();
_listener.Prefixes.Add($"http://127.0.0.1:{port}/"); var prefix = bindToLan
? $"http://+:{port}/"
: $"http://127.0.0.1:{port}/";
_listener.Prefixes.Add(prefix);
try try
{ {
_listener.Start(); _listener.Start();
} }
catch (HttpListenerException ex) catch (HttpListenerException ex)
{ {
_logger?.LogWarning(ex, "Could not start control surface on port {Port}.", port); _logger?.LogWarning(ex,
"Could not start control surface on {Prefix}. " +
"If binding to LAN, run as Administrator once OR run: " +
"netsh http add urlacl url=http://+:{Port}/ user=Everyone",
prefix, port);
_listener = null; _listener = null;
return; return;
} }
@ -122,7 +145,7 @@ public sealed class ControlSurfaceServer : IAsyncDisposable
} }
IsRunning = true; IsRunning = true;
_logger?.LogInformation("Control surface listening on http://127.0.0.1:{Port}/ (REST + ws)", port); _logger?.LogInformation("Control surface listening on {Prefix} (REST + ws)", prefix);
} }
public void Stop() public void Stop()

View file

@ -48,6 +48,8 @@ public sealed class OscBridge : IAsyncDisposable
public bool IsRunning { get; private set; } public bool IsRunning { get; private set; }
public int Port { get; private set; } = DefaultPort; public int Port { get; private set; } = DefaultPort;
/// <summary>True when the listener is bound to all interfaces rather than just loopback.</summary>
public bool BoundToLan { get; private set; }
public OscBridge( public OscBridge(
IIsoController controller, IIsoController controller,
@ -59,27 +61,35 @@ public sealed class OscBridge : IAsyncDisposable
_logger = logger; _logger = logger;
} }
public void Start(int port) /// <summary>
/// Start the OSC listener on the given UDP port. <paramref name="bindToLan"/>
/// flag selects between loopback (default — only this machine) and any-
/// interface binding (LAN-reachable, for thin-client controllers).
/// Unlike the REST surface, UDP doesn't need a URL ACL — binding 0.0.0.0
/// is just an unprivileged port reservation.
/// </summary>
public void Start(int port, bool bindToLan = false)
{ {
if (IsRunning && Port == port) return; if (IsRunning && Port == port && BoundToLan == bindToLan) return;
Stop(); Stop();
Port = port; Port = port;
BoundToLan = bindToLan;
var bindAddr = bindToLan ? IPAddress.Any : IPAddress.Loopback;
try try
{ {
// Bind to loopback only — same threat model as the REST surface. _udp = new UdpClient(new IPEndPoint(bindAddr, port));
_udp = new UdpClient(new IPEndPoint(IPAddress.Loopback, port));
} }
catch (SocketException ex) catch (SocketException ex)
{ {
_logger?.LogWarning(ex, "Could not bind OSC bridge to udp://127.0.0.1:{Port}.", port); _logger?.LogWarning(ex, "Could not bind OSC bridge to udp://{Addr}:{Port}.", bindAddr, port);
_udp = null; _udp = null;
return; return;
} }
_cts = new CancellationTokenSource(); _cts = new CancellationTokenSource();
_receiveTask = Task.Run(() => ReceiveLoopAsync(_cts.Token)); _receiveTask = Task.Run(() => ReceiveLoopAsync(_cts.Token));
IsRunning = true; IsRunning = true;
_logger?.LogInformation("OSC bridge listening on udp://127.0.0.1:{Port}/", port); _logger?.LogInformation("OSC bridge listening on udp://{Addr}:{Port}/", bindAddr, port);
} }
public void Stop() public void Stop()

View file

@ -38,7 +38,8 @@ public static class UIPreferences
bool HideLocalSelf = true, bool HideLocalSelf = true,
bool AutoDisableOnDeparture = false, bool AutoDisableOnDeparture = false,
SortMode ParticipantSort = SortMode.JoinOrder, SortMode ParticipantSort = SortMode.JoinOrder,
bool MinimizeToTray = false); bool MinimizeToTray = false,
bool ControlSurfaceLanReachable = false);
public static Prefs Load() public static Prefs Load()
{ {

View file

@ -33,6 +33,7 @@ public sealed class GlobalSettingsViewModel : ObservableObject
private string _outputNameTemplate = TeamsISO.App.Services.OutputNameTemplate.Get(); private string _outputNameTemplate = TeamsISO.App.Services.OutputNameTemplate.Get();
private UIPreferences.SortMode _participantSort = UIPreferences.SortMode.JoinOrder; private UIPreferences.SortMode _participantSort = UIPreferences.SortMode.JoinOrder;
private bool _minimizeToTray; private bool _minimizeToTray;
private bool _controlSurfaceLanReachable;
public GlobalSettingsViewModel(IIsoController controller, ToastViewModel? toast = null) public GlobalSettingsViewModel(IIsoController controller, ToastViewModel? toast = null)
{ {
@ -57,6 +58,7 @@ public sealed class GlobalSettingsViewModel : ObservableObject
_autoDisableOnDeparture = uiPrefs.AutoDisableOnDeparture; _autoDisableOnDeparture = uiPrefs.AutoDisableOnDeparture;
_participantSort = uiPrefs.ParticipantSort; _participantSort = uiPrefs.ParticipantSort;
_minimizeToTray = uiPrefs.MinimizeToTray; _minimizeToTray = uiPrefs.MinimizeToTray;
_controlSurfaceLanReachable = uiPrefs.ControlSurfaceLanReachable;
// Bring the auto-apply flag in from the presets store so the checkbox // Bring the auto-apply flag in from the presets store so the checkbox
// reflects the user's prior choice when the settings panel opens. // reflects the user's prior choice when the settings panel opens.
@ -77,6 +79,18 @@ public sealed class GlobalSettingsViewModel : ObservableObject
ApplyCommand = new AsyncRelayCommand(ApplyAsync); ApplyCommand = new AsyncRelayCommand(ApplyAsync);
ApplyTranscoderTopologyCommand = new AsyncRelayCommand(ApplyTranscoderTopologyAsync); ApplyTranscoderTopologyCommand = new AsyncRelayCommand(ApplyTranscoderTopologyAsync);
ResetOutputDefaultsCommand = new RelayCommand(ResetOutputDefaults); ResetOutputDefaultsCommand = new RelayCommand(ResetOutputDefaults);
CopyControlSurfaceUrlCommand = new RelayCommand(() =>
{
try
{
System.Windows.Clipboard.SetText(ControlSurfaceUrl);
_toast?.Show($"Copied: {ControlSurfaceUrl}");
}
catch
{
// Clipboard occasionally errors when something else has it locked.
}
});
} }
private void ResetOutputDefaults() private void ResetOutputDefaults()
@ -207,7 +221,8 @@ public sealed class GlobalSettingsViewModel : ObservableObject
HideLocalSelf: _hideLocalSelf, HideLocalSelf: _hideLocalSelf,
AutoDisableOnDeparture: _autoDisableOnDeparture, AutoDisableOnDeparture: _autoDisableOnDeparture,
ParticipantSort: _participantSort, ParticipantSort: _participantSort,
MinimizeToTray: _minimizeToTray)); MinimizeToTray: _minimizeToTray,
ControlSurfaceLanReachable: _controlSurfaceLanReachable));
/// <summary> /// <summary>
/// Record each newly-enabled ISO's normalized output to disk under /// Record each newly-enabled ISO's normalized output to disk under
@ -246,9 +261,9 @@ public sealed class GlobalSettingsViewModel : ObservableObject
} }
/// <summary> /// <summary>
/// REST control surface (localhost:port) — Stream Deck / Companion / OSC bridges /// REST control surface — Stream Deck / Companion / thin-client controllers.
/// can hit it. Off by default; bound to 127.0.0.1 so LAN access requires explicit /// Off by default. Bind address depends on <see cref="ControlSurfaceLanReachable"/>:
/// reconfiguration. Toggling reaches into App's owned ControlSurfaceServer. /// loopback (127.0.0.1) by default, or all-interfaces when LAN-reachable.
/// </summary> /// </summary>
public bool ControlSurfaceEnabled public bool ControlSurfaceEnabled
{ {
@ -258,10 +273,10 @@ public sealed class GlobalSettingsViewModel : ObservableObject
if (!SetField(ref _controlSurfaceEnabled, value)) return; if (!SetField(ref _controlSurfaceEnabled, value)) return;
var srv = (Application.Current as App)?.ControlSurface; var srv = (Application.Current as App)?.ControlSurface;
if (srv is null) return; if (srv is null) return;
if (value) srv.Start(_controlSurfacePort); if (value) srv.Start(_controlSurfacePort, _controlSurfaceLanReachable);
else srv.Stop(); else srv.Stop();
_toast?.Show(value _toast?.Show(value
? $"Control surface listening on http://127.0.0.1:{_controlSurfacePort}/" ? $"Control surface listening on {ControlSurfaceUrl}"
: "Control surface stopped"); : "Control surface stopped");
} }
} }
@ -276,13 +291,88 @@ public sealed class GlobalSettingsViewModel : ObservableObject
set set
{ {
if (!SetField(ref _controlSurfacePort, value)) return; if (!SetField(ref _controlSurfacePort, value)) return;
OnPropertyChanged(nameof(ControlSurfaceUrl));
if (!_controlSurfaceEnabled) return; if (!_controlSurfaceEnabled) return;
var srv = (Application.Current as App)?.ControlSurface; var srv = (Application.Current as App)?.ControlSurface;
srv?.Start(value); // Start is idempotent + handles port change srv?.Start(value, _controlSurfaceLanReachable);
_toast?.Show($"Control surface restarted on http://127.0.0.1:{value}/"); _toast?.Show($"Control surface restarted on {ControlSurfaceUrl}");
} }
} }
/// <summary>
/// LAN-reachable mode. When false (default), control surface binds to
/// 127.0.0.1 — only this machine. When true, binds to all interfaces so
/// a thin-client controller on a phone or another laptop can drive
/// TeamsISO. The OSC bridge follows suit if it's running.
///
/// Important: HttpListener requires either Administrator privilege OR a
/// one-time URL ACL reservation for non-loopback prefixes:
/// <code>netsh http add urlacl url=http://+:9755/ user=Everyone</code>
/// (run from an elevated PowerShell). Without that the listener throws
/// AccessDeniedException on Start; the failure surfaces as a logger
/// warning with the exact netsh command.
/// </summary>
public bool ControlSurfaceLanReachable
{
get => _controlSurfaceLanReachable;
set
{
if (!SetField(ref _controlSurfaceLanReachable, value)) return;
PersistUiPrefs();
OnPropertyChanged(nameof(ControlSurfaceUrl));
if (!_controlSurfaceEnabled) return;
var srv = (Application.Current as App)?.ControlSurface;
srv?.Start(_controlSurfacePort, value);
var osc = (Application.Current as App)?.OscBridge;
if (osc?.IsRunning == true) osc.Start(_oscBridgePort, value);
_toast?.Show(value
? $"Control surface now LAN-reachable: {ControlSurfaceUrl}"
: "Control surface now loopback-only");
}
}
/// <summary>
/// Friendly URL of the running surface, for the settings panel + status
/// bar tooltip. Resolves to the first non-loopback IPv4 address when
/// LAN-reachable; loopback otherwise. Computed on demand because the
/// LAN IP may change between settings opens (Wi-Fi swap, VPN connect).
/// </summary>
public string ControlSurfaceUrl
{
get
{
var host = _controlSurfaceLanReachable
? GetLanIPv4() ?? "127.0.0.1"
: "127.0.0.1";
return $"http://{host}:{_controlSurfacePort}/ui";
}
}
/// <summary>
/// Best-effort first IPv4 address that isn't loopback. Returns null if
/// no LAN interface is up. The first hit is good enough for the URL —
/// operators with multi-NIC setups can manually substitute.
/// </summary>
private static string? GetLanIPv4()
{
try
{
foreach (var ni in System.Net.NetworkInformation.NetworkInterface.GetAllNetworkInterfaces())
{
if (ni.OperationalStatus != System.Net.NetworkInformation.OperationalStatus.Up) continue;
if (ni.NetworkInterfaceType == System.Net.NetworkInformation.NetworkInterfaceType.Loopback) continue;
foreach (var ua in ni.GetIPProperties().UnicastAddresses)
{
if (ua.Address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork
&& !System.Net.IPAddress.IsLoopback(ua.Address))
return ua.Address.ToString();
}
}
}
catch { /* best-effort */ }
return null;
}
/// <summary> /// <summary>
/// OSC bridge over UDP — same command surface as the REST endpoints, /// OSC bridge over UDP — same command surface as the REST endpoints,
/// reachable from Companion / TouchOSC / lighting consoles. Off by default; /// reachable from Companion / TouchOSC / lighting consoles. Off by default;
@ -296,10 +386,11 @@ public sealed class GlobalSettingsViewModel : ObservableObject
if (!SetField(ref _oscBridgeEnabled, value)) return; if (!SetField(ref _oscBridgeEnabled, value)) return;
var bridge = (Application.Current as App)?.OscBridge; var bridge = (Application.Current as App)?.OscBridge;
if (bridge is null) return; if (bridge is null) return;
if (value) bridge.Start(_oscBridgePort); if (value) bridge.Start(_oscBridgePort, _controlSurfaceLanReachable);
else bridge.Stop(); else bridge.Stop();
var host = _controlSurfaceLanReachable ? "0.0.0.0" : "127.0.0.1";
_toast?.Show(value _toast?.Show(value
? $"OSC bridge listening on udp://127.0.0.1:{_oscBridgePort}/" ? $"OSC bridge listening on udp://{host}:{_oscBridgePort}/"
: "OSC bridge stopped"); : "OSC bridge stopped");
} }
} }
@ -313,8 +404,9 @@ public sealed class GlobalSettingsViewModel : ObservableObject
if (!SetField(ref _oscBridgePort, value)) return; if (!SetField(ref _oscBridgePort, value)) return;
if (!_oscBridgeEnabled) return; if (!_oscBridgeEnabled) return;
var bridge = (Application.Current as App)?.OscBridge; var bridge = (Application.Current as App)?.OscBridge;
bridge?.Start(value); bridge?.Start(value, _controlSurfaceLanReachable);
_toast?.Show($"OSC bridge restarted on udp://127.0.0.1:{value}/"); var host = _controlSurfaceLanReachable ? "0.0.0.0" : "127.0.0.1";
_toast?.Show($"OSC bridge restarted on udp://{host}:{value}/");
} }
} }
@ -381,6 +473,13 @@ public sealed class GlobalSettingsViewModel : ObservableObject
public AsyncRelayCommand ApplyCommand { get; } public AsyncRelayCommand ApplyCommand { get; }
/// <summary>
/// Copy the current control-surface URL to the clipboard. Operators on a
/// thin-client setup tap this, then paste into a phone browser. Bound to
/// a small button next to the LAN-reachable toggle.
/// </summary>
public RelayCommand CopyControlSurfaceUrlCommand { get; }
/// <summary> /// <summary>
/// Restore Output settings (framerate, resolution, aspect, audio) to the engine's /// Restore Output settings (framerate, resolution, aspect, audio) to the engine's
/// shipping defaults. Doesn't touch NDI groups (those are intentionally sticky — /// shipping defaults. Doesn't touch NDI groups (those are intentionally sticky —