@@ -9,31 +9,142 @@ const props = defineProps<{
9
9
hideNote? : boolean
10
10
forceDnsChallenge? : boolean
11
11
keyTypeReadOnly? : boolean
12
+ isDefaultServer? : boolean
13
+ hasWildcardServerName? : boolean
14
+ hasExplicitIpAddress? : boolean
15
+ isIpCertificate? : boolean
16
+ needsManualIpInput? : boolean
12
17
}>()
13
18
14
19
const data = defineModel <AutoCertOptions >(' options' , {
15
20
default: reactive ({}),
16
21
required: true ,
17
22
})
18
23
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
+
19
40
onMounted (() => {
20
41
if (! data .value .key_type )
21
42
data .value .key_type = PrivateKeyTypeEnum .P256
22
43
23
44
if (props .forceDnsChallenge )
24
45
data .value .challenge_method = AutoCertChallengeMethod .dns01
46
+ else if (props .isIpCertificate )
47
+ data .value .challenge_method = AutoCertChallengeMethod .http01
25
48
})
26
49
27
50
watch (() => props .forceDnsChallenge , v => {
28
51
if (v )
29
52
data .value .challenge_method = AutoCertChallengeMethod .dns01
30
53
})
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
+ }
31
114
</script >
32
115
33
116
<template >
34
117
<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
+
35
146
<AAlert
36
- v-if =" !hideNote"
147
+ v-if =" !hideNote && !isIpCertificate "
37
148
type =" info"
38
149
show-icon
39
150
:message =" $gettext('Note')"
@@ -61,6 +172,47 @@ watch(() => props.forceDnsChallenge, v => {
61
172
</template >
62
173
</AAlert >
63
174
<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
+
64
216
<AFormItem
65
217
v-if =" !forceDnsChallenge"
66
218
:label =" $gettext('Challenge Method')"
@@ -69,8 +221,14 @@ watch(() => props.forceDnsChallenge, v => {
69
221
<ASelectOption value =" http01" >
70
222
{{ $gettext('HTTP01') }}
71
223
</ASelectOption >
72
- <ASelectOption value =" dns01" >
224
+ <ASelectOption
225
+ value =" dns01"
226
+ :disabled =" isIpCertificate"
227
+ >
73
228
{{ $gettext('DNS01') }}
229
+ <span v-if =" isIpCertificate" class =" text-gray-400 ml-2" >
230
+ ({{ $gettext('Not supported for IP certificates') }})
231
+ </span >
74
232
</ASelectOption >
75
233
</ASelect >
76
234
</AFormItem >
0 commit comments