Enable enrollment protocols on the application, not by cloning profiles
The enrollment methods were not appearing in the UI because they were being set
in the wrong place. A profile's own enrollmentType is one value, but that is
only the base; what the UI lists under an application - "configure how this
application will issue certificates via API, EST, ACME, or SCEP" - is a
separate configuration on the application-to-profile link, and those are
independent. A single profile can answer all four at once.
That replaces the previous approach. The mecm-...-scep and ec-...-acme profiles
are gone; there are three profiles again, one per policy, and each declares an
Enrollment block naming the protocols it should answer. Applied with PUT to
/applications/{id}/profiles/{id}/enrollment/{api,scep,acme}, which is idempotent
by nature, so a re-run restates the same settings. The routes take PUT, not
POST, and a POST there returns 404.
SCEP now uses a dynamic challenge, so there is no shared secret to distribute
or rotate; each request collects a one-time password from the challenge
endpoint. -ScepChallengePassword is kept for anyone who sets ChallengeType to
static, and is otherwise unused.
Enabling a protocol is what makes Infisical mint its endpoint - a SCEP URL,
challenge URL and RA certificate, or an ACME directory URL - so the run reads
them back and prints them, and returns them under Enrollment. None of it can be
derived from the configuration alone.
Verified against a live instance: scepConfigured and acmeConfigured come back
true on the intended profiles, the dynamic challenge settings round-trip, and a
re-run restates without creating anything.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -21,9 +21,12 @@
|
||||
Two sets are seeded by default: an RSA hierarchy carrying server and client authentication for
|
||||
SCCM/MECM, and an ECDSA P-384 hierarchy carrying server, client, and code signing.
|
||||
|
||||
A profile carries one enrollment method, so a policy that should be reachable over more than one gets
|
||||
a profile per method. Seeded by default: API on all three policies, SCEP alongside the RSA one for
|
||||
clients that enrol without a token, and ACME alongside the EC server/client one.
|
||||
Enrollment protocols are enabled on the link between the application and each profile, which is where
|
||||
they are independent of one another, so a single profile can answer several at once. Seeded by
|
||||
default: API everywhere, SCEP on the RSA profile with a dynamic challenge for clients that enrol
|
||||
without a token, and ACME on the EC server/client profile. Enabling SCEP or ACME makes Infisical mint
|
||||
the matching endpoint, and the run prints those URLs because they cannot be derived from the
|
||||
configuration.
|
||||
|
||||
The script is idempotent. Every object is looked up by its natural key before creation, so a re-run
|
||||
reports what already exists and creates only what is missing. It is safe to run repeatedly while
|
||||
@@ -161,13 +164,7 @@ Set-StrictMode -Version Latest
|
||||
Application = [Ordered]@{
|
||||
Name = 'platform'
|
||||
Description = 'Endpoint and workload certificate enrollment.'
|
||||
CertificateProfiles = @(
|
||||
'mecm-server-client-auth'
|
||||
'mecm-server-client-auth-scep'
|
||||
'ec-server-client-auth'
|
||||
'ec-server-client-auth-acme'
|
||||
'ec-code-signing'
|
||||
)
|
||||
CertificateProfiles = @('mecm-server-client-auth', 'ec-server-client-auth', 'ec-code-signing')
|
||||
}
|
||||
|
||||
Organization = [Ordered]@{
|
||||
@@ -289,76 +286,65 @@ Set-StrictMode -Version Latest
|
||||
)
|
||||
|
||||
<#
|
||||
A profile carries exactly one enrollment method. Infisical rejects a profile that mixes them
|
||||
("API enrollment type cannot have EST, ACME, or SCEP configuration"), so offering a policy over
|
||||
more than one method means one profile per method, all pointing at the same policy and CA.
|
||||
Enrollment is configured twice, at two different levels, and only the second one is what clients
|
||||
actually use.
|
||||
|
||||
EnrollmentType is api, scep, acme, or est. Each takes only its own configuration:
|
||||
api AutoRenew, RenewBeforeDays (1-30)
|
||||
scep ScepChallengeType static (needs a challenge password) or dynamic (issues one per request)
|
||||
acme SkipDnsOwnershipVerification, SkipEabBinding
|
||||
est a passphrase and a bootstrap CA chain, so it is left to be configured by hand
|
||||
The profile itself carries a single enrollmentType, and Infisical rejects a profile that mixes
|
||||
them ("API enrollment type cannot have EST, ACME, or SCEP configuration"). That setting is left
|
||||
at api for every profile below; it is the base the application builds on.
|
||||
|
||||
AutoRenew is an api-only setting. Neither the SCEP nor the ACME configuration has an equivalent,
|
||||
because both protocols have the client drive renewal on its own schedule.
|
||||
The application-to-profile link is where a profile is exposed over one or more protocols, which
|
||||
is what the UI means by "configure how this application will issue certificates via API, EST,
|
||||
ACME, or SCEP". Those are independent, so a single profile can serve all of them at once, and
|
||||
enabling one is what makes Infisical mint its endpoint - a SCEP URL and RA certificate, or an
|
||||
ACME directory URL. The Enrollment block below drives that step; omit a protocol to leave it off.
|
||||
|
||||
Api AutoRenew, RenewBeforeDays (1-365)
|
||||
Scep ChallengeType dynamic (a one-time password per request) or static (one shared secret),
|
||||
IncludeCaCertInResponse, AllowCertBasedRenewal, and for dynamic the expiry in minutes
|
||||
(5-1440) and the cap on outstanding challenges (1-1000)
|
||||
Acme SkipDnsOwnershipVerification, SkipEabBinding
|
||||
Est a passphrase and a bootstrap CA chain, so it is left to be configured by hand
|
||||
|
||||
AutoRenew is an Api setting. Neither SCEP nor ACME has an equivalent, because both protocols
|
||||
have the client drive renewal on its own schedule.
|
||||
|
||||
Defaults fill in what a requester leaves out. They are required whenever the policy marks a
|
||||
constraint Required: Request-InfisicalCertificate does not send key usages of its own, so
|
||||
without a default the request arrives empty and is refused with "Missing required key usages".
|
||||
#>
|
||||
CertificateProfiles = @(
|
||||
[Ordered]@{
|
||||
Slug = 'mecm-server-client-auth'
|
||||
Description = 'API enrollment for SCCM/MECM site systems and clients.'
|
||||
CertificateAuthority = 'rsa-issuing-ca'
|
||||
CertificatePolicy = 'mecm-server-client-auth'
|
||||
EnrollmentType = 'api'
|
||||
AutoRenew = $True
|
||||
RenewBeforeDays = 14
|
||||
Defaults = [Ordered]@{
|
||||
KeyAlgorithm = 'RSA_2048'
|
||||
SignatureAlgorithm = 'RSA-SHA256'
|
||||
KeyUsages = @('digital_signature', 'key_encipherment')
|
||||
ExtendedKeyUsages = @('server_auth', 'client_auth')
|
||||
}
|
||||
}
|
||||
<#
|
||||
SCEP alongside the API profile: it is how domain-joined Windows and mobile clients enrol
|
||||
without a token, which is the NDES role SCCM/MECM would otherwise need. RSA because SCEP
|
||||
client support for EC keys is patchy.
|
||||
SCEP as well as API: it is how domain-joined Windows and mobile clients enrol without a
|
||||
token, which is the NDES role SCCM/MECM would otherwise need. Dynamic challenge, so there is
|
||||
no shared secret to distribute or rotate - each request collects a one-time password from
|
||||
the challenge endpoint instead.
|
||||
#>
|
||||
[Ordered]@{
|
||||
Slug = 'mecm-server-client-auth-scep'
|
||||
Description = 'SCEP enrollment for SCCM/MECM site systems and clients.'
|
||||
Slug = 'mecm-server-client-auth'
|
||||
Description = 'SCCM/MECM site systems and clients.'
|
||||
CertificateAuthority = 'rsa-issuing-ca'
|
||||
CertificatePolicy = 'mecm-server-client-auth'
|
||||
EnrollmentType = 'scep'
|
||||
ScepChallengeType = 'static'
|
||||
Defaults = [Ordered]@{
|
||||
KeyAlgorithm = 'RSA_2048'
|
||||
SignatureAlgorithm = 'RSA-SHA256'
|
||||
KeyUsages = @('digital_signature', 'key_encipherment')
|
||||
ExtendedKeyUsages = @('server_auth', 'client_auth')
|
||||
}
|
||||
}
|
||||
[Ordered]@{
|
||||
Slug = 'ec-server-client-auth'
|
||||
Description = 'API enrollment for general workload certificates.'
|
||||
CertificateAuthority = 'ec-issuing-ca'
|
||||
CertificatePolicy = 'ec-server-client-auth'
|
||||
EnrollmentType = 'api'
|
||||
AutoRenew = $True
|
||||
RenewBeforeDays = 14
|
||||
Defaults = [Ordered]@{
|
||||
KeyAlgorithm = 'EC_secp384r1'
|
||||
SignatureAlgorithm = 'ECDSA-SHA384'
|
||||
KeyUsages = @('digital_signature')
|
||||
ExtendedKeyUsages = @('server_auth', 'client_auth')
|
||||
Enrollment = [Ordered]@{
|
||||
Api = [Ordered]@{ AutoRenew = $True; RenewBeforeDays = 14 }
|
||||
Scep = [Ordered]@{
|
||||
ChallengeType = 'dynamic'
|
||||
IncludeCaCertInResponse = $True
|
||||
AllowCertBasedRenewal = $True
|
||||
DynamicChallengeExpiryMinutes = 60
|
||||
DynamicChallengeMaxPending = 100
|
||||
}
|
||||
}
|
||||
}
|
||||
<#
|
||||
ACME alongside the API profile, for workloads that already speak it (cert-manager, Caddy,
|
||||
Traefik, acme.sh).
|
||||
ACME as well as API, for workloads that already speak it (cert-manager, Caddy, Traefik,
|
||||
acme.sh).
|
||||
|
||||
SkipDnsOwnershipVerification is on because this is an internal CA issuing internal names:
|
||||
the DNS-01 challenge proves control of a public zone, which a name like host.contoso.local
|
||||
@@ -366,19 +352,20 @@ Set-StrictMode -Version Latest
|
||||
off if the names being issued live in a zone Infisical can actually resolve.
|
||||
#>
|
||||
[Ordered]@{
|
||||
Slug = 'ec-server-client-auth-acme'
|
||||
Description = 'ACME enrollment for general workload certificates.'
|
||||
CertificateAuthority = 'ec-issuing-ca'
|
||||
CertificatePolicy = 'ec-server-client-auth'
|
||||
EnrollmentType = 'acme'
|
||||
SkipDnsOwnershipVerification = $True
|
||||
SkipEabBinding = $False
|
||||
Defaults = [Ordered]@{
|
||||
Slug = 'ec-server-client-auth'
|
||||
Description = 'General workload certificates.'
|
||||
CertificateAuthority = 'ec-issuing-ca'
|
||||
CertificatePolicy = 'ec-server-client-auth'
|
||||
Defaults = [Ordered]@{
|
||||
KeyAlgorithm = 'EC_secp384r1'
|
||||
SignatureAlgorithm = 'ECDSA-SHA384'
|
||||
KeyUsages = @('digital_signature')
|
||||
ExtendedKeyUsages = @('server_auth', 'client_auth')
|
||||
}
|
||||
Enrollment = [Ordered]@{
|
||||
Api = [Ordered]@{ AutoRenew = $True; RenewBeforeDays = 14 }
|
||||
Acme = [Ordered]@{ SkipDnsOwnershipVerification = $True; SkipEabBinding = $False }
|
||||
}
|
||||
}
|
||||
<#
|
||||
Code signing is API only. SCEP and ACME both exist to prove control of a device or a DNS
|
||||
@@ -386,18 +373,18 @@ Set-StrictMode -Version Latest
|
||||
#>
|
||||
[Ordered]@{
|
||||
Slug = 'ec-code-signing'
|
||||
Description = 'API enrollment for code signing certificates.'
|
||||
Description = 'Code signing certificates.'
|
||||
CertificateAuthority = 'ec-issuing-ca'
|
||||
CertificatePolicy = 'ec-code-signing'
|
||||
EnrollmentType = 'api'
|
||||
AutoRenew = $True
|
||||
RenewBeforeDays = 14
|
||||
Defaults = [Ordered]@{
|
||||
KeyAlgorithm = 'EC_secp384r1'
|
||||
SignatureAlgorithm = 'ECDSA-SHA384'
|
||||
KeyUsages = @('digital_signature')
|
||||
ExtendedKeyUsages = @('code_signing')
|
||||
}
|
||||
Enrollment = [Ordered]@{
|
||||
Api = [Ordered]@{ AutoRenew = $True; RenewBeforeDays = 14 }
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -731,6 +718,117 @@ Set-StrictMode -Version Latest
|
||||
Write-Step "attached $($missing.Count) profile(s)" -Status 'Sub'
|
||||
}
|
||||
|
||||
function Set-SeededApplicationEnrollment {
|
||||
<#
|
||||
Turns on each protocol a profile should answer, on its link to the application. This is the step
|
||||
that makes a method visible in the UI and gives Infisical something to mint an endpoint from:
|
||||
enabling SCEP creates the RA certificate and the pkiclient.exe and challenge URLs, and enabling
|
||||
ACME creates the directory URL. Nothing here is exclusive, so a profile can serve several.
|
||||
|
||||
PUT replaces the configuration for one protocol, which makes re-running safe: the same settings
|
||||
produce the same result, and a protocol left out of the Enrollment block is simply never
|
||||
touched. Removing one from the block therefore does not disable it - delete it in the UI, or
|
||||
send DELETE to the same route.
|
||||
#>
|
||||
param([String]$ProjectId, $Application, $Profiles)
|
||||
|
||||
if ($Null -eq $Application -or [String]::IsNullOrWhiteSpace($ProjectId)) { return }
|
||||
|
||||
Write-Step 'Enrollment methods'
|
||||
|
||||
foreach ($definition in $Configuration.CertificateProfiles) {
|
||||
if (-not $Profiles.Contains($definition.Slug)) { continue }
|
||||
if (-not $definition.Contains('Enrollment') -or $Null -eq $definition.Enrollment) { continue }
|
||||
|
||||
$profileId = $Profiles[$definition.Slug].id
|
||||
|
||||
foreach ($method in @('Api', 'Scep', 'Acme')) {
|
||||
if (-not $definition.Enrollment.Contains($method)) { continue }
|
||||
|
||||
$settings = $definition.Enrollment[$method]
|
||||
$payload = ConvertTo-EnrollmentPayload -Method $method -Settings $settings
|
||||
$route = $method.ToLowerInvariant()
|
||||
|
||||
if (-not $PSCmdlet.ShouldProcess("$($definition.Slug) ($route)", 'Configure enrollment')) {
|
||||
Write-Step "would enable $route on '$($definition.Slug)'" -Status 'WhatIf'
|
||||
continue
|
||||
}
|
||||
|
||||
$payload.projectId = $ProjectId
|
||||
$Null = Invoke-InfisicalApi -Method 'PUT' -Body $payload `
|
||||
-Path "/api/v1/cert-manager/applications/$($Application.id)/profiles/$profileId/enrollment/$route"
|
||||
|
||||
$detail = switch ($method) {
|
||||
'Scep' { " ($($settings.ChallengeType) challenge)" }
|
||||
'Api' { if ($settings.AutoRenew) { " (auto-renew $($settings.RenewBeforeDays)d)" } else { ' (no auto-renew)' } }
|
||||
default { '' }
|
||||
}
|
||||
Write-Step "$route on '$($definition.Slug)'$detail" -Status 'Created'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Get-SeededEnrollmentSummary {
|
||||
<#
|
||||
Reads back what each profile ended up answering, because the endpoint URLs and the SCEP RA
|
||||
certificate are generated by Infisical and are not knowable from the configuration alone.
|
||||
#>
|
||||
param([String]$ProjectId, $Application, $Profiles)
|
||||
|
||||
$summary = [Ordered]@{}
|
||||
if ($Null -eq $Application -or [String]::IsNullOrWhiteSpace($ProjectId)) { return $summary }
|
||||
|
||||
foreach ($slug in $Profiles.Keys) {
|
||||
$profileId = $Profiles[$slug].id
|
||||
try {
|
||||
$summary[$slug] = Invoke-InfisicalApi -Method 'GET' `
|
||||
-Path "/api/v1/cert-manager/applications/$($Application.id)/profiles/$profileId/enrollment?projectId=$ProjectId"
|
||||
}
|
||||
catch {
|
||||
Write-Verbose "Could not read enrollment for '$slug': $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
|
||||
return $summary
|
||||
}
|
||||
|
||||
function ConvertTo-EnrollmentPayload {
|
||||
param([String]$Method, $Settings)
|
||||
|
||||
$payload = [Ordered]@{}
|
||||
switch ($Method) {
|
||||
'Api' {
|
||||
$payload.autoRenew = [Bool]$Settings.AutoRenew
|
||||
if ($payload.autoRenew -and $Null -ne $Settings.RenewBeforeDays) {
|
||||
$payload.renewBeforeDays = $Settings.RenewBeforeDays
|
||||
}
|
||||
}
|
||||
'Scep' {
|
||||
$challengeType = if ($Settings.Contains('ChallengeType')) { $Settings.ChallengeType } else { 'dynamic' }
|
||||
$payload.challengeType = $challengeType
|
||||
|
||||
# A static challenge is one shared secret; a dynamic one is minted per request instead.
|
||||
if ($challengeType -eq 'static') {
|
||||
$payload.challengePassword = Get-ScepChallengePassword
|
||||
}
|
||||
else {
|
||||
if ($Settings.Contains('DynamicChallengeExpiryMinutes')) { $payload.dynamicChallengeExpiryMinutes = $Settings.DynamicChallengeExpiryMinutes }
|
||||
if ($Settings.Contains('DynamicChallengeMaxPending')) { $payload.dynamicChallengeMaxPending = $Settings.DynamicChallengeMaxPending }
|
||||
}
|
||||
|
||||
if ($Settings.Contains('IncludeCaCertInResponse')) { $payload.includeCaCertInResponse = [Bool]$Settings.IncludeCaCertInResponse }
|
||||
if ($Settings.Contains('AllowCertBasedRenewal')) { $payload.allowCertBasedRenewal = [Bool]$Settings.AllowCertBasedRenewal }
|
||||
}
|
||||
'Acme' {
|
||||
$payload.skipDnsOwnershipVerification = [Bool]$Settings.SkipDnsOwnershipVerification
|
||||
$payload.skipEabBinding = [Bool]$Settings.SkipEabBinding
|
||||
}
|
||||
default { throw "Unsupported enrollment method '$Method'." }
|
||||
}
|
||||
|
||||
return $payload
|
||||
}
|
||||
|
||||
function Get-SeededCertificateAuthorities {
|
||||
param([String]$ProjectId)
|
||||
|
||||
@@ -1013,7 +1111,17 @@ Set-StrictMode -Version Latest
|
||||
continue
|
||||
}
|
||||
|
||||
$enrollmentType = if ($definition.Contains('EnrollmentType')) { $definition.EnrollmentType } else { 'api' }
|
||||
<#
|
||||
Always api at the profile level. The profile may only name one enrollment type, and the
|
||||
protocols a client actually reaches are enabled per application-profile link further down,
|
||||
where they are not mutually exclusive.
|
||||
#>
|
||||
$apiEnrollment = if ($definition.Enrollment.Contains('Api')) { $definition.Enrollment.Api } else { [Ordered]@{} }
|
||||
$apiConfig = [Ordered]@{ autoRenew = [Bool]$apiEnrollment.AutoRenew }
|
||||
if ($apiConfig.autoRenew -and $Null -ne $apiEnrollment.RenewBeforeDays) {
|
||||
# The profile-level cap is 30 days, where the application-level one allows up to 365.
|
||||
$apiConfig.renewBeforeDays = [Math]::Min([Int]$apiEnrollment.RenewBeforeDays, 30)
|
||||
}
|
||||
|
||||
$body = [Ordered]@{
|
||||
projectId = $ProjectId
|
||||
@@ -1021,34 +1129,9 @@ Set-StrictMode -Version Latest
|
||||
description = $definition.Description
|
||||
caId = $Authorities[$definition.CertificateAuthority].id
|
||||
certificatePolicyId = $Policies[$definition.CertificatePolicy].id
|
||||
enrollmentType = $enrollmentType
|
||||
enrollmentType = 'api'
|
||||
issuerType = 'ca'
|
||||
}
|
||||
|
||||
# Only the configuration matching the enrollment type may be sent; the others are rejected.
|
||||
switch ($enrollmentType) {
|
||||
'api' {
|
||||
$apiConfig = [Ordered]@{ autoRenew = [Bool]$definition.AutoRenew }
|
||||
if ($definition.AutoRenew -and $Null -ne $definition.RenewBeforeDays) {
|
||||
$apiConfig.renewBeforeDays = $definition.RenewBeforeDays
|
||||
}
|
||||
$body.apiConfig = $apiConfig
|
||||
}
|
||||
'scep' {
|
||||
$challengeType = if ($definition.Contains('ScepChallengeType')) { $definition.ScepChallengeType } else { 'static' }
|
||||
$scepConfig = [Ordered]@{ challengeType = $challengeType }
|
||||
if ($challengeType -eq 'static') {
|
||||
$scepConfig.challengePassword = Get-ScepChallengePassword
|
||||
}
|
||||
$body.scepConfig = $scepConfig
|
||||
}
|
||||
'acme' {
|
||||
$body.acmeConfig = [Ordered]@{
|
||||
skipDnsOwnershipVerification = [Bool]$definition.SkipDnsOwnershipVerification
|
||||
skipEabBinding = [Bool]$definition.SkipEabBinding
|
||||
}
|
||||
}
|
||||
default { throw "Profile '$($definition.Slug)' declares unsupported EnrollmentType '$enrollmentType'." }
|
||||
apiConfig = $apiConfig
|
||||
}
|
||||
|
||||
$defaults = ConvertTo-ProfileDefaults -Definition $definition.Defaults
|
||||
@@ -1076,6 +1159,8 @@ Set-StrictMode -Version Latest
|
||||
$policies = Get-SeededCertificatePolicies -ProjectId $projectId
|
||||
$profiles = Get-SeededCertificateProfiles -ProjectId $projectId -Authorities $authorities -Policies $policies
|
||||
$application = Get-SeededApplication -ProjectId $projectId -Profiles $profiles
|
||||
Set-SeededApplicationEnrollment -ProjectId $projectId -Application $application -Profiles $profiles
|
||||
$enrollment = Get-SeededEnrollmentSummary -ProjectId $projectId -Application $application -Profiles $profiles
|
||||
|
||||
Write-Host ''
|
||||
Write-Step 'Done.'
|
||||
@@ -1097,6 +1182,24 @@ Set-StrictMode -Version Latest
|
||||
Write-Host " -CommonName `$Env:ComputerName.ToUpper() -DnsName (Get-InfisicalSANList) -Install -InstallChain"
|
||||
}
|
||||
|
||||
# Minted by Infisical when a protocol is enabled, so they can only be reported by reading them back.
|
||||
foreach ($slug in $enrollment.Keys) {
|
||||
$entry = $enrollment[$slug]
|
||||
$scep = Get-ApiProperty -InputObject $entry -Name 'scep'
|
||||
$acme = Get-ApiProperty -InputObject $entry -Name 'acme'
|
||||
if ($Null -eq $scep -and $Null -eq $acme) { continue }
|
||||
|
||||
Write-Host ''
|
||||
Write-Host "Enrollment endpoints for '$slug':"
|
||||
if ($Null -ne $scep) {
|
||||
Write-Host " SCEP $(Get-ApiProperty -InputObject $scep -Name 'scepEndpointUrl')"
|
||||
Write-Host " challenge $(Get-ApiProperty -InputObject $scep -Name 'challengeEndpointUrl')"
|
||||
}
|
||||
if ($Null -ne $acme) {
|
||||
Write-Host " ACME $(Get-ApiProperty -InputObject $acme -Name 'directoryUrl')"
|
||||
}
|
||||
}
|
||||
|
||||
<#
|
||||
Reported here because Infisical will not hand the challenge back afterwards: a static SCEP
|
||||
profile is unusable to anyone who did not capture this value. Re-running the script does not
|
||||
@@ -1119,6 +1222,7 @@ Set-StrictMode -Version Latest
|
||||
CertificatePolicies = $policies
|
||||
CertificateProfiles = $profiles
|
||||
Application = $application
|
||||
Enrollment = $enrollment
|
||||
ScepChallengePassword = $Script:ScepChallenge
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user