Skip to content

Commit 9323e47

Browse files
committed
feat(sites): load bind addresses from host interfaces
1 parent c6cbae0 commit 9323e47

11 files changed

Lines changed: 304 additions & 28 deletions

File tree

AGENTS.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,9 @@ Recent history follows Conventional Commits, for example `fix(cloudflare): local
3131

3232
## Agent Workflow Notes
3333

34-
Before cross-repo, infrastructure, CI, or architecture work, load MCP memory for WDC context and read `CLAUDE.md` if present; fall back to `C:\work\sources\CLAUDE.md`. The active master roadmap is `docs/plans/2026-05-04-wdc-master-refactor-redesign-plan.md`; related plans include `docs/plans/2026-05-04-ui-ux-refactor-redesign.md`, `docs/plans/2026-05-04-postgresql-platform-support.md`, and `docs/plans/2026-05-04-binaries-catalog-ci.md`. Do not run `tools/pre-push-check.sh` autonomously because prior WDC sessions observed interactive prompts; run the individual gates instead: `npx vue-tsc --noEmit`, `npx playwright test --reporter=line`, `npx electron-vite build`, plus relevant .NET tests. Do not start privileged daemon, hosts, firewall, certificate, or lifecycle checks as a normal local process; use CI or an approved noninteractive elevated path for admin-only verification.
34+
Before cross-repo, infrastructure, CI, or architecture work, load MCP memory for WDC context and read `CLAUDE.md` if present; fall back to `C:\work\sources\CLAUDE.md`. If a local roadmap exists under `docs/plans/`, read it before coding; do not recreate deleted plan artifacts unless explicitly requested. Do not run `tools/pre-push-check.sh` autonomously because prior WDC sessions observed interactive prompts; run the individual gates instead: `npx vue-tsc --noEmit`, `npx playwright test --reporter=line`, `npx electron-vite build`, plus relevant .NET tests. Do not start privileged daemon, hosts, firewall, certificate, or lifecycle checks as a normal local process; use CI or an approved noninteractive elevated path for admin-only verification.
35+
36+
For UI changes, capture Playwright screenshots for every changed screen/state and review layout, readability, contrast, responsiveness, and visual regressions before signoff. Store temporary screenshots under `output/playwright/` and do not commit them.
3537

3638
## Security & Configuration Tips
3739

src/daemon/NKS.WebDevConsole.Daemon/Program.cs

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
using System.Text.Json;
2+
using System.Net;
3+
using System.Net.NetworkInformation;
24
using Dapper;
35
using NKS.WebDevConsole.Daemon.Plugin;
46
using NKS.WebDevConsole.Daemon.Services;
@@ -5760,6 +5762,8 @@ await InvokeNodeMethodAsync(nodeModule, "StartSiteAsync",
57605762

57615763
app.MapGet("/api/sites", (SiteManager sm) => Results.Ok(sm.Sites.Values));
57625764

5765+
app.MapGet("/api/sites/bind-addresses", () => Results.Ok(GetBindAddressOptions()));
5766+
57635767
app.MapGet("/api/sites/{domain}", (string domain, SiteManager sm) =>
57645768
{
57655769
var site = sm.Get(domain);
@@ -10937,6 +10941,62 @@ await ws.SendAsync(new ArraySegment<byte>(bytes),
1093710941

1093810942
await app.RunAsync();
1093910943

10944+
static IReadOnlyList<BindAddressOption> GetBindAddressOptions()
10945+
{
10946+
var options = new List<BindAddressOption>
10947+
{
10948+
new("*", "*",
10949+
"Name-based virtual host on every Apache listener. Use only when hostnames are unique across all sites on the same ports.",
10950+
true, false, null),
10951+
new(IPAddress.Loopback.ToString(), IPAddress.Loopback.ToString(),
10952+
"Loopback-only. Good default for localhost and private local sites.",
10953+
false, true, "Loopback"),
10954+
new(IPAddress.IPv6Loopback.ToString(), IPAddress.IPv6Loopback.ToString(),
10955+
"IPv6 loopback-only. Use when the site should be reachable on ::1.",
10956+
false, true, "Loopback"),
10957+
};
10958+
var seen = new HashSet<string>(options.Select(o => o.Value), StringComparer.OrdinalIgnoreCase);
10959+
10960+
foreach (var ni in NetworkInterface.GetAllNetworkInterfaces()
10961+
.Where(i => i.OperationalStatus == OperationalStatus.Up))
10962+
{
10963+
IPInterfaceProperties props;
10964+
try { props = ni.GetIPProperties(); }
10965+
catch { continue; }
10966+
10967+
foreach (var unicast in props.UnicastAddresses)
10968+
{
10969+
var ip = unicast.Address;
10970+
if (ip.AddressFamily is not (System.Net.Sockets.AddressFamily.InterNetwork
10971+
or System.Net.Sockets.AddressFamily.InterNetworkV6))
10972+
continue;
10973+
if (ip.IsIPv6LinkLocal || ip.IsIPv6Multicast || IPAddress.IsLoopback(ip))
10974+
continue;
10975+
var value = ip.ToString();
10976+
if (!seen.Add(value))
10977+
continue;
10978+
10979+
options.Add(new BindAddressOption(
10980+
value,
10981+
$"{value} - {ni.Name}",
10982+
"Bind this site only on this device address. Requests on other addresses cannot fall through to this vhost.",
10983+
false,
10984+
false,
10985+
ni.Name));
10986+
}
10987+
}
10988+
10989+
return options;
10990+
}
10991+
10992+
record BindAddressOption(
10993+
string Value,
10994+
string Label,
10995+
string Description,
10996+
bool Wildcard,
10997+
bool Loopback,
10998+
string? InterfaceName);
10999+
1094011000
record GrantCreateBody(
1094111001
string ScopeType,
1094211002
string? ScopeValue,

src/daemon/NKS.WebDevConsole.Daemon/Sites/SiteManager.cs

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,36 @@ public static void ValidateBindAddress(string? bindAddress)
189189
throw new ArgumentException("BindAddress must be an IP address or '*'");
190190
}
191191

192+
public void ValidateBindConflicts(SiteConfig site, string? existingDomain = null)
193+
{
194+
var candidateHosts = HostnamesFor(site).ToArray();
195+
var candidateHttpPort = site.HttpPort > 0 ? site.HttpPort : 80;
196+
var candidateHttpsPort = site.HttpsPort > 0 ? site.HttpsPort : 443;
197+
foreach (var other in Sites.Values)
198+
{
199+
if (!other.Enabled)
200+
continue;
201+
if (!string.IsNullOrWhiteSpace(existingDomain)
202+
&& string.Equals(other.Domain, existingDomain, StringComparison.OrdinalIgnoreCase))
203+
continue;
204+
if (!BindScopesOverlap(site.BindAddress, other.BindAddress))
205+
continue;
206+
207+
var httpOverlaps = candidateHttpPort == (other.HttpPort > 0 ? other.HttpPort : 80);
208+
var httpsOverlaps = site.SslEnabled && other.SslEnabled
209+
&& candidateHttpsPort == (other.HttpsPort > 0 ? other.HttpsPort : 443);
210+
if (!httpOverlaps && !httpsOverlaps)
211+
continue;
212+
213+
var duplicateHost = candidateHosts.FirstOrDefault(h => HostnamesFor(other).Contains(h, StringComparer.OrdinalIgnoreCase));
214+
if (duplicateHost is not null)
215+
{
216+
throw new ArgumentException(
217+
$"Host '{duplicateHost}' already belongs to site '{other.Domain}' on an overlapping bind address and port.");
218+
}
219+
}
220+
}
221+
192222
public async Task<SiteConfig> CreateAsync(SiteConfig site)
193223
{
194224
ValidateDomain(site.Domain);
@@ -197,6 +227,7 @@ public async Task<SiteConfig> CreateAsync(SiteConfig site)
197227
if (site.Aliases is { Length: > 0 })
198228
foreach (var alias in site.Aliases)
199229
ValidateAlias(alias);
230+
ValidateBindConflicts(site);
200231

201232
var toml = TomlSerializer.Serialize(site);
202233

@@ -230,6 +261,7 @@ public async Task<SiteConfig> UpdateAsync(SiteConfig site)
230261
if (site.Aliases is { Length: > 0 })
231262
foreach (var alias in site.Aliases)
232263
ValidateAlias(alias);
264+
ValidateBindConflicts(site, site.Domain);
233265

234266
var toml = TomlSerializer.Serialize(site);
235267
var sitesRoot = Path.GetFullPath(_sitesDir);
@@ -445,6 +477,30 @@ public static string FormatApacheBindAddress(string? bindAddress)
445477
return value.Contains(':') ? $"[{value}]" : value;
446478
}
447479

480+
private static IEnumerable<string> HostnamesFor(SiteConfig site)
481+
{
482+
yield return site.Domain;
483+
foreach (var alias in NormalizeAliases(site.Domain, site.Aliases))
484+
yield return alias;
485+
}
486+
487+
private static bool BindScopesOverlap(string? left, string? right)
488+
{
489+
var a = NormalizeBindScope(left);
490+
var b = NormalizeBindScope(right);
491+
return a == "*" || b == "*" || string.Equals(a, b, StringComparison.OrdinalIgnoreCase);
492+
}
493+
494+
private static string NormalizeBindScope(string? bindAddress)
495+
{
496+
if (string.IsNullOrWhiteSpace(bindAddress) || bindAddress.Trim() == "*")
497+
return "*";
498+
var value = bindAddress.Trim();
499+
if (value.StartsWith('[') && value.EndsWith(']'))
500+
value = value[1..^1];
501+
return System.Net.IPAddress.TryParse(value, out var ip) ? ip.ToString() : value;
502+
}
503+
448504
/// <summary>
449505
/// Detects the PHP framework powering a site by inspecting its document root
450506
/// and parent directory (Laravel/Symfony/Nette typically have docroot=public/ or www/).

src/frontend/electron/main.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -292,7 +292,8 @@ async function isDaemonAlive(): Promise<boolean> {
292292
// option is to kill it and spawn the one bundled with this app.
293293
const appVersion = app.getVersion()
294294
const daemonVersion = (parsed.version || '').split('+')[0].trim()
295-
if (daemonVersion && daemonVersion !== appVersion) {
295+
const daemonBaseVersion = daemonVersion.split('-')[0].trim()
296+
if (daemonBaseVersion && daemonBaseVersion !== appVersion) {
296297
console.warn(`[daemon] version skew — app=${appVersion} daemon=${daemonVersion} — stopping stale daemon`)
297298
try {
298299
await new Promise<void>((resolve) => {

src/frontend/src/api/daemon.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -530,6 +530,17 @@ export interface SystemInfo {
530530
}
531531
export const fetchSystem = (): Promise<SystemInfo> => json('/api/system')
532532

533+
export interface BindAddressOption {
534+
value: string
535+
label: string
536+
description: string
537+
wildcard: boolean
538+
loopback: boolean
539+
interfaceName?: string | null
540+
}
541+
export const fetchBindAddressOptions = (): Promise<BindAddressOption[]> =>
542+
json('/api/sites/bind-addresses')
543+
533544
// Settings store — flat "category.key" → string map the daemon persists
534545
// in SQLite. Used by Settings.vue (read + write + sync compare) and by
535546
// Login.vue (read-only, to pre-fill the catalog URL). Several pages and

src/frontend/src/components/pages/SiteDetailSimple.vue

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -71,13 +71,21 @@
7171
<div class="sd-row sd-row-stack">
7272
<span class="sd-label">{{ $t('sites.bindIp') }}</span>
7373
<div class="sd-control-wrap sd-bind-control">
74-
<el-input
74+
<el-select
7575
v-model="bindAddress"
7676
size="small"
77-
clearable
78-
placeholder="* or 127.0.0.1"
77+
filterable
78+
:loading="bindAddressOptionsLoading"
79+
:disabled="bindAddressOptionsLoading || bindAddressOptions.length === 0"
7980
@change="onBindAddressChange"
80-
/>
81+
>
82+
<el-option
83+
v-for="opt in bindAddressOptions"
84+
:key="opt.value"
85+
:label="opt.label"
86+
:value="opt.value"
87+
/>
88+
</el-select>
8189
<Transition name="flash">
8290
<span v-if="savedBind" class="sd-saved">{{ $t('sites.detail.simple.saved') }}</span>
8391
</Transition>
@@ -181,7 +189,7 @@ import { useSitesStore } from '../../stores/sites'
181189
import { useDaemonStore } from '../../stores/daemon'
182190
import { useServicesStore } from '../../stores/services'
183191
import { useI18n } from 'vue-i18n'
184-
import { daemonBaseUrl, fetchPhpVersions } from '../../api/daemon'
192+
import { daemonBaseUrl, fetchBindAddressOptions, fetchPhpVersions, type BindAddressOption } from '../../api/daemon'
185193
import { errorMessage } from '../../utils/errors'
186194
187195
const props = defineProps<{ domain: string }>()
@@ -200,6 +208,8 @@ const site = computed(() => sitesStore.sites.find(s => s.domain === props.domain
200208
const phpVersion = ref('')
201209
const sslEnabled = ref(false)
202210
const bindAddress = ref('')
211+
const bindAddressOptions = ref<BindAddressOption[]>([])
212+
const bindAddressOptionsLoading = ref(false)
203213
const tunnelEnabled = ref(false)
204214
205215
const savedPhp = ref(false)
@@ -324,7 +334,7 @@ watch(site, (s) => {
324334
if (!s) return
325335
phpVersion.value = s.phpVersion ?? ''
326336
sslEnabled.value = s.sslEnabled ?? false
327-
bindAddress.value = s.bindAddress ?? ''
337+
bindAddress.value = s.bindAddress || '*'
328338
tunnelEnabled.value = s.cloudflare?.enabled ?? false
329339
}, { immediate: true })
330340
@@ -337,6 +347,27 @@ async function loadPhpVersions() {
337347
}
338348
}
339349
350+
async function loadBindAddressOptions() {
351+
bindAddressOptionsLoading.value = true
352+
try {
353+
bindAddressOptions.value = await fetchBindAddressOptions()
354+
const current = bindAddress.value || '*'
355+
if (!bindAddressOptions.value.some(o => o.value === current)) {
356+
bindAddressOptions.value.push({
357+
value: current,
358+
label: `${current} (saved, unavailable)`,
359+
description: 'Saved site value that is not present on this device right now.',
360+
wildcard: current === '*',
361+
loopback: false,
362+
})
363+
}
364+
} catch {
365+
bindAddressOptions.value = []
366+
} finally {
367+
bindAddressOptionsLoading.value = false
368+
}
369+
}
370+
340371
function flashSaved(flag: { value: boolean }) {
341372
flag.value = true
342373
setTimeout(() => { flag.value = false }, 1500)
@@ -370,7 +401,7 @@ async function onBindAddressChange(v: string) {
370401
flashSaved(savedBind)
371402
} catch (e) {
372403
ElMessage.error(`Update failed: ${errorMessage(e)}`)
373-
bindAddress.value = site.value.bindAddress ?? ''
404+
bindAddress.value = site.value.bindAddress || '*'
374405
}
375406
}
376407
@@ -434,6 +465,7 @@ onMounted(async () => {
434465
try {
435466
await sitesStore.load()
436467
await loadPhpVersions()
468+
await loadBindAddressOptions()
437469
await loadRecentActivity()
438470
} finally {
439471
loading.value = false

src/frontend/src/components/pages/SiteEdit.vue

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -65,14 +65,21 @@
6565
</el-input>
6666
</el-form-item>
6767
<el-form-item :label="$t('sites.bindIp')">
68-
<el-input
68+
<el-select
6969
v-model="site.bindAddress"
70-
clearable
71-
placeholder="* or 127.0.0.1"
72-
@input="markDirty"
70+
style="width: 100%"
71+
filterable
72+
:loading="bindAddressOptionsLoading"
73+
:disabled="bindAddressOptionsLoading || bindAddressOptions.length === 0"
74+
@change="markDirty"
7375
>
74-
<template #prepend><el-icon><Link /></el-icon></template>
75-
</el-input>
76+
<el-option
77+
v-for="opt in bindAddressOptions"
78+
:key="opt.value"
79+
:label="opt.label"
80+
:value="opt.value"
81+
/>
82+
</el-select>
7683
<div class="hint">{{ $t('sites.bindIpHint') }}</div>
7784
</el-form-item>
7885
<el-form-item :label="$t('sites.documentRoot')" required>
@@ -663,8 +670,10 @@ import {
663670
fetchDockerComposeStatus, type DockerComposeStatus,
664671
composeUp, composeDown, composeRestart, composePs,
665672
getHistoricalMetrics,
673+
fetchBindAddressOptions,
666674
fetchPhpVersions,
667675
daemonBaseUrl,
676+
type BindAddressOption,
668677
} from '../../api/daemon'
669678
import { errorMessage } from '../../utils/errors'
670679
import { use } from 'echarts/core'
@@ -702,6 +711,8 @@ const renameNewDomain = ref('')
702711
const composeInfo = ref<DockerComposeStatus | null>(null)
703712
const composeLoading = ref(false)
704713
const composeOutput = ref('')
714+
const bindAddressOptions = ref<BindAddressOption[]>([])
715+
const bindAddressOptionsLoading = ref(false)
705716
706717
async function runCompose(action: 'up' | 'down' | 'restart' | 'ps') {
707718
if (!site.value) return
@@ -1053,6 +1064,7 @@ async function load() {
10531064
await sitesStore.load()
10541065
const found = sitesStore.sites.find(s => s.domain === domain.value)
10551066
site.value = found ? { ...found, aliases: [...(found.aliases ?? [])] } : null
1067+
if (site.value && !site.value.bindAddress) site.value.bindAddress = '*'
10561068
dirty.value = false
10571069
10581070
// PHP versions — authoritative list from daemon. Leave the array
@@ -1073,6 +1085,25 @@ async function load() {
10731085
})
10741086
} catch { phpVersions.value = [] }
10751087
1088+
bindAddressOptionsLoading.value = true
1089+
try {
1090+
bindAddressOptions.value = await fetchBindAddressOptions()
1091+
const current = site.value?.bindAddress || '*'
1092+
if (site.value && current && !bindAddressOptions.value.some(o => o.value === current)) {
1093+
bindAddressOptions.value.push({
1094+
value: current,
1095+
label: `${current} (saved, unavailable)`,
1096+
description: 'Saved site value that is not present on this device right now.',
1097+
wildcard: current === '*',
1098+
loopback: false,
1099+
})
1100+
}
1101+
} catch {
1102+
bindAddressOptions.value = []
1103+
} finally {
1104+
bindAddressOptionsLoading.value = false
1105+
}
1106+
10761107
// history
10771108
try {
10781109
const res = await fetch(`${daemonBaseUrl()}/api/sites/${domain.value}/history`, {

0 commit comments

Comments
 (0)