mirror of
https://github.com/fosrl/pangolin.git
synced 2026-01-29 06:10:47 +00:00
Handle wildcard certs
This commit is contained in:
@@ -10,6 +10,7 @@ export async function getValidCertificatesForDomainsHybrid(domains: Set<string>)
|
||||
Array<{
|
||||
id: number;
|
||||
domain: string;
|
||||
wildcard: boolean;
|
||||
certFile: string | null;
|
||||
keyFile: string | null;
|
||||
expiresAt: Date | null;
|
||||
@@ -68,6 +69,7 @@ export async function getValidCertificatesForDomains(domains: Set<string>): Prom
|
||||
Array<{
|
||||
id: number;
|
||||
domain: string;
|
||||
wildcard: boolean;
|
||||
certFile: string | null;
|
||||
keyFile: string | null;
|
||||
expiresAt: Date | null;
|
||||
|
||||
235
server/lib/traefikConfig.test.ts
Normal file
235
server/lib/traefikConfig.test.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
import { assertEquals } from "@test/assert";
|
||||
import { isDomainCoveredByWildcard } from "./traefikConfig";
|
||||
|
||||
function runTests() {
|
||||
console.log('Running wildcard domain coverage tests...');
|
||||
|
||||
// Test case 1: Basic wildcard certificate at example.com
|
||||
const basicWildcardCerts = new Map([
|
||||
['example.com', { exists: true, wildcard: true }]
|
||||
]);
|
||||
|
||||
// Should match first-level subdomains
|
||||
assertEquals(
|
||||
isDomainCoveredByWildcard('level1.example.com', basicWildcardCerts),
|
||||
true,
|
||||
'Wildcard cert at example.com should match level1.example.com'
|
||||
);
|
||||
|
||||
assertEquals(
|
||||
isDomainCoveredByWildcard('api.example.com', basicWildcardCerts),
|
||||
true,
|
||||
'Wildcard cert at example.com should match api.example.com'
|
||||
);
|
||||
|
||||
assertEquals(
|
||||
isDomainCoveredByWildcard('www.example.com', basicWildcardCerts),
|
||||
true,
|
||||
'Wildcard cert at example.com should match www.example.com'
|
||||
);
|
||||
|
||||
// Should match the root domain (exact match)
|
||||
assertEquals(
|
||||
isDomainCoveredByWildcard('example.com', basicWildcardCerts),
|
||||
true,
|
||||
'Wildcard cert at example.com should match example.com itself'
|
||||
);
|
||||
|
||||
// Should NOT match second-level subdomains
|
||||
assertEquals(
|
||||
isDomainCoveredByWildcard('level2.level1.example.com', basicWildcardCerts),
|
||||
false,
|
||||
'Wildcard cert at example.com should NOT match level2.level1.example.com'
|
||||
);
|
||||
|
||||
assertEquals(
|
||||
isDomainCoveredByWildcard('deep.nested.subdomain.example.com', basicWildcardCerts),
|
||||
false,
|
||||
'Wildcard cert at example.com should NOT match deep.nested.subdomain.example.com'
|
||||
);
|
||||
|
||||
// Should NOT match different domains
|
||||
assertEquals(
|
||||
isDomainCoveredByWildcard('test.otherdomain.com', basicWildcardCerts),
|
||||
false,
|
||||
'Wildcard cert at example.com should NOT match test.otherdomain.com'
|
||||
);
|
||||
|
||||
assertEquals(
|
||||
isDomainCoveredByWildcard('notexample.com', basicWildcardCerts),
|
||||
false,
|
||||
'Wildcard cert at example.com should NOT match notexample.com'
|
||||
);
|
||||
|
||||
// Test case 2: Multiple wildcard certificates
|
||||
const multipleWildcardCerts = new Map([
|
||||
['example.com', { exists: true, wildcard: true }],
|
||||
['test.org', { exists: true, wildcard: true }],
|
||||
['api.service.net', { exists: true, wildcard: true }]
|
||||
]);
|
||||
|
||||
assertEquals(
|
||||
isDomainCoveredByWildcard('app.example.com', multipleWildcardCerts),
|
||||
true,
|
||||
'Should match subdomain of first wildcard cert'
|
||||
);
|
||||
|
||||
assertEquals(
|
||||
isDomainCoveredByWildcard('staging.test.org', multipleWildcardCerts),
|
||||
true,
|
||||
'Should match subdomain of second wildcard cert'
|
||||
);
|
||||
|
||||
assertEquals(
|
||||
isDomainCoveredByWildcard('v1.api.service.net', multipleWildcardCerts),
|
||||
true,
|
||||
'Should match subdomain of third wildcard cert'
|
||||
);
|
||||
|
||||
assertEquals(
|
||||
isDomainCoveredByWildcard('deep.nested.api.service.net', multipleWildcardCerts),
|
||||
false,
|
||||
'Should NOT match multi-level subdomain of third wildcard cert'
|
||||
);
|
||||
|
||||
// Test exact domain matches for multiple certs
|
||||
assertEquals(
|
||||
isDomainCoveredByWildcard('example.com', multipleWildcardCerts),
|
||||
true,
|
||||
'Should match exact domain of first wildcard cert'
|
||||
);
|
||||
|
||||
assertEquals(
|
||||
isDomainCoveredByWildcard('test.org', multipleWildcardCerts),
|
||||
true,
|
||||
'Should match exact domain of second wildcard cert'
|
||||
);
|
||||
|
||||
assertEquals(
|
||||
isDomainCoveredByWildcard('api.service.net', multipleWildcardCerts),
|
||||
true,
|
||||
'Should match exact domain of third wildcard cert'
|
||||
);
|
||||
|
||||
// Test case 3: Non-wildcard certificates (should not match anything)
|
||||
const nonWildcardCerts = new Map([
|
||||
['example.com', { exists: true, wildcard: false }],
|
||||
['specific.domain.com', { exists: true, wildcard: false }]
|
||||
]);
|
||||
|
||||
assertEquals(
|
||||
isDomainCoveredByWildcard('sub.example.com', nonWildcardCerts),
|
||||
false,
|
||||
'Non-wildcard cert should not match subdomains'
|
||||
);
|
||||
|
||||
assertEquals(
|
||||
isDomainCoveredByWildcard('example.com', nonWildcardCerts),
|
||||
false,
|
||||
'Non-wildcard cert should not match even exact domain via this function'
|
||||
);
|
||||
|
||||
// Test case 4: Non-existent certificates (should not match)
|
||||
const nonExistentCerts = new Map([
|
||||
['example.com', { exists: false, wildcard: true }],
|
||||
['missing.com', { exists: false, wildcard: true }]
|
||||
]);
|
||||
|
||||
assertEquals(
|
||||
isDomainCoveredByWildcard('sub.example.com', nonExistentCerts),
|
||||
false,
|
||||
'Non-existent wildcard cert should not match'
|
||||
);
|
||||
|
||||
// Test case 5: Edge cases with special domain names
|
||||
const specialDomainCerts = new Map([
|
||||
['localhost', { exists: true, wildcard: true }],
|
||||
['127-0-0-1.nip.io', { exists: true, wildcard: true }],
|
||||
['xn--e1afmkfd.xn--p1ai', { exists: true, wildcard: true }] // IDN domain
|
||||
]);
|
||||
|
||||
assertEquals(
|
||||
isDomainCoveredByWildcard('app.localhost', specialDomainCerts),
|
||||
true,
|
||||
'Should match subdomain of localhost wildcard'
|
||||
);
|
||||
|
||||
assertEquals(
|
||||
isDomainCoveredByWildcard('test.127-0-0-1.nip.io', specialDomainCerts),
|
||||
true,
|
||||
'Should match subdomain of nip.io wildcard'
|
||||
);
|
||||
|
||||
assertEquals(
|
||||
isDomainCoveredByWildcard('sub.xn--e1afmkfd.xn--p1ai', specialDomainCerts),
|
||||
true,
|
||||
'Should match subdomain of IDN wildcard'
|
||||
);
|
||||
|
||||
// Test case 6: Empty input and edge cases
|
||||
const emptyCerts = new Map();
|
||||
|
||||
assertEquals(
|
||||
isDomainCoveredByWildcard('any.domain.com', emptyCerts),
|
||||
false,
|
||||
'Empty certificate map should not match any domain'
|
||||
);
|
||||
|
||||
// Test case 7: Domains with single character components
|
||||
const singleCharCerts = new Map([
|
||||
['a.com', { exists: true, wildcard: true }],
|
||||
['x.y.z', { exists: true, wildcard: true }]
|
||||
]);
|
||||
|
||||
assertEquals(
|
||||
isDomainCoveredByWildcard('b.a.com', singleCharCerts),
|
||||
true,
|
||||
'Should match single character subdomain'
|
||||
);
|
||||
|
||||
assertEquals(
|
||||
isDomainCoveredByWildcard('w.x.y.z', singleCharCerts),
|
||||
true,
|
||||
'Should match single character subdomain of multi-part domain'
|
||||
);
|
||||
|
||||
assertEquals(
|
||||
isDomainCoveredByWildcard('v.w.x.y.z', singleCharCerts),
|
||||
false,
|
||||
'Should NOT match multi-level subdomain of single char domain'
|
||||
);
|
||||
|
||||
// Test case 8: Domains with numbers and hyphens
|
||||
const numericCerts = new Map([
|
||||
['api-v2.service-1.com', { exists: true, wildcard: true }],
|
||||
['123.456.net', { exists: true, wildcard: true }]
|
||||
]);
|
||||
|
||||
assertEquals(
|
||||
isDomainCoveredByWildcard('staging.api-v2.service-1.com', numericCerts),
|
||||
true,
|
||||
'Should match subdomain with hyphens and numbers'
|
||||
);
|
||||
|
||||
assertEquals(
|
||||
isDomainCoveredByWildcard('test.123.456.net', numericCerts),
|
||||
true,
|
||||
'Should match subdomain with numeric components'
|
||||
);
|
||||
|
||||
assertEquals(
|
||||
isDomainCoveredByWildcard('deep.staging.api-v2.service-1.com', numericCerts),
|
||||
false,
|
||||
'Should NOT match multi-level subdomain with hyphens and numbers'
|
||||
);
|
||||
|
||||
console.log('All wildcard domain coverage tests passed!');
|
||||
}
|
||||
|
||||
// Run all tests
|
||||
try {
|
||||
runTests();
|
||||
} catch (error) {
|
||||
console.error('Test failed:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -29,6 +29,7 @@ export class TraefikConfigManager {
|
||||
exists: boolean;
|
||||
lastModified: Date | null;
|
||||
expiresAt: Date | null;
|
||||
wildcard: boolean;
|
||||
}
|
||||
>();
|
||||
|
||||
@@ -115,6 +116,7 @@ export class TraefikConfigManager {
|
||||
exists: boolean;
|
||||
lastModified: Date | null;
|
||||
expiresAt: Date | null;
|
||||
wildcard: boolean;
|
||||
}
|
||||
>
|
||||
> {
|
||||
@@ -136,13 +138,16 @@ export class TraefikConfigManager {
|
||||
const certPath = path.join(domainDir, "cert.pem");
|
||||
const keyPath = path.join(domainDir, "key.pem");
|
||||
const lastUpdatePath = path.join(domainDir, ".last_update");
|
||||
const wildcardPath = path.join(domainDir, ".wildcard");
|
||||
|
||||
const certExists = await this.fileExists(certPath);
|
||||
const keyExists = await this.fileExists(keyPath);
|
||||
const lastUpdateExists = await this.fileExists(lastUpdatePath);
|
||||
const wildcardExists = await this.fileExists(wildcardPath);
|
||||
|
||||
let lastModified: Date | null = null;
|
||||
const expiresAt: Date | null = null;
|
||||
let wildcard = false;
|
||||
|
||||
if (lastUpdateExists) {
|
||||
try {
|
||||
@@ -161,10 +166,26 @@ export class TraefikConfigManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Check if this is a wildcard certificate
|
||||
if (wildcardExists) {
|
||||
try {
|
||||
const wildcardContent = fs
|
||||
.readFileSync(wildcardPath, "utf8")
|
||||
.trim();
|
||||
wildcard = wildcardContent === "true";
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`Could not read wildcard file for ${domain}:`,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
state.set(domain, {
|
||||
exists: certExists && keyExists,
|
||||
lastModified,
|
||||
expiresAt
|
||||
expiresAt,
|
||||
wildcard
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -192,19 +213,36 @@ export class TraefikConfigManager {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Fetch if domains have changed
|
||||
// Filter out domains covered by wildcard certificates
|
||||
const domainsNeedingCerts = new Set<string>();
|
||||
for (const domain of currentDomains) {
|
||||
if (!isDomainCoveredByWildcard(domain, this.lastLocalCertificateState)) {
|
||||
domainsNeedingCerts.add(domain);
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch if domains needing certificates have changed
|
||||
const lastDomainsNeedingCerts = new Set<string>();
|
||||
for (const domain of this.lastKnownDomains) {
|
||||
if (!isDomainCoveredByWildcard(domain, this.lastLocalCertificateState)) {
|
||||
lastDomainsNeedingCerts.add(domain);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
this.lastKnownDomains.size !== currentDomains.size ||
|
||||
!Array.from(this.lastKnownDomains).every((domain) =>
|
||||
currentDomains.has(domain)
|
||||
domainsNeedingCerts.size !== lastDomainsNeedingCerts.size ||
|
||||
!Array.from(domainsNeedingCerts).every((domain) =>
|
||||
lastDomainsNeedingCerts.has(domain)
|
||||
)
|
||||
) {
|
||||
logger.info("Fetching certificates due to domain changes");
|
||||
logger.info(
|
||||
"Fetching certificates due to domain changes (after wildcard filtering)"
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if any local certificates are missing or appear to be outdated
|
||||
for (const domain of currentDomains) {
|
||||
for (const domain of domainsNeedingCerts) {
|
||||
const localState = this.lastLocalCertificateState.get(domain);
|
||||
if (!localState || !localState.exists) {
|
||||
logger.info(
|
||||
@@ -273,6 +311,7 @@ export class TraefikConfigManager {
|
||||
let validCertificates: Array<{
|
||||
id: number;
|
||||
domain: string;
|
||||
wildcard: boolean;
|
||||
certFile: string | null;
|
||||
keyFile: string | null;
|
||||
expiresAt: Date | null;
|
||||
@@ -280,23 +319,50 @@ export class TraefikConfigManager {
|
||||
}> = [];
|
||||
|
||||
if (this.shouldFetchCertificates(domains)) {
|
||||
// Get valid certificates for active domains
|
||||
if (config.isManagedMode()) {
|
||||
validCertificates =
|
||||
await getValidCertificatesForDomainsHybrid(domains);
|
||||
} else {
|
||||
validCertificates =
|
||||
await getValidCertificatesForDomains(domains);
|
||||
// Filter out domains that are already covered by wildcard certificates
|
||||
const domainsToFetch = new Set<string>();
|
||||
for (const domain of domains) {
|
||||
if (!isDomainCoveredByWildcard(domain, this.lastLocalCertificateState)) {
|
||||
domainsToFetch.add(domain);
|
||||
} else {
|
||||
logger.debug(
|
||||
`Domain ${domain} is covered by existing wildcard certificate, skipping fetch`
|
||||
);
|
||||
}
|
||||
}
|
||||
this.lastCertificateFetch = new Date();
|
||||
this.lastKnownDomains = new Set(domains);
|
||||
|
||||
logger.info(
|
||||
`Fetched ${validCertificates.length} certificates from remote`
|
||||
);
|
||||
if (domainsToFetch.size > 0) {
|
||||
// Get valid certificates for domains not covered by wildcards
|
||||
if (config.isManagedMode()) {
|
||||
validCertificates =
|
||||
await getValidCertificatesForDomainsHybrid(
|
||||
domainsToFetch
|
||||
);
|
||||
} else {
|
||||
validCertificates =
|
||||
await getValidCertificatesForDomains(
|
||||
domainsToFetch
|
||||
);
|
||||
}
|
||||
this.lastCertificateFetch = new Date();
|
||||
this.lastKnownDomains = new Set(domains);
|
||||
|
||||
// Download and decrypt new certificates
|
||||
await this.processValidCertificates(validCertificates);
|
||||
logger.info(
|
||||
`Fetched ${validCertificates.length} certificates from remote (${domains.size - domainsToFetch.size} domains covered by wildcards)`
|
||||
);
|
||||
|
||||
// Download and decrypt new certificates
|
||||
await this.processValidCertificates(validCertificates);
|
||||
} else {
|
||||
logger.info(
|
||||
"All domains are covered by existing wildcard certificates, no fetch needed"
|
||||
);
|
||||
this.lastCertificateFetch = new Date();
|
||||
this.lastKnownDomains = new Set(domains);
|
||||
}
|
||||
|
||||
// Always ensure all existing certificates (including wildcards) are in the config
|
||||
await this.updateDynamicConfigFromLocalCerts(domains);
|
||||
} else {
|
||||
const timeSinceLastFetch = this.lastCertificateFetch
|
||||
? Math.round(
|
||||
@@ -544,7 +610,11 @@ export class TraefikConfigManager {
|
||||
// Clear existing certificates and rebuild from local state
|
||||
dynamicConfig.tls.certificates = [];
|
||||
|
||||
// Keep track of certificates we've already added to avoid duplicates
|
||||
const addedCertPaths = new Set<string>();
|
||||
|
||||
for (const domain of domains) {
|
||||
// First, try to find an exact match certificate
|
||||
const localState = this.lastLocalCertificateState.get(domain);
|
||||
if (localState && localState.exists) {
|
||||
const domainDir = path.join(
|
||||
@@ -554,11 +624,47 @@ export class TraefikConfigManager {
|
||||
const certPath = path.join(domainDir, "cert.pem");
|
||||
const keyPath = path.join(domainDir, "key.pem");
|
||||
|
||||
const certEntry = {
|
||||
certFile: certPath,
|
||||
keyFile: keyPath
|
||||
};
|
||||
dynamicConfig.tls.certificates.push(certEntry);
|
||||
if (!addedCertPaths.has(certPath)) {
|
||||
const certEntry = {
|
||||
certFile: certPath,
|
||||
keyFile: keyPath
|
||||
};
|
||||
dynamicConfig.tls.certificates.push(certEntry);
|
||||
addedCertPaths.add(certPath);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// If no exact match, check for wildcard certificates that cover this domain
|
||||
for (const [certDomain, certState] of this.lastLocalCertificateState) {
|
||||
if (certState.exists && certState.wildcard) {
|
||||
// Check if this wildcard certificate covers the domain
|
||||
if (domain.endsWith("." + certDomain)) {
|
||||
// Verify it's only one level deep (wildcard only covers one level)
|
||||
const prefix = domain.substring(
|
||||
0,
|
||||
domain.length - ("." + certDomain).length
|
||||
);
|
||||
if (!prefix.includes(".")) {
|
||||
const domainDir = path.join(
|
||||
config.getRawConfig().traefik.certificates_path,
|
||||
certDomain
|
||||
);
|
||||
const certPath = path.join(domainDir, "cert.pem");
|
||||
const keyPath = path.join(domainDir, "key.pem");
|
||||
|
||||
if (!addedCertPaths.has(certPath)) {
|
||||
const certEntry = {
|
||||
certFile: certPath,
|
||||
keyFile: keyPath
|
||||
};
|
||||
dynamicConfig.tls.certificates.push(certEntry);
|
||||
addedCertPaths.add(certPath);
|
||||
}
|
||||
break; // Found a wildcard that covers this domain
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -577,6 +683,7 @@ export class TraefikConfigManager {
|
||||
validCertificates: Array<{
|
||||
id: number;
|
||||
domain: string;
|
||||
wildcard: boolean;
|
||||
certFile: string | null;
|
||||
keyFile: string | null;
|
||||
expiresAt: Date | null;
|
||||
@@ -651,15 +758,24 @@ export class TraefikConfigManager {
|
||||
"utf8"
|
||||
);
|
||||
|
||||
// Check if this is a wildcard certificate and store it
|
||||
const wildcardPath = path.join(domainDir, ".wildcard");
|
||||
fs.writeFileSync(
|
||||
wildcardPath,
|
||||
cert.wildcard ? "true" : "false",
|
||||
"utf8"
|
||||
);
|
||||
|
||||
logger.info(
|
||||
`Certificate updated for domain: ${cert.domain}`
|
||||
`Certificate updated for domain: ${cert.domain}${cert.wildcard ? " (wildcard)" : ""}`
|
||||
);
|
||||
|
||||
// Update local state tracking
|
||||
this.lastLocalCertificateState.set(cert.domain, {
|
||||
exists: true,
|
||||
lastModified: new Date(),
|
||||
expiresAt: cert.expiresAt
|
||||
expiresAt: cert.expiresAt,
|
||||
wildcard: cert.wildcard
|
||||
});
|
||||
}
|
||||
|
||||
@@ -810,14 +926,8 @@ export class TraefikConfigManager {
|
||||
this.lastLocalCertificateState.delete(dirName);
|
||||
|
||||
// Remove from dynamic config
|
||||
const certFilePath = path.join(
|
||||
domainDir,
|
||||
"cert.pem"
|
||||
);
|
||||
const keyFilePath = path.join(
|
||||
domainDir,
|
||||
"key.pem"
|
||||
);
|
||||
const certFilePath = path.join(domainDir, "cert.pem");
|
||||
const keyFilePath = path.join(domainDir, "key.pem");
|
||||
const before = dynamicConfig.tls.certificates.length;
|
||||
dynamicConfig.tls.certificates =
|
||||
dynamicConfig.tls.certificates.filter(
|
||||
@@ -894,14 +1004,58 @@ export class TraefikConfigManager {
|
||||
monitorInterval: number;
|
||||
lastCertificateFetch: Date | null;
|
||||
localCertificateCount: number;
|
||||
wildcardCertificates: string[];
|
||||
domainsCoveredByWildcards: string[];
|
||||
} {
|
||||
const wildcardCertificates: string[] = [];
|
||||
const domainsCoveredByWildcards: string[] = [];
|
||||
|
||||
// Find wildcard certificates
|
||||
for (const [domain, state] of this.lastLocalCertificateState) {
|
||||
if (state.exists && state.wildcard) {
|
||||
wildcardCertificates.push(domain);
|
||||
}
|
||||
}
|
||||
|
||||
// Find domains covered by wildcards
|
||||
for (const domain of this.activeDomains) {
|
||||
if (isDomainCoveredByWildcard(domain, this.lastLocalCertificateState)) {
|
||||
domainsCoveredByWildcards.push(domain);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isRunning: this.isRunning,
|
||||
activeDomains: Array.from(this.activeDomains),
|
||||
monitorInterval:
|
||||
config.getRawConfig().traefik.monitor_interval || 5000,
|
||||
lastCertificateFetch: this.lastCertificateFetch,
|
||||
localCertificateCount: this.lastLocalCertificateState.size
|
||||
localCertificateCount: this.lastLocalCertificateState.size,
|
||||
wildcardCertificates,
|
||||
domainsCoveredByWildcards
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a domain is covered by existing wildcard certificates
|
||||
*/
|
||||
export function isDomainCoveredByWildcard(domain: string, lastLocalCertificateState: Map<string, { exists: boolean; wildcard: boolean }>): boolean {
|
||||
for (const [certDomain, state] of lastLocalCertificateState) {
|
||||
if (state.exists && state.wildcard) {
|
||||
// If stored as example.com but is wildcard, check subdomains
|
||||
if (domain.endsWith("." + certDomain)) {
|
||||
// Check that it's only one level deep (wildcard only covers one level)
|
||||
const prefix = domain.substring(
|
||||
0,
|
||||
domain.length - ("." + certDomain).length
|
||||
);
|
||||
// If prefix contains a dot, it's more than one level deep
|
||||
if (!prefix.includes(".")) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user