Skip to content

Commit c6cbae0

Browse files
committed
feat(sites): add per-site bind address
1 parent 9d6d08d commit c6cbae0

10 files changed

Lines changed: 235 additions & 2 deletions

File tree

src/daemon/NKS.WebDevConsole.Core/Models/SiteConfig.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@ public class SiteConfig
88
public bool SslEnabled { get; set; }
99
public int HttpPort { get; set; } = 80;
1010
public int HttpsPort { get; set; } = 443;
11+
/// <summary>
12+
/// Optional IP address used by generated per-site vhosts. Empty or "*"
13+
/// means bind on every Apache listener address.
14+
/// </summary>
15+
public string BindAddress { get; set; } = "";
1116

1217
/// <summary>
1318
/// Task 31: soft enable/disable flag. When false, the site's TOML

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7343,6 +7343,7 @@ await File.WriteAllTextAsync(indexPath,
73437343
PhpVersion = "",
73447344
SslEnabled = true,
73457345
Enabled = true,
7346+
Aliases = new[] { "127.0.0.1" },
73467347
});
73477348
await siteOrch.ApplyAsync(created, CancellationToken.None);
73487349
}

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

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,10 +175,25 @@ public static void ValidateDocumentRoot(string documentRoot)
175175
throw new ArgumentException($"DocumentRoot contains forbidden character: '{c}'");
176176
}
177177

178+
public static void ValidateBindAddress(string? bindAddress)
179+
{
180+
if (string.IsNullOrWhiteSpace(bindAddress) || bindAddress.Trim() == "*")
181+
return;
182+
if (bindAddress.Length > 64)
183+
throw new ArgumentException("BindAddress too long (max 64 chars)");
184+
var value = bindAddress.Trim();
185+
if (value.Any(c => char.IsWhiteSpace(c) || c is '"' or '\'' or ';' or '|' or '&' or '`' or '<' or '>' or '\0'))
186+
throw new ArgumentException("BindAddress contains forbidden characters");
187+
var unwrapped = value.StartsWith('[') && value.EndsWith(']') ? value[1..^1] : value;
188+
if (!System.Net.IPAddress.TryParse(unwrapped, out _))
189+
throw new ArgumentException("BindAddress must be an IP address or '*'");
190+
}
191+
178192
public async Task<SiteConfig> CreateAsync(SiteConfig site)
179193
{
180194
ValidateDomain(site.Domain);
181195
ValidateDocumentRoot(site.DocumentRoot);
196+
ValidateBindAddress(site.BindAddress);
182197
if (site.Aliases is { Length: > 0 })
183198
foreach (var alias in site.Aliases)
184199
ValidateAlias(alias);
@@ -211,6 +226,7 @@ public async Task<SiteConfig> UpdateAsync(SiteConfig site)
211226
// via crafted PUT body). Same treatment as Delete/Create.
212227
ValidateDomain(site.Domain);
213228
ValidateDocumentRoot(site.DocumentRoot);
229+
ValidateBindAddress(site.BindAddress);
214230
if (site.Aliases is { Length: > 0 })
215231
foreach (var alias in site.Aliases)
216232
ValidateAlias(alias);
@@ -334,13 +350,15 @@ public async Task GenerateVhostAsync(SiteConfig site)
334350
catch { /* ignore */ }
335351

336352
var apacheSettings = site.ApacheSettings;
353+
var aliases = NormalizeAliases(site.Domain, site.Aliases);
337354

338355
var model = new
339356
{
340357
site = new
341358
{
342359
domain = site.Domain,
343-
aliases = site.Aliases,
360+
aliases = aliases,
361+
bind_address = FormatApacheBindAddress(site.BindAddress),
344362
root = site.DocumentRoot,
345363
root_parent = rootParent,
346364
php_ini_path = sitePhpIni,
@@ -399,6 +417,34 @@ public async Task GenerateVhostAsync(SiteConfig site)
399417
return candidates.FirstOrDefault(File.Exists);
400418
}
401419

420+
public static string[] NormalizeAliases(string domain, IEnumerable<string>? aliases)
421+
{
422+
var result = new List<string>();
423+
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
424+
foreach (var alias in aliases ?? Array.Empty<string>())
425+
{
426+
var value = alias.Trim();
427+
if (!string.IsNullOrWhiteSpace(value) && seen.Add(value))
428+
result.Add(value);
429+
}
430+
if (string.Equals(domain, "localhost", StringComparison.OrdinalIgnoreCase)
431+
&& seen.Add("127.0.0.1"))
432+
{
433+
result.Add("127.0.0.1");
434+
}
435+
return result.ToArray();
436+
}
437+
438+
public static string FormatApacheBindAddress(string? bindAddress)
439+
{
440+
if (string.IsNullOrWhiteSpace(bindAddress) || bindAddress.Trim() == "*")
441+
return "*";
442+
var value = bindAddress.Trim();
443+
if (value.StartsWith('[') && value.EndsWith(']'))
444+
return value;
445+
return value.Contains(':') ? $"[{value}]" : value;
446+
}
447+
402448
/// <summary>
403449
/// Detects the PHP framework powering a site by inspecting its document root
404450
/// and parent directory (Laravel/Symfony/Nette typically have docroot=public/ or www/).

src/frontend/src/api/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ export interface SiteInfo {
5656
sslEnabled: boolean
5757
httpPort: number
5858
httpsPort: number
59+
/** Empty or "*" binds on every Apache listener address; use an IP such as 127.0.0.1 for loopback-only sites. */
60+
bindAddress?: string
5961
aliases: string[]
6062
/** Task 31: soft enable/disable. When false, httpd vhosts are removed
6163
* on apply; the TOML config stays on disk. Default true for backward

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

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,23 @@
6767
</div>
6868
</div>
6969

70+
<!-- Bind IP -->
71+
<div class="sd-row sd-row-stack">
72+
<span class="sd-label">{{ $t('sites.bindIp') }}</span>
73+
<div class="sd-control-wrap sd-bind-control">
74+
<el-input
75+
v-model="bindAddress"
76+
size="small"
77+
clearable
78+
placeholder="* or 127.0.0.1"
79+
@change="onBindAddressChange"
80+
/>
81+
<Transition name="flash">
82+
<span v-if="savedBind" class="sd-saved">{{ $t('sites.detail.simple.saved') }}</span>
83+
</Transition>
84+
</div>
85+
</div>
86+
7087
<!-- Cloudflare tunnel switch -->
7188
<div class="sd-row">
7289
<span class="sd-label">{{ $t('sites.detail.simple.tunnel') }}</span>
@@ -182,10 +199,12 @@ const site = computed(() => sitesStore.sites.find(s => s.domain === props.domain
182199
183200
const phpVersion = ref('')
184201
const sslEnabled = ref(false)
202+
const bindAddress = ref('')
185203
const tunnelEnabled = ref(false)
186204
187205
const savedPhp = ref(false)
188206
const savedSsl = ref(false)
207+
const savedBind = ref(false)
189208
const savedTunnel = ref(false)
190209
191210
const startStopLoading = ref(false)
@@ -305,6 +324,7 @@ watch(site, (s) => {
305324
if (!s) return
306325
phpVersion.value = s.phpVersion ?? ''
307326
sslEnabled.value = s.sslEnabled ?? false
327+
bindAddress.value = s.bindAddress ?? ''
308328
tunnelEnabled.value = s.cloudflare?.enabled ?? false
309329
}, { immediate: true })
310330
@@ -343,6 +363,17 @@ async function onSslChange(v: boolean) {
343363
}
344364
}
345365
366+
async function onBindAddressChange(v: string) {
367+
if (!site.value) return
368+
try {
369+
await sitesStore.update(props.domain, { ...site.value, bindAddress: v.trim() })
370+
flashSaved(savedBind)
371+
} catch (e) {
372+
ElMessage.error(`Update failed: ${errorMessage(e)}`)
373+
bindAddress.value = site.value.bindAddress ?? ''
374+
}
375+
}
376+
346377
async function onTunnelChange(v: boolean) {
347378
if (!site.value) return
348379
const existing = site.value.cloudflare ?? { enabled: false, subdomain: '', zoneId: '', zoneName: '', localService: 'localhost:80', protocol: 'http' as const }
@@ -445,6 +476,10 @@ onMounted(async () => {
445476
padding: 10px 0;
446477
}
447478
479+
.sd-row-stack {
480+
align-items: flex-start;
481+
}
482+
448483
.sd-status-row {
449484
padding: 12px 0;
450485
}
@@ -485,6 +520,10 @@ onMounted(async () => {
485520
gap: 10px;
486521
}
487522
523+
.sd-bind-control {
524+
width: min(260px, 100%);
525+
}
526+
488527
.sd-status-dot {
489528
width: 10px;
490529
height: 10px;

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,17 @@
6464
<template #prepend><el-icon><Link /></el-icon></template>
6565
</el-input>
6666
</el-form-item>
67+
<el-form-item :label="$t('sites.bindIp')">
68+
<el-input
69+
v-model="site.bindAddress"
70+
clearable
71+
placeholder="* or 127.0.0.1"
72+
@input="markDirty"
73+
>
74+
<template #prepend><el-icon><Link /></el-icon></template>
75+
</el-input>
76+
<div class="hint">{{ $t('sites.bindIpHint') }}</div>
77+
</el-form-item>
6778
<el-form-item :label="$t('sites.documentRoot')" required>
6879
<el-input
6980
v-model="site.documentRoot"

src/frontend/src/components/pages/Sites.vue

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,12 @@
5959
effect="dark"
6060
class="runtime-tag runtime-php"
6161
>{{ rowRuntimeLabel(site) }}</el-tag>
62+
<el-tag
63+
v-if="site.bindAddress"
64+
size="small"
65+
effect="plain"
66+
class="cell-tag"
67+
>{{ site.bindAddress }}</el-tag>
6268
<span v-if="site.aliases?.length" class="alias-dot">+{{ site.aliases.length }}</span>
6369
<span v-if="site.aliases?.length" class="site-mobile-alias mono">
6470
{{ site.aliases[0] }}
@@ -122,6 +128,13 @@
122128
? `:${row.httpPort || 80}/:${row.httpsPort || 443}`
123129
: `:${row.httpPort || 80}`
124130
}}</span>
131+
<el-tag
132+
v-if="row.bindAddress"
133+
size="small"
134+
effect="plain"
135+
class="cell-tag"
136+
:title="$t('sites.bindIp')"
137+
>{{ row.bindAddress }}</el-tag>
125138
<el-tag
126139
v-if="row.sslEnabled"
127140
size="small"
@@ -282,6 +295,13 @@
282295
<el-option :label="$t('sites.phpNone')" value="none" />
283296
</el-select>
284297
</el-form-item>
298+
<el-form-item :label="$t('sites.bindIp')">
299+
<el-input
300+
v-model="newSite.bindAddress"
301+
clearable
302+
placeholder="* or 127.0.0.1"
303+
/>
304+
</el-form-item>
285305
<!-- F91.2: new-site toggles hidden when their plugin is disabled. -->
286306
<el-form-item v-if="pluginsStore.isUiVisible('sites-badge:cloudflare-tunnel')" :label="$t('sites.simple.cloudflareTunnel')">
287307
<el-switch v-model="newSite.cloudflareTunnel" />
@@ -337,6 +357,14 @@
337357
<el-form-item :label="$t('sites.aliases')">
338358
<el-input v-model="newSite.aliases" placeholder="www.myapp.loc" />
339359
</el-form-item>
360+
<el-form-item :label="$t('sites.bindIp')">
361+
<el-input
362+
v-model="newSite.bindAddress"
363+
clearable
364+
placeholder="* or 127.0.0.1"
365+
/>
366+
<div class="form-hint">{{ $t('sites.bindIpHint') }}</div>
367+
</el-form-item>
340368
<el-form-item :label="$t('sites.ssl')">
341369
<el-switch v-model="newSite.sslEnabled" />
342370
</el-form-item>
@@ -514,6 +542,7 @@ const newSite = reactive({
514542
// binds v-model to a non-existent option and renders blank.
515543
phpVersion: '',
516544
aliases: '',
545+
bindAddress: '',
517546
createDb: false,
518547
dbName: '',
519548
// SSL on by default so users opting out is the conscious choice — fresh
@@ -645,6 +674,7 @@ async function createSite() {
645674
phpVersion: newSite.phpVersion,
646675
sslEnabled: newSite.sslEnabled,
647676
aliases: newSite.aliases ? newSite.aliases.split(',').map(s => s.trim()).filter(Boolean) : [],
677+
bindAddress: newSite.bindAddress.trim(),
648678
...(newSite.cloudflareTunnel ? { cloudflareTunnel: true } : {}),
649679
}
650680
const created = await sitesStore.create(payload)
@@ -687,7 +717,7 @@ async function createSite() {
687717
// again (set once by loadPhpVersions on dialog open) instead of a
688718
// hardcoded literal that may not exist on this machine.
689719
const defaultPhp = phpVersions.value.find(p => p.isActive)?.value ?? phpVersions.value[0]?.value ?? ''
690-
Object.assign(newSite, { domain: '', documentRoot: '', phpVersion: defaultPhp, aliases: '', sslEnabled: true, createDb: false, dbName: '', cloudflareTunnel: false, template: '' })
720+
Object.assign(newSite, { domain: '', documentRoot: '', phpVersion: defaultPhp, aliases: '', bindAddress: '', sslEnabled: true, createDb: false, dbName: '', cloudflareTunnel: false, template: '' })
691721
} catch (e) {
692722
ElMessage.error(`Create failed: ${errorMessage(e)}`)
693723
} finally {
@@ -1256,6 +1286,13 @@ function handleRowAction(cmd: string, row: SiteInfo) {
12561286
line-height: 1.5;
12571287
}
12581288
1289+
.form-hint {
1290+
margin-top: 4px;
1291+
color: var(--wdc-text-3);
1292+
font-size: 12px;
1293+
line-height: 1.35;
1294+
}
1295+
12591296
.simple-advanced-link {
12601297
margin-top: 12px;
12611298
text-align: center;

src/frontend/src/locales/cs.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -428,6 +428,8 @@
428428
"filterPlaceholder": "Filtrovat podle domény nebo docrootu…",
429429
"ssl": "SSL",
430430
"aliases": "Aliasy",
431+
"bindIp": "IP vazba",
432+
"bindIpHint": "Prazdne pole navaze vhost na vsechny Apache listen adresy. Pro loopback-only pouzij 127.0.0.1.",
431433
"create": "Nový web",
432434
"openHosts": "Otevřít hosts",
433435
"additionalDomains": "{n} dalších domén",

src/frontend/src/locales/en.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -428,6 +428,8 @@
428428
"filterPlaceholder": "Filter by domain or docroot…",
429429
"ssl": "SSL",
430430
"aliases": "Aliases",
431+
"bindIp": "Bind IP",
432+
"bindIpHint": "Leave empty to bind this vhost on every Apache listener. Use 127.0.0.1 for loopback-only.",
431433
"create": "New Site",
432434
"openHosts": "Open hosts",
433435
"additionalDomains": "{n} additional domains",

0 commit comments

Comments
 (0)