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.
2. Tick "Control surface (Stream Deck / Companion)".
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.
If you need LAN access (e.g. a Stream Deck on a separate control PC),
front it with `ssh -L 9755:127.0.0.1:9755` or a localhost TCP bridge.
4. By default the server binds to `127.0.0.1` only — it is NOT reachable
from the LAN.
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
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
None. The localhost-only bind is the security model. Any process on the
operator's machine can hit these endpoints, which is the same threat model
as a Stream Deck's USB connection.
None — by design. In localhost-only mode, the loopback bind is the
security model: any process on the operator's machine can hit these
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
@ -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
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:
@ -266,11 +292,7 @@ on the appropriate endpoint above.
## Future work
- **OSC bridge** over UDP 9000 with `/teamsiso/iso {id} {0|1}` etc. — same
command surface, different transport. Adapter sits in front of the REST
handlers.
- **Bidirectional state** via WebSocket — push `participants` updates so
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.
- **HTTPS / token auth** — for deployments that don't have a closed
network, layer TLS termination + a shared bearer token in front of the
HttpListener. Out of scope for v1; the LAN-reachable mode is a
trusted-network feature only.

View file

@ -1235,9 +1235,34 @@
<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}"
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.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>

View file

@ -59,6 +59,8 @@ public sealed class ControlSurfaceServer : IAsyncDisposable
public bool IsRunning { get; private set; }
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>
/// JSON serializer options shared across all responses. Camel-case property
@ -81,24 +83,45 @@ public sealed class ControlSurfaceServer : IAsyncDisposable
}
/// <summary>
/// Start listening on the given port. Idempotent: if already running on a
/// different port, stop + restart on the new one.
/// Start listening on the given port. Idempotent: if already running on the
/// same (port, bindToLan) combination, no-op; otherwise stop + restart.
/// </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();
Port = port;
BoundToLan = bindToLan;
_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
{
_listener.Start();
}
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;
return;
}
@ -122,7 +145,7 @@ public sealed class ControlSurfaceServer : IAsyncDisposable
}
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()

View file

@ -48,6 +48,8 @@ public sealed class OscBridge : IAsyncDisposable
public bool IsRunning { get; private set; }
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(
IIsoController controller,
@ -59,27 +61,35 @@ public sealed class OscBridge : IAsyncDisposable
_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();
Port = port;
BoundToLan = bindToLan;
var bindAddr = bindToLan ? IPAddress.Any : IPAddress.Loopback;
try
{
// Bind to loopback only — same threat model as the REST surface.
_udp = new UdpClient(new IPEndPoint(IPAddress.Loopback, port));
_udp = new UdpClient(new IPEndPoint(bindAddr, port));
}
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;
return;
}
_cts = new CancellationTokenSource();
_receiveTask = Task.Run(() => ReceiveLoopAsync(_cts.Token));
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()

View file

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

View file

@ -33,6 +33,7 @@ public sealed class GlobalSettingsViewModel : ObservableObject
private string _outputNameTemplate = TeamsISO.App.Services.OutputNameTemplate.Get();
private UIPreferences.SortMode _participantSort = UIPreferences.SortMode.JoinOrder;
private bool _minimizeToTray;
private bool _controlSurfaceLanReachable;
public GlobalSettingsViewModel(IIsoController controller, ToastViewModel? toast = null)
{
@ -57,6 +58,7 @@ public sealed class GlobalSettingsViewModel : ObservableObject
_autoDisableOnDeparture = uiPrefs.AutoDisableOnDeparture;
_participantSort = uiPrefs.ParticipantSort;
_minimizeToTray = uiPrefs.MinimizeToTray;
_controlSurfaceLanReachable = uiPrefs.ControlSurfaceLanReachable;
// Bring the auto-apply flag in from the presets store so the checkbox
// reflects the user's prior choice when the settings panel opens.
@ -77,6 +79,18 @@ public sealed class GlobalSettingsViewModel : ObservableObject
ApplyCommand = new AsyncRelayCommand(ApplyAsync);
ApplyTranscoderTopologyCommand = new AsyncRelayCommand(ApplyTranscoderTopologyAsync);
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()
@ -207,7 +221,8 @@ public sealed class GlobalSettingsViewModel : ObservableObject
HideLocalSelf: _hideLocalSelf,
AutoDisableOnDeparture: _autoDisableOnDeparture,
ParticipantSort: _participantSort,
MinimizeToTray: _minimizeToTray));
MinimizeToTray: _minimizeToTray,
ControlSurfaceLanReachable: _controlSurfaceLanReachable));
/// <summary>
/// Record each newly-enabled ISO's normalized output to disk under
@ -246,9 +261,9 @@ public sealed class GlobalSettingsViewModel : ObservableObject
}
/// <summary>
/// REST control surface (localhost:port) — Stream Deck / Companion / OSC bridges
/// can hit it. Off by default; bound to 127.0.0.1 so LAN access requires explicit
/// reconfiguration. Toggling reaches into App's owned ControlSurfaceServer.
/// REST control surface — Stream Deck / Companion / thin-client controllers.
/// Off by default. Bind address depends on <see cref="ControlSurfaceLanReachable"/>:
/// loopback (127.0.0.1) by default, or all-interfaces when LAN-reachable.
/// </summary>
public bool ControlSurfaceEnabled
{
@ -258,10 +273,10 @@ public sealed class GlobalSettingsViewModel : ObservableObject
if (!SetField(ref _controlSurfaceEnabled, value)) return;
var srv = (Application.Current as App)?.ControlSurface;
if (srv is null) return;
if (value) srv.Start(_controlSurfacePort);
if (value) srv.Start(_controlSurfacePort, _controlSurfaceLanReachable);
else srv.Stop();
_toast?.Show(value
? $"Control surface listening on http://127.0.0.1:{_controlSurfacePort}/"
? $"Control surface listening on {ControlSurfaceUrl}"
: "Control surface stopped");
}
}
@ -276,13 +291,88 @@ public sealed class GlobalSettingsViewModel : ObservableObject
set
{
if (!SetField(ref _controlSurfacePort, value)) return;
OnPropertyChanged(nameof(ControlSurfaceUrl));
if (!_controlSurfaceEnabled) return;
var srv = (Application.Current as App)?.ControlSurface;
srv?.Start(value); // Start is idempotent + handles port change
_toast?.Show($"Control surface restarted on http://127.0.0.1:{value}/");
srv?.Start(value, _controlSurfaceLanReachable);
_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>
/// OSC bridge over UDP — same command surface as the REST endpoints,
/// reachable from Companion / TouchOSC / lighting consoles. Off by default;
@ -296,10 +386,11 @@ public sealed class GlobalSettingsViewModel : ObservableObject
if (!SetField(ref _oscBridgeEnabled, value)) return;
var bridge = (Application.Current as App)?.OscBridge;
if (bridge is null) return;
if (value) bridge.Start(_oscBridgePort);
if (value) bridge.Start(_oscBridgePort, _controlSurfaceLanReachable);
else bridge.Stop();
var host = _controlSurfaceLanReachable ? "0.0.0.0" : "127.0.0.1";
_toast?.Show(value
? $"OSC bridge listening on udp://127.0.0.1:{_oscBridgePort}/"
? $"OSC bridge listening on udp://{host}:{_oscBridgePort}/"
: "OSC bridge stopped");
}
}
@ -313,8 +404,9 @@ public sealed class GlobalSettingsViewModel : ObservableObject
if (!SetField(ref _oscBridgePort, value)) return;
if (!_oscBridgeEnabled) return;
var bridge = (Application.Current as App)?.OscBridge;
bridge?.Start(value);
_toast?.Show($"OSC bridge restarted on udp://127.0.0.1:{value}/");
bridge?.Start(value, _controlSurfaceLanReachable);
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; }
/// <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>
/// Restore Output settings (framerate, resolution, aspect, audio) to the engine's
/// shipping defaults. Doesn't touch NDI groups (those are intentionally sticky —