Compare commits

...

4 Commits

Author SHA1 Message Date
Stefan Agner 78d4072049 Explain isolated network behavior on the app network card
Add an info alert to the isolated network access section covering what
users should know about the implementation: the app appears as a
separate device, IPv6 is automatic via SLAAC, the host and the app
cannot reach each other on the isolated network by design (Supervisor
communication like ingress is unaffected), and apps that introspect
host interfaces may behave differently.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 15:06:14 +02:00
Stefan Agner f2827d0ab6 Show the endpoint MAC address on the app network card
Display the saved endpoint MAC below the IP address field of the
isolated network access section, so users can set up router or
firewall rules right where they configure the endpoint.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 14:55:02 +02:00
Stefan Agner 22b83b20fc Show the MAC address of isolated app network endpoints
The Supervisor now reports network_isolation_mac, a stable MAC derived
from the static IP that is known as soon as isolation is configured.
Show it next to the IP on the app info tab, including while the app is
stopped, so users can create router or firewall rules before the first
start.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 14:06:46 +02:00
Stefan Agner 65f7d65462 Add isolated network access settings for apps
Apps that use host networking can now be switched to an isolated
network endpoint (macvlan) from the network card on the Configuration
tab, when the Supervisor reports network_isolation_available. The card
gains a toggle, an interface select populated from the
network_isolation_capable host interfaces, and an IPv4 address field.
Saving posts the network_isolation option and suggests an app restart,
same as port changes. The info tab shows the assigned IP while the app
is running.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 14:04:24 +02:00
6 changed files with 350 additions and 42 deletions
+13
View File
@@ -44,6 +44,15 @@ interface AddonTranslations {
configuration?: Record<string, AddonFieldTranslation>;
}
export interface AddonNetworkIsolationParams {
interface: string;
ipv4: string;
}
export interface AddonNetworkIsolation extends AddonNetworkIsolationParams {
driver: "macvlan";
}
export interface HassioAddonInfo {
advanced: boolean;
available: boolean;
@@ -100,6 +109,9 @@ export interface HassioAddonDetails extends HassioAddonInfo {
long_description: null | string;
machine: any;
network_description: null | Record<string, string>;
network_isolation: AddonNetworkIsolation | null;
network_isolation_available: boolean;
network_isolation_mac: string | null;
network: null | Record<string, number>;
options: Record<string, unknown>;
privileged: any;
@@ -143,6 +155,7 @@ export interface HassioAddonSetOptionParams {
auto_update?: boolean;
ingress_panel?: boolean;
network?: Record<string, unknown> | null;
network_isolation?: AddonNetworkIsolationParams | null;
watchdog?: boolean;
}
+1
View File
@@ -19,6 +19,7 @@ export interface NetworkInterface {
ipv6?: Partial<IpConfiguration>;
type: "ethernet" | "wireless" | "vlan";
wifi?: Partial<WifiConfiguration> | null;
network_isolation_capable?: boolean;
}
export interface DockerNetwork {
@@ -29,11 +29,13 @@ class SupervisorAppConfigDashboard extends LitElement {
const hasConfiguration =
(this.addon.options && Object.keys(this.addon.options).length) ||
(this.addon.schema && Object.keys(this.addon.schema).length);
const hasNetwork =
this.addon.network || this.addon.network_isolation_available;
return html`
<div class="content">
${this.addon.system_managed &&
(hasConfiguration || this.addon.network || this.addon.audio)
(hasConfiguration || hasNetwork || this.addon.audio)
? html`
<supervisor-app-system-managed
.hass=${this.hass}
@@ -42,7 +44,7 @@ class SupervisorAppConfigDashboard extends LitElement {
></supervisor-app-system-managed>
`
: nothing}
${hasConfiguration || this.addon.network || this.addon.audio
${hasConfiguration || hasNetwork || this.addon.audio
? html`
${hasConfiguration
? html`
@@ -54,7 +56,7 @@ class SupervisorAppConfigDashboard extends LitElement {
></supervisor-app-config>
`
: nothing}
${this.addon.network
${hasNetwork
? html`
<supervisor-app-network
.hass=${this.hass}
@@ -9,22 +9,44 @@ import "../../../../../components/ha-card";
import "../../../../../components/ha-form/ha-form";
import type { HaFormSchema } from "../../../../../components/ha-form/types";
import "../../../../../components/ha-formfield";
import "../../../../../components/ha-select";
import type { HaSelectSelectEvent } from "../../../../../components/ha-select";
import "../../../../../components/ha-switch";
import type { HaSwitch } from "../../../../../components/ha-switch";
import "../../../../../components/input/ha-input";
import type { HaInput } from "../../../../../components/input/ha-input";
import type {
AddonNetworkIsolationParams,
HassioAddonDetails,
HassioAddonSetOptionParams,
} from "../../../../../data/hassio/addon";
import { setHassioAddonOption } from "../../../../../data/hassio/addon";
import { extractApiErrorMessage } from "../../../../../data/hassio/common";
import type { NetworkInterface } from "../../../../../data/hassio/network";
import { fetchNetworkInfo } from "../../../../../data/hassio/network";
import { DirtyStateProviderMixin } from "../../../../../mixins/dirty-state-provider-mixin";
import { haStyle } from "../../../../../resources/styles";
import type { HomeAssistant } from "../../../../../types";
import { supervisorAppsStyle } from "../../resources/supervisor-apps-style";
import { suggestSupervisorAppRestart } from "../dialogs/suggestSupervisorAppRestart";
interface NetworkConfig {
ports: Record<string, number | null>;
isolation: AddonNetworkIsolationParams | null;
}
const isValidIpv4 = (address: string): boolean => {
const parts = address.split(".");
return (
parts.length === 4 &&
parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255)
);
};
@customElement("supervisor-app-network")
class SupervisorAppNetwork extends DirtyStateProviderMixin<
Record<string, number | null>
>()(LitElement) {
class SupervisorAppNetwork extends DirtyStateProviderMixin<NetworkConfig>()(
LitElement
) {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public addon!: HassioAddonDetails;
@@ -35,17 +57,19 @@ class SupervisorAppNetwork extends DirtyStateProviderMixin<
@state() private _error?: string;
@state() private _config?: Record<string, number | null>;
@state() private _config?: NetworkConfig;
@state() private _isolationInterfaces?: NetworkInterface[];
protected render() {
if (!this._config) {
return nothing;
}
const config = this._config;
const ports = this._config.ports;
const hasHiddenOptions = Object.keys(config).find(
(entry) => config[entry] === null
const hasHiddenOptions = Object.keys(ports).find(
(entry) => ports[entry] === null
);
return html`
@@ -56,23 +80,29 @@ class SupervisorAppNetwork extends DirtyStateProviderMixin<
)}
>
<div class="card-content">
<p>
${this.hass.localize(
"ui.panel.config.apps.configuration.network.introduction"
)}
</p>
${this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: nothing}
<ha-form
.disabled=${this.disabled}
.data=${this._config}
@value-changed=${this._configChanged}
.computeLabel=${this._computeLabel}
.computeHelper=${this._computeHelper}
.schema=${this._createSchema(this._config, this._showOptional)}
></ha-form>
${this.addon.network
? html`
<p>
${this.hass.localize(
"ui.panel.config.apps.configuration.network.introduction"
)}
</p>
<ha-form
.disabled=${this.disabled}
.data=${ports}
@value-changed=${this._configChanged}
.computeLabel=${this._computeLabel}
.computeHelper=${this._computeHelper}
.schema=${this._createSchema(ports, this._showOptional)}
></ha-form>
`
: nothing}
${this.addon.network_isolation_available
? this._renderIsolation()
: nothing}
</div>
${hasHiddenOptions
? html`<ha-formfield
@@ -110,21 +140,129 @@ class SupervisorAppNetwork extends DirtyStateProviderMixin<
`;
}
private _renderIsolation() {
const isolation = this._config!.isolation;
return html`
<div class="isolation">
<ha-formfield
.label=${this.hass.localize(
"ui.panel.config.apps.configuration.network.isolation.title"
)}
>
<ha-switch
.checked=${isolation !== null}
.disabled=${this.disabled}
@change=${this._isolationToggled}
></ha-switch>
</ha-formfield>
<p class="secondary">
${this.hass.localize(
"ui.panel.config.apps.configuration.network.isolation.description"
)}
</p>
${isolation
? html`
<ha-select
.label=${this.hass.localize(
"ui.panel.config.apps.configuration.network.isolation.interface"
)}
.value=${isolation.interface}
.disabled=${this.disabled}
.options=${(this._isolationInterfaces || []).map((iface) => ({
value: iface.interface,
label: iface.ipv4?.address?.length
? `${iface.interface} (${iface.ipv4.address.join(", ")})`
: iface.interface,
}))}
@selected=${this._isolationInterfaceChanged}
></ha-select>
<ha-input
.label=${this.hass.localize(
"ui.panel.config.apps.configuration.network.isolation.ip_address"
)}
.hint=${this.hass.localize(
"ui.panel.config.apps.configuration.network.isolation.ip_address_helper"
)}
.value=${isolation.ipv4}
.disabled=${this.disabled}
@change=${this._isolationAddressChanged}
></ha-input>
${this.addon.network_isolation_mac
? html`
<p class="mac">
<span class="secondary">
${this.hass.localize(
"ui.panel.config.apps.configuration.network.isolation.mac_address"
)}
</span>
<code>${this.addon.network_isolation_mac}</code>
</p>
`
: nothing}
<ha-alert alert-type="info">
<ul>
<li>
${this.hass.localize(
"ui.panel.config.apps.configuration.network.isolation.info.separate_device"
)}
</li>
<li>
${this.hass.localize(
"ui.panel.config.apps.configuration.network.isolation.info.ipv6"
)}
</li>
<li>
${this.hass.localize(
"ui.panel.config.apps.configuration.network.isolation.info.host_reachability"
)}
</li>
<li>
${this.hass.localize(
"ui.panel.config.apps.configuration.network.isolation.info.host_interfaces"
)}
</li>
</ul>
</ha-alert>
`
: nothing}
</div>
`;
}
protected willUpdate(changedProperties: PropertyValues<this>): void {
super.willUpdate(changedProperties);
if (changedProperties.has("addon")) {
this._setNetworkConfig();
if (
this.addon.network_isolation_available &&
this._isolationInterfaces === undefined
) {
this._loadIsolationInterfaces();
}
}
}
private async _loadIsolationInterfaces(): Promise<void> {
this._isolationInterfaces = [];
try {
const { interfaces } = await fetchNetworkInfo(this.hass);
this._isolationInterfaces = interfaces.filter(
(iface) => iface.network_isolation_capable
);
} catch (err: any) {
this._error = extractApiErrorMessage(err);
}
}
private _createSchema = memoizeOne(
(
config: Record<string, number | null>,
ports: Record<string, number | null>,
showOptional: boolean
): HaFormSchema[] =>
(showOptional
? Object.keys(config)
: Object.keys(config).filter((entry) => config[entry] !== null)
? Object.keys(ports)
: Object.keys(ports).filter((entry) => ports[entry] !== null)
).map((entry) => ({
name: entry,
selector: {
@@ -147,14 +285,58 @@ class SupervisorAppNetwork extends DirtyStateProviderMixin<
item.name;
private _setNetworkConfig(): void {
const config = this.addon.network || {};
const config: NetworkConfig = {
ports: this.addon.network || {},
isolation: this.addon.network_isolation
? {
interface: this.addon.network_isolation.interface,
ipv4: this.addon.network_isolation.ipv4,
}
: null,
};
this._config = config;
this._initDirtyTracking({ type: "shallow" }, config);
this._initDirtyTracking({ type: "deep" }, config);
}
private _configChanged(ev: CustomEvent): void {
this._config = ev.detail.value;
this._updateDirtyState(ev.detail.value);
this._config = { ...this._config!, ports: ev.detail.value };
this._updateDirtyState(this._config);
}
private _isolationToggled(ev: Event): void {
const enabled = (ev.target as HaSwitch).checked;
this._config = {
...this._config!,
isolation: enabled
? {
interface:
this.addon.network_isolation?.interface ||
this._isolationInterfaces?.[0]?.interface ||
"",
ipv4: this.addon.network_isolation?.ipv4 || "",
}
: null,
};
this._updateDirtyState(this._config);
}
private _isolationInterfaceChanged(ev: HaSelectSelectEvent): void {
this._config = {
...this._config!,
isolation: { ...this._config!.isolation!, interface: ev.detail.value },
};
this._updateDirtyState(this._config);
}
private _isolationAddressChanged(ev: Event): void {
this._config = {
...this._config!,
isolation: {
...this._config!.isolation!,
ipv4: (ev.target as HaInput).value || "",
},
};
this._updateDirtyState(this._config);
}
private async _resetTapped(ev: CustomEvent): Promise<void> {
@@ -163,9 +345,13 @@ class SupervisorAppNetwork extends DirtyStateProviderMixin<
}
const button = ev.currentTarget as any;
const data: HassioAddonSetOptionParams = {
network: null,
};
const data: HassioAddonSetOptionParams = {};
if (this.addon.network) {
data.network = null;
}
if (this.addon.network_isolation_available) {
data.network_isolation = null;
}
try {
await setHassioAddonOption(this.hass.callWS, this.addon.slug, data);
@@ -203,14 +389,36 @@ class SupervisorAppNetwork extends DirtyStateProviderMixin<
const button = ev.currentTarget as any;
this._error = undefined;
const networkconfiguration: Record<string, number | null> = {};
Object.entries(this._config!).forEach(([key, value]) => {
networkconfiguration[key] = value ?? null;
});
const { ports, isolation } = this._config!;
const data: HassioAddonSetOptionParams = {
network: networkconfiguration,
};
if (this.addon.network_isolation_available && isolation) {
if (!isolation.interface) {
this._error = this.hass.localize(
"ui.panel.config.apps.configuration.network.isolation.no_interface"
);
button.actionError();
return;
}
if (!isValidIpv4(isolation.ipv4)) {
this._error = this.hass.localize(
"ui.panel.config.apps.configuration.network.isolation.invalid_ip"
);
button.actionError();
return;
}
}
const data: HassioAddonSetOptionParams = {};
if (this.addon.network) {
const networkconfiguration: Record<string, number | null> = {};
Object.entries(ports).forEach(([key, value]) => {
networkconfiguration[key] = value ?? null;
});
data.network = networkconfiguration;
}
if (this.addon.network_isolation_available) {
data.network_isolation = isolation;
}
try {
await setHassioAddonOption(this.hass.callWS, this.addon.slug, data);
@@ -254,6 +462,36 @@ class SupervisorAppNetwork extends DirtyStateProviderMixin<
.show-optional {
padding: 16px;
}
ha-form + .isolation {
margin-top: var(--ha-space-6);
}
.isolation .secondary {
margin-top: var(--ha-space-1);
color: var(--secondary-text-color);
}
.isolation ha-select,
.isolation ha-input {
display: block;
width: 100%;
}
.isolation ha-input {
margin-top: var(--ha-space-4);
}
.isolation .mac {
margin-bottom: 0;
}
.isolation .mac code {
display: block;
margin-top: var(--ha-space-1);
}
.isolation ha-alert {
display: block;
margin-top: var(--ha-space-4);
}
.isolation ha-alert ul {
margin: 0;
padding-inline-start: var(--ha-space-4);
}
`,
];
}
@@ -832,6 +832,7 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
</span>
<code slot="headline"> ${this._currentAddon.hostname} </code>
</ha-row-item>
${this._renderNetworkIsolationRows()}
${metrics.map(
(metric) => html`
<supervisor-app-metric
@@ -842,11 +843,46 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
`
)}`
: nothing}
${this._currentAddon.version &&
this._currentAddon.state !== "started" &&
this._currentAddon.network_isolation
? html`<wa-divider></wa-divider>
${this._renderNetworkIsolationRows()}`
: nothing}
</div>
</ha-card>
`;
}
private _renderNetworkIsolationRows() {
const addon = this._currentAddon;
if (!addon.version || !addon.network_isolation) {
return nothing;
}
return html`
<ha-row-item>
<span slot="supporting-text">
${this.i18n.localize(
"ui.panel.config.apps.dashboard.network_isolation_ip"
)}
</span>
<code slot="headline"> ${addon.network_isolation.ipv4} </code>
</ha-row-item>
${addon.network_isolation_mac
? html`
<ha-row-item>
<span slot="supporting-text">
${this.i18n.localize(
"ui.panel.config.apps.dashboard.network_isolation_mac"
)}
</span>
<code slot="headline"> ${addon.network_isolation_mac} </code>
</ha-row-item>
`
: nothing}
`;
}
protected render(): TemplateResult {
return html`
${"protected" in this._currentAddon && !this._currentAddon.protected
+19 -1
View File
@@ -2904,6 +2904,8 @@
"current_version": "Current version: {version}",
"changelog": "Changelog",
"hostname": "Hostname",
"network_isolation_ip": "IP address on your network",
"network_isolation_mac": "MAC address on your network",
"visit_app_page": "Visit {name} page for more details.",
"start": "Start",
"stop": "Stop",
@@ -3078,7 +3080,23 @@
"header": "Network",
"introduction": "Configure the network ports that this app uses.",
"show_disabled": "Show disabled ports",
"reset_defaults": "Reset to defaults"
"reset_defaults": "Reset to defaults",
"isolation": {
"title": "Isolated network access",
"description": "Run this app with its own IP address on a selected network instead of sharing the host network. Ingress and the web UI link keep working as before.",
"interface": "Network interface",
"ip_address": "IP address",
"ip_address_helper": "Choose an address outside the DHCP range of your router, or reserve it in your router.",
"mac_address": "MAC address of this app on your network",
"no_interface": "Select a network interface to use isolated network access.",
"invalid_ip": "Enter a valid IPv4 address, like 192.168.1.50.",
"info": {
"separate_device": "The app joins the selected network as a separate device with its own IP and MAC address.",
"ipv6": "IPv6 needs no setup: the app automatically gets its IPv6 addresses from your network.",
"host_reachability": "Your Home Assistant system and the app cannot reach each other through their addresses on the selected network. This is part of the isolation — ingress, the web UI link, and other Home Assistant features are not affected.",
"host_interfaces": "Apps that need access to all network interfaces of your system may not work as expected with isolated network access."
}
}
},
"audio": {
"header": "Audio",