Skip to content

Commit e47fc25

Browse files
committed
feat: add support for IP address in AutoCert options #1208
1 parent 38ee12f commit e47fc25

File tree

20 files changed

+4160
-2997
lines changed

20 files changed

+4160
-2997
lines changed

app/src/api/auto_cert.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export interface DNSProvider {
2222
export interface AutoCertOptions {
2323
name?: string
2424
domains: string[]
25+
ip_address?: string
2526
code?: string
2627
dns_credential_id?: number | null
2728
challenge_method: keyof typeof AutoCertChallengeMethod

app/src/components/AutoCertForm/AutoCertForm.vue

Lines changed: 160 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,31 +9,142 @@ const props = defineProps<{
99
hideNote?: boolean
1010
forceDnsChallenge?: boolean
1111
keyTypeReadOnly?: boolean
12+
isDefaultServer?: boolean
13+
hasWildcardServerName?: boolean
14+
hasExplicitIpAddress?: boolean
15+
isIpCertificate?: boolean
16+
needsManualIpInput?: boolean
1217
}>()
1318
1419
const data = defineModel<AutoCertOptions>('options', {
1520
default: reactive({}),
1621
required: true,
1722
})
1823
24+
// Local IP address buffer for manual input
25+
const manualIpAddress = ref('')
26+
27+
// Function to apply manual IP to domains when needed
28+
function applyManualIpToDomains() {
29+
if (props.needsManualIpInput && manualIpAddress.value?.trim()) {
30+
if (!data.value.domains)
31+
data.value.domains = []
32+
33+
const trimmedIp = manualIpAddress.value.trim()
34+
if (!data.value.domains.includes(trimmedIp)) {
35+
data.value.domains.push(trimmedIp)
36+
}
37+
}
38+
}
39+
1940
onMounted(() => {
2041
if (!data.value.key_type)
2142
data.value.key_type = PrivateKeyTypeEnum.P256
2243
2344
if (props.forceDnsChallenge)
2445
data.value.challenge_method = AutoCertChallengeMethod.dns01
46+
else if (props.isIpCertificate)
47+
data.value.challenge_method = AutoCertChallengeMethod.http01
2548
})
2649
2750
watch(() => props.forceDnsChallenge, v => {
2851
if (v)
2952
data.value.challenge_method = AutoCertChallengeMethod.dns01
3053
})
54+
55+
watch(() => props.isIpCertificate, v => {
56+
if (v && !props.forceDnsChallenge)
57+
data.value.challenge_method = AutoCertChallengeMethod.http01
58+
})
59+
60+
// Expose function for parent component to call before submission
61+
defineExpose({
62+
applyManualIpToDomains,
63+
})
64+
65+
// Check if IPv4 address is private
66+
function isPrivateIPv4(ip: string): boolean {
67+
const parts = ip.split('.').map(part => Number.parseInt(part, 10))
68+
const [a, b] = parts
69+
70+
// 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 127.0.0.0/8 (localhost)
71+
return a === 10
72+
|| (a === 172 && b >= 16 && b <= 31)
73+
|| (a === 192 && b === 168)
74+
|| a === 127
75+
}
76+
77+
// IP address validation function
78+
function validateIpAddress(_rule: unknown, value: string) {
79+
if (!value || value.trim() === '') {
80+
return Promise.reject($gettext('Please enter the server IP address'))
81+
}
82+
83+
// Basic IPv4 validation (simplified)
84+
const ipv4Regex = /^(?:\d{1,3}\.){3}\d{1,3}$/
85+
// Basic IPv6 validation
86+
const ipv6Regex = /^(?:[\da-f]{1,4}:){7}[\da-f]{1,4}$|^::1$|^::$/i
87+
88+
const trimmedValue = value.trim()
89+
90+
// Additional validation for IPv4 ranges
91+
if (ipv4Regex.test(trimmedValue)) {
92+
const parts = trimmedValue.split('.')
93+
const validRange = parts.every(part => {
94+
const num = Number.parseInt(part, 10)
95+
return num >= 0 && num <= 255
96+
})
97+
if (!validRange) {
98+
return Promise.reject($gettext('Please enter a valid IPv4 address (0-255 per octet)'))
99+
}
100+
101+
// Warn about private IP addresses
102+
if (isPrivateIPv4(trimmedValue)) {
103+
return Promise.reject($gettext('Warning: This appears to be a private IP address. '
104+
+ 'Public CAs like Let\'s Encrypt cannot issue certificates for private IPs. '
105+
+ 'Use a public IP address or consider using a private CA.'))
106+
}
107+
}
108+
else if (!ipv6Regex.test(trimmedValue)) {
109+
return Promise.reject($gettext('Please enter a valid IPv4 or IPv6 address'))
110+
}
111+
112+
return Promise.resolve()
113+
}
31114
</script>
32115

33116
<template>
34117
<div>
118+
<!-- IP Certificate Warning -->
119+
<AAlert
120+
v-if="isIpCertificate && !hideNote"
121+
type="warning"
122+
show-icon
123+
:message="$gettext('IP Certificate Notice')"
124+
class="mb-4"
125+
>
126+
<template #description>
127+
<p v-if="isDefaultServer">
128+
{{ $gettext('This site is configured as a default server (default_server) for HTTPS (port 443). '
129+
+ 'IP certificates require Certificate Authority (CA) support and may not be available with all ACME providers.') }}
130+
</p>
131+
<p v-else-if="hasWildcardServerName">
132+
{{ $gettext('This site uses wildcard server name (_) which typically indicates an IP-based certificate. '
133+
+ 'IP certificates require Certificate Authority (CA) support and may not be available with all ACME providers.') }}
134+
</p>
135+
<p v-if="needsManualIpInput">
136+
{{ $gettext('No specific IP address found in server_name configuration. '
137+
+ 'Please specify the server IP address below for the certificate.') }}
138+
</p>
139+
<p>
140+
{{ $gettext('For IP-based certificate configurations, only HTTP-01 challenge method is supported. '
141+
+ 'DNS-01 challenge is not compatible with IP-based certificates.') }}
142+
</p>
143+
</template>
144+
</AAlert>
145+
35146
<AAlert
36-
v-if="!hideNote"
147+
v-if="!hideNote && !isIpCertificate"
37148
type="info"
38149
show-icon
39150
:message="$gettext('Note')"
@@ -61,6 +172,47 @@ watch(() => props.forceDnsChallenge, v => {
61172
</template>
62173
</AAlert>
63174
<AForm layout="vertical">
175+
<!-- IP Address Input for IP certificates without explicit IP -->
176+
<AFormItem
177+
v-if="needsManualIpInput"
178+
:label="$gettext('Server IP Address')"
179+
:rules="[{ validator: validateIpAddress, trigger: 'blur' }]"
180+
>
181+
<AInput
182+
v-model:value="manualIpAddress"
183+
:placeholder="$gettext('Enter server IP address (e.g., 203.0.113.1 or 2001:db8::1)')"
184+
/>
185+
<template #help>
186+
<div class="space-y-2">
187+
<p>
188+
{{ $gettext('For IP-based certificates, please specify the server IP address that will be included in the certificate.') }}
189+
</p>
190+
<div class="text-xs text-gray-600">
191+
<p class="font-medium">
192+
{{ $gettext('Public CA Requirements:') }}
193+
</p>
194+
<ul class="ml-4 list-disc space-y-1">
195+
<li>
196+
{{ $gettext('Must be a public IP address accessible from the internet') }}
197+
</li>
198+
<li>
199+
{{ $gettext('Port 80 must be open for HTTP-01 challenge validation') }}
200+
</li>
201+
<li>
202+
{{ $gettext('Private IPs (192.168.x.x, 10.x.x.x, 172.16-31.x.x) will fail') }}
203+
</li>
204+
</ul>
205+
<p class="mt-2 font-medium">
206+
{{ $gettext('Private CA:') }}
207+
</p>
208+
<p class="ml-4">
209+
{{ $gettext('Any reachable IP address can be used with private Certificate Authorities') }}
210+
</p>
211+
</div>
212+
</div>
213+
</template>
214+
</AFormItem>
215+
64216
<AFormItem
65217
v-if="!forceDnsChallenge"
66218
:label="$gettext('Challenge Method')"
@@ -69,8 +221,14 @@ watch(() => props.forceDnsChallenge, v => {
69221
<ASelectOption value="http01">
70222
{{ $gettext('HTTP01') }}
71223
</ASelectOption>
72-
<ASelectOption value="dns01">
224+
<ASelectOption
225+
value="dns01"
226+
:disabled="isIpCertificate"
227+
>
73228
{{ $gettext('DNS01') }}
229+
<span v-if="isIpCertificate" class="text-gray-400 ml-2">
230+
({{ $gettext('Not supported for IP certificates') }})
231+
</span>
74232
</ASelectOption>
75233
</ASelect>
76234
</AFormItem>

0 commit comments

Comments
 (0)