@@ -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
1419const 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+
1940onMounted (() => {
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
2750watch (() => 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 = / ^ (?:[\d a-f ] {1,4} :){7} [\d a-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