Add SSL certificate mode: Let's Encrypt or Wildcard per NPM
Settings > NPM Integration now allows choosing between per-customer Let's Encrypt certificates (default) or a shared wildcard certificate already uploaded in NPM. Includes backend, frontend UI, and i18n support. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
25
README.md
25
README.md
@@ -34,6 +34,7 @@ A management solution for running isolated NetBird instances for your MSP busine
|
||||
- **Complete Isolation** — Each customer gets their own NetBird stack with separate data
|
||||
- **One-Click Deployment** — Deploy new customer instances in under 2 minutes
|
||||
- **Nginx Proxy Manager Integration** — Automatic SSL certificates and reverse proxy setup
|
||||
- **SSL Certificate Modes** — Choose between per-customer Let's Encrypt certificates or a shared wildcard certificate
|
||||
- **Docker-Based** — Everything runs in containers for easy deployment
|
||||
|
||||
### Dashboard
|
||||
@@ -269,7 +270,8 @@ Available under **Settings** in the web interface:
|
||||
|
||||
| Tab | Settings |
|
||||
|-----|----------|
|
||||
| **System** | Base domain, admin email, NPM credentials, Docker images, port ranges, data directory |
|
||||
| **System** | Base domain, admin email, Docker images, port ranges, data directory |
|
||||
| **NPM Integration** | NPM API URL, login credentials, SSL certificate mode (Let's Encrypt / Wildcard), wildcard certificate selection |
|
||||
| **Branding** | Platform name, subtitle, logo upload, default language |
|
||||
| **Users** | Create/edit/delete admin users, per-user language preference, MFA reset |
|
||||
| **Azure AD** | Azure AD / Entra ID SSO configuration |
|
||||
@@ -342,6 +344,26 @@ When MFA is enabled and a user logs in for the first time:
|
||||
- **Disable own TOTP** — In Settings > Security, click "Disable my TOTP" to remove your own MFA setup
|
||||
- **Disable MFA globally** — Uncheck the toggle in Settings > Security to allow login without MFA
|
||||
|
||||
### SSL Certificate Mode
|
||||
|
||||
The appliance supports two SSL certificate modes for customer proxy hosts, configurable under **Settings > NPM Integration**:
|
||||
|
||||
#### Let's Encrypt (default)
|
||||
Each customer gets an individual Let's Encrypt certificate via HTTP-01 validation. This is the default behavior and requires no additional setup beyond a valid admin email.
|
||||
|
||||
#### Wildcard Certificate
|
||||
Use a pre-existing wildcard certificate (e.g. `*.yourdomain.com`) already uploaded in NPM. All customer proxy hosts share this certificate — no per-customer LE validation needed.
|
||||
|
||||
**Setup:**
|
||||
1. Upload a wildcard certificate in Nginx Proxy Manager (e.g. via DNS challenge)
|
||||
2. Go to **Settings > NPM Integration**
|
||||
3. Set **SSL Mode** to "Wildcard Certificate"
|
||||
4. Click the refresh button to load certificates from NPM
|
||||
5. Select your wildcard certificate from the dropdown
|
||||
6. Click **Save NPM Settings**
|
||||
|
||||
New customer deployments will automatically use the selected wildcard certificate.
|
||||
|
||||
---
|
||||
|
||||
## API Documentation
|
||||
@@ -376,6 +398,7 @@ GET /api/customers/{id}/logs # Get container logs
|
||||
GET /api/customers/{id}/health # Health check
|
||||
|
||||
GET /api/settings/branding # Get branding (public, no auth)
|
||||
GET /api/settings/npm-certificates # List NPM SSL certificates
|
||||
PUT /api/settings # Update system settings
|
||||
GET /api/users # List users
|
||||
POST /api/users # Create user
|
||||
|
||||
@@ -83,6 +83,8 @@ def _run_migrations() -> None:
|
||||
("system_config", "mfa_enabled", "BOOLEAN DEFAULT 0"),
|
||||
("users", "totp_secret_encrypted", "TEXT"),
|
||||
("users", "totp_enabled", "BOOLEAN DEFAULT 0"),
|
||||
("system_config", "ssl_mode", "TEXT DEFAULT 'letsencrypt'"),
|
||||
("system_config", "wildcard_cert_id", "INTEGER"),
|
||||
]
|
||||
for table, column, col_type in migrations:
|
||||
if not _has_column(table, column):
|
||||
|
||||
@@ -161,6 +161,8 @@ class SystemConfig(Base):
|
||||
)
|
||||
branding_logo_path: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||
default_language: Mapped[Optional[str]] = mapped_column(String(10), default="en")
|
||||
ssl_mode: Mapped[str] = mapped_column(String(20), default="letsencrypt")
|
||||
wildcard_cert_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||
mfa_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
azure_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
azure_tenant_id: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
@@ -194,6 +196,8 @@ class SystemConfig(Base):
|
||||
"branding_subtitle": self.branding_subtitle or "Multi-Tenant Management Platform",
|
||||
"branding_logo_path": self.branding_logo_path,
|
||||
"default_language": self.default_language or "en",
|
||||
"ssl_mode": self.ssl_mode or "letsencrypt",
|
||||
"wildcard_cert_id": self.wildcard_cert_id,
|
||||
"mfa_enabled": bool(self.mfa_enabled),
|
||||
"azure_enabled": bool(self.azure_enabled),
|
||||
"azure_tenant_id": self.azure_tenant_id or "",
|
||||
|
||||
@@ -129,6 +129,41 @@ async def test_npm(
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/npm-certificates")
|
||||
async def list_npm_certificates(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""List all SSL certificates configured in NPM.
|
||||
|
||||
Used by the frontend to populate the wildcard certificate dropdown.
|
||||
|
||||
Returns:
|
||||
List of certificate dicts with id, domain_names, provider, expires_on, is_wildcard.
|
||||
"""
|
||||
config = get_system_config(db)
|
||||
if not config:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="System configuration not initialized.",
|
||||
)
|
||||
if not config.npm_api_url or not config.npm_api_email or not config.npm_api_password:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="NPM API URL or credentials not configured.",
|
||||
)
|
||||
|
||||
result = await npm_service.list_certificates(
|
||||
config.npm_api_url, config.npm_api_email, config.npm_api_password
|
||||
)
|
||||
if "error" in result:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=result["error"],
|
||||
)
|
||||
return result["certificates"]
|
||||
|
||||
|
||||
@router.get("/branding")
|
||||
async def get_branding(db: Session = Depends(get_db)):
|
||||
"""Public endpoint — returns branding info for the login page (no auth required)."""
|
||||
|
||||
@@ -277,6 +277,8 @@ async def deploy_customer(db: Session, customer_id: int) -> dict[str, Any]:
|
||||
forward_host=forward_host,
|
||||
forward_port=dashboard_port,
|
||||
admin_email=config.admin_email,
|
||||
ssl_mode=config.ssl_mode,
|
||||
wildcard_cert_id=config.wildcard_cert_id,
|
||||
)
|
||||
npm_proxy_id = npm_result.get("proxy_id")
|
||||
if npm_result.get("error"):
|
||||
|
||||
@@ -112,6 +112,45 @@ async def test_npm_connection(api_url: str, email: str, password: str) -> dict[s
|
||||
return {"ok": False, "message": f"Unexpected error: {exc}"}
|
||||
|
||||
|
||||
async def list_certificates(api_url: str, email: str, password: str) -> dict[str, Any]:
|
||||
"""Fetch all SSL certificates from NPM.
|
||||
|
||||
Args:
|
||||
api_url: NPM API base URL.
|
||||
email: NPM login email.
|
||||
password: NPM login password.
|
||||
|
||||
Returns:
|
||||
Dict with ``certificates`` list on success, or ``error`` on failure.
|
||||
"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=NPM_TIMEOUT) as client:
|
||||
token = await _npm_login(client, api_url, email, password)
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
resp = await client.get(f"{api_url}/nginx/certificates", headers=headers)
|
||||
if resp.status_code == 200:
|
||||
result = []
|
||||
for cert in resp.json():
|
||||
domains = cert.get("domain_names", [])
|
||||
result.append({
|
||||
"id": cert.get("id"),
|
||||
"domain_names": domains,
|
||||
"provider": cert.get("provider", "unknown"),
|
||||
"expires_on": cert.get("expires_on"),
|
||||
"is_wildcard": any(d.startswith("*.") for d in domains),
|
||||
})
|
||||
return {"certificates": result}
|
||||
return {"error": f"NPM returned {resp.status_code}: {resp.text[:200]}"}
|
||||
except RuntimeError as exc:
|
||||
return {"error": str(exc)}
|
||||
except httpx.ConnectError:
|
||||
return {"error": "Connection refused. Is NPM running and reachable?"}
|
||||
except httpx.TimeoutException:
|
||||
return {"error": "Connection timed out."}
|
||||
except Exception as exc:
|
||||
return {"error": f"Unexpected error: {exc}"}
|
||||
|
||||
|
||||
async def _find_cert_by_domain(
|
||||
client: httpx.AsyncClient, api_url: str, headers: dict, domain: str
|
||||
) -> int | None:
|
||||
@@ -169,6 +208,8 @@ async def create_proxy_host(
|
||||
forward_host: str,
|
||||
forward_port: int = 80,
|
||||
admin_email: str = "",
|
||||
ssl_mode: str = "letsencrypt",
|
||||
wildcard_cert_id: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Create a proxy host entry in NPM with SSL for a customer.
|
||||
|
||||
@@ -265,7 +306,10 @@ async def create_proxy_host(
|
||||
return {"error": error_msg}
|
||||
|
||||
# Step 2: Request SSL certificate and enable HTTPS
|
||||
ssl_ok = await _request_ssl(client, api_url, headers, proxy_id, domain, admin_email)
|
||||
ssl_ok = await _request_ssl(
|
||||
client, api_url, headers, proxy_id, domain, admin_email,
|
||||
ssl_mode=ssl_mode, wildcard_cert_id=wildcard_cert_id,
|
||||
)
|
||||
|
||||
return {"proxy_id": proxy_id, "ssl": ssl_ok}
|
||||
except RuntimeError as exc:
|
||||
@@ -283,13 +327,14 @@ async def _request_ssl(
|
||||
proxy_id: int,
|
||||
domain: str,
|
||||
admin_email: str,
|
||||
ssl_mode: str = "letsencrypt",
|
||||
wildcard_cert_id: int | None = None,
|
||||
) -> bool:
|
||||
"""Request a Let's Encrypt SSL certificate and enable HTTPS on the proxy host.
|
||||
"""Request an SSL certificate and enable HTTPS on the proxy host.
|
||||
|
||||
Flow:
|
||||
1. Create LE certificate via NPM API (HTTP-01 validation, up to 120s)
|
||||
2. Assign certificate to the proxy host
|
||||
3. Enable ssl_forced + hsts on the proxy host
|
||||
Supports two modes:
|
||||
- ``letsencrypt``: Create a per-domain LE certificate (HTTP-01 validation).
|
||||
- ``wildcard``: Assign a pre-existing wildcard certificate from NPM.
|
||||
|
||||
Args:
|
||||
client: httpx client (already authenticated).
|
||||
@@ -298,10 +343,49 @@ async def _request_ssl(
|
||||
proxy_id: The proxy host ID.
|
||||
domain: The domain to certify.
|
||||
admin_email: Contact email for LE.
|
||||
ssl_mode: ``"letsencrypt"`` or ``"wildcard"``.
|
||||
wildcard_cert_id: NPM certificate ID for wildcard mode.
|
||||
|
||||
Returns:
|
||||
True if SSL was successfully enabled, False otherwise.
|
||||
"""
|
||||
# Wildcard mode: assign the pre-existing wildcard cert directly
|
||||
if ssl_mode == "wildcard" and wildcard_cert_id:
|
||||
logger.info(
|
||||
"Wildcard mode: assigning cert id=%s to proxy host %s for %s",
|
||||
wildcard_cert_id, proxy_id, domain,
|
||||
)
|
||||
ssl_update = {
|
||||
"certificate_id": wildcard_cert_id,
|
||||
"ssl_forced": True,
|
||||
"hsts_enabled": True,
|
||||
"http2_support": True,
|
||||
}
|
||||
try:
|
||||
update_resp = await client.put(
|
||||
f"{api_url}/nginx/proxy-hosts/{proxy_id}",
|
||||
json=ssl_update,
|
||||
headers=headers,
|
||||
)
|
||||
if update_resp.status_code in (200, 201):
|
||||
logger.info(
|
||||
"SSL enabled on proxy host %s (wildcard cert_id=%s)",
|
||||
proxy_id, wildcard_cert_id,
|
||||
)
|
||||
return True
|
||||
logger.error(
|
||||
"Failed to assign wildcard cert %s to proxy host %s: HTTP %s — %s",
|
||||
wildcard_cert_id, proxy_id,
|
||||
update_resp.status_code, update_resp.text[:300],
|
||||
)
|
||||
return False
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"Failed to assign wildcard cert to proxy host %s: %s", proxy_id, exc,
|
||||
)
|
||||
return False
|
||||
|
||||
# Let's Encrypt mode (default)
|
||||
if not admin_email:
|
||||
logger.warning("No admin email set — skipping SSL certificate for %s", domain)
|
||||
return False
|
||||
|
||||
@@ -31,6 +31,8 @@ class AppConfig:
|
||||
docker_network: str
|
||||
relay_base_port: int
|
||||
dashboard_base_port: int
|
||||
ssl_mode: str
|
||||
wildcard_cert_id: int | None
|
||||
|
||||
|
||||
# Environment-level settings (not stored in DB)
|
||||
@@ -79,4 +81,6 @@ def get_system_config(db: Session) -> Optional[AppConfig]:
|
||||
docker_network=row.docker_network,
|
||||
relay_base_port=row.relay_base_port,
|
||||
dashboard_base_port=getattr(row, "dashboard_base_port", 9000) or 9000,
|
||||
ssl_mode=getattr(row, "ssl_mode", "letsencrypt") or "letsencrypt",
|
||||
wildcard_cert_id=getattr(row, "wildcard_cert_id", None),
|
||||
)
|
||||
|
||||
@@ -126,12 +126,25 @@ class SystemConfigUpdate(BaseModel):
|
||||
branding_name: Optional[str] = Field(None, max_length=255)
|
||||
branding_subtitle: Optional[str] = Field(None, max_length=255)
|
||||
default_language: Optional[str] = Field(None, max_length=10)
|
||||
ssl_mode: Optional[str] = Field(None, max_length=20)
|
||||
wildcard_cert_id: Optional[int] = Field(None, ge=0)
|
||||
mfa_enabled: Optional[bool] = None
|
||||
azure_enabled: Optional[bool] = None
|
||||
azure_tenant_id: Optional[str] = Field(None, max_length=255)
|
||||
azure_client_id: Optional[str] = Field(None, max_length=255)
|
||||
azure_client_secret: Optional[str] = None # encrypted before storage
|
||||
|
||||
@field_validator("ssl_mode")
|
||||
@classmethod
|
||||
def validate_ssl_mode(cls, v: Optional[str]) -> Optional[str]:
|
||||
"""SSL mode must be 'letsencrypt' or 'wildcard'."""
|
||||
if v is None:
|
||||
return v
|
||||
allowed = {"letsencrypt", "wildcard"}
|
||||
if v not in allowed:
|
||||
raise ValueError(f"ssl_mode must be one of: {', '.join(sorted(allowed))}")
|
||||
return v
|
||||
|
||||
@field_validator("base_domain")
|
||||
@classmethod
|
||||
def validate_domain(cls, v: Optional[str]) -> Optional[str]:
|
||||
|
||||
@@ -381,6 +381,32 @@
|
||||
<button class="btn btn-outline-secondary" type="button" onclick="togglePasswordVisibility('cfg-npm-api-password')"><i class="bi bi-eye"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- SSL Certificate Mode -->
|
||||
<div class="col-12 mt-3">
|
||||
<hr class="my-2">
|
||||
<h6 class="mb-2" data-i18n="settings.sslModeTitle">SSL Certificate Mode</h6>
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
<label class="form-label" data-i18n="settings.sslMode">SSL Mode</label>
|
||||
<select class="form-select" id="cfg-ssl-mode" onchange="onSslModeChange()">
|
||||
<option value="letsencrypt" data-i18n="settings.sslModeLetsencrypt">Per-Customer Let's Encrypt Certificate</option>
|
||||
<option value="wildcard" data-i18n="settings.sslModeWildcard">Wildcard Certificate (pre-configured in NPM)</option>
|
||||
</select>
|
||||
<div class="form-text" data-i18n="settings.sslModeHint">Choose how SSL certificates are assigned to customer proxy hosts.</div>
|
||||
</div>
|
||||
<div class="col-md-8" id="wildcard-cert-section" style="display:none;">
|
||||
<label class="form-label" data-i18n="settings.wildcardCertificate">Wildcard Certificate</label>
|
||||
<div class="input-group">
|
||||
<select class="form-select" id="cfg-wildcard-cert-id">
|
||||
<option value="" data-i18n="settings.selectCertificate">-- Select a certificate --</option>
|
||||
</select>
|
||||
<button type="button" class="btn btn-outline-secondary" onclick="loadNpmCertificates()" title="Refresh">
|
||||
<i class="bi bi-arrow-clockwise"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="form-text" data-i18n="settings.wildcardCertHint">Select the wildcard certificate (e.g. *.example.com) already uploaded in NPM.</div>
|
||||
<div id="wildcard-cert-status" class="mt-1"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<button type="submit" class="btn btn-primary me-2"><i class="bi bi-save me-1"></i><span data-i18n="settings.saveNpmSettings">Save NPM Settings</span></button>
|
||||
|
||||
@@ -816,6 +816,14 @@ async function loadSettings() {
|
||||
document.getElementById('cfg-dashboard-base-port').value = cfg.dashboard_base_port || 9000;
|
||||
document.getElementById('cfg-npm-api-url').value = cfg.npm_api_url || '';
|
||||
document.getElementById('npm-credentials-status').textContent = cfg.npm_credentials_set ? t('settings.credentialsSet') : t('settings.noCredentials');
|
||||
|
||||
// SSL mode
|
||||
document.getElementById('cfg-ssl-mode').value = cfg.ssl_mode || 'letsencrypt';
|
||||
onSslModeChange();
|
||||
if (cfg.ssl_mode === 'wildcard') {
|
||||
loadNpmCertificates(cfg.wildcard_cert_id);
|
||||
}
|
||||
|
||||
document.getElementById('cfg-mgmt-image').value = cfg.netbird_management_image || '';
|
||||
document.getElementById('cfg-signal-image').value = cfg.netbird_signal_image || '';
|
||||
document.getElementById('cfg-relay-image').value = cfg.netbird_relay_image || '';
|
||||
@@ -876,6 +884,14 @@ document.getElementById('settings-npm-form').addEventListener('submit', async (e
|
||||
const password = document.getElementById('cfg-npm-api-password').value;
|
||||
if (email) payload.npm_api_email = email;
|
||||
if (password) payload.npm_api_password = password;
|
||||
|
||||
// SSL mode
|
||||
const sslMode = document.getElementById('cfg-ssl-mode').value;
|
||||
payload.ssl_mode = sslMode;
|
||||
if (sslMode === 'wildcard') {
|
||||
const certId = document.getElementById('cfg-wildcard-cert-id').value;
|
||||
if (certId) payload.wildcard_cert_id = parseInt(certId);
|
||||
}
|
||||
try {
|
||||
await api('PUT', '/settings/system', payload);
|
||||
showSettingsAlert('success', t('messages.npmSettingsSaved'));
|
||||
@@ -924,6 +940,42 @@ async function testNpmConnection() {
|
||||
}
|
||||
}
|
||||
|
||||
// SSL mode toggle
|
||||
function onSslModeChange() {
|
||||
const mode = document.getElementById('cfg-ssl-mode').value;
|
||||
const section = document.getElementById('wildcard-cert-section');
|
||||
section.style.display = mode === 'wildcard' ? '' : 'none';
|
||||
}
|
||||
|
||||
// Load NPM wildcard certificates into dropdown
|
||||
async function loadNpmCertificates(preselectId) {
|
||||
const select = document.getElementById('cfg-wildcard-cert-id');
|
||||
const statusEl = document.getElementById('wildcard-cert-status');
|
||||
select.innerHTML = `<option value="">${t('settings.selectCertificate')}</option>`;
|
||||
statusEl.textContent = t('common.loading');
|
||||
statusEl.className = 'mt-1 text-muted';
|
||||
|
||||
try {
|
||||
const certs = await api('GET', '/settings/npm-certificates');
|
||||
const wildcards = certs.filter(c => c.is_wildcard || (c.domain_names && c.domain_names.some(d => d.startsWith('*.'))));
|
||||
wildcards.forEach(c => {
|
||||
const domains = (c.domain_names || []).join(', ');
|
||||
const expires = c.expires_on ? ` (${t('settings.expiresOn')}: ${new Date(c.expires_on).toLocaleDateString()})` : '';
|
||||
const opt = document.createElement('option');
|
||||
opt.value = c.id;
|
||||
opt.textContent = `${domains}${expires}`;
|
||||
select.appendChild(opt);
|
||||
});
|
||||
if (preselectId) select.value = preselectId;
|
||||
statusEl.textContent = t('settings.certsLoaded', { count: wildcards.length });
|
||||
statusEl.className = wildcards.length > 0 ? 'mt-1 text-success small' : 'mt-1 text-warning small';
|
||||
if (wildcards.length === 0) statusEl.textContent = t('settings.noWildcardCerts');
|
||||
} catch (err) {
|
||||
statusEl.textContent = t('errors.failed', { error: err.message });
|
||||
statusEl.className = 'mt-1 text-danger small';
|
||||
}
|
||||
}
|
||||
|
||||
// Change password form
|
||||
document.getElementById('change-password-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -147,6 +147,17 @@
|
||||
"noCredentials": "Keine NPM-Zugangsdaten konfiguriert",
|
||||
"saveNpmSettings": "NPM Einstellungen speichern",
|
||||
"testConnection": "Verbindung testen",
|
||||
"sslModeTitle": "SSL-Zertifikat Modus",
|
||||
"sslMode": "SSL Modus",
|
||||
"sslModeLetsencrypt": "Let's Encrypt (pro Kunde)",
|
||||
"sslModeWildcard": "Wildcard-Zertifikat",
|
||||
"sslModeHint": "Waehlen Sie, ob jeder Kunde ein eigenes Let's Encrypt Zertifikat erhaelt oder ein gemeinsames Wildcard-Zertifikat verwendet wird.",
|
||||
"wildcardCertificate": "Wildcard-Zertifikat",
|
||||
"selectCertificate": "-- Zertifikat waehlen --",
|
||||
"wildcardCertHint": "Waehlen Sie das Wildcard-Zertifikat (z.B. *.example.com), das bereits in NPM hochgeladen ist.",
|
||||
"noWildcardCerts": "Keine Wildcard-Zertifikate in NPM gefunden.",
|
||||
"certsLoaded": "{count} Wildcard-Zertifikat(e) gefunden.",
|
||||
"expiresOn": "Ablaufdatum",
|
||||
"managementImage": "Management Image",
|
||||
"managementImagePlaceholder": "netbirdio/management:latest",
|
||||
"signalImage": "Signal Image",
|
||||
|
||||
@@ -147,6 +147,17 @@
|
||||
"noCredentials": "No NPM credentials configured",
|
||||
"saveNpmSettings": "Save NPM Settings",
|
||||
"testConnection": "Test Connection",
|
||||
"sslModeTitle": "SSL Certificate Mode",
|
||||
"sslMode": "SSL Mode",
|
||||
"sslModeLetsencrypt": "Let's Encrypt (per customer)",
|
||||
"sslModeWildcard": "Wildcard Certificate",
|
||||
"sslModeHint": "Choose whether each customer gets an individual Let's Encrypt certificate or uses a shared wildcard certificate.",
|
||||
"wildcardCertificate": "Wildcard Certificate",
|
||||
"selectCertificate": "-- Select certificate --",
|
||||
"wildcardCertHint": "Select the wildcard certificate (e.g. *.example.com) already uploaded in NPM.",
|
||||
"noWildcardCerts": "No wildcard certificates found in NPM.",
|
||||
"certsLoaded": "{count} wildcard certificate(s) found.",
|
||||
"expiresOn": "Expires",
|
||||
"managementImage": "Management Image",
|
||||
"managementImagePlaceholder": "netbirdio/management:latest",
|
||||
"signalImage": "Signal Image",
|
||||
|
||||
Reference in New Issue
Block a user