Skip to content

Commit 1bbd477

Browse files
committed
fix: multiple bug fixes for custom cert, SSH key auth, and UI polish
- NPM custom cert: use two-step upload flow (create shell then upload files) so NPM correctly parses and stores the real expires_on instead of now() - SSH key auth: remove hard-coded paramiko.DSSKey reference (removed in paramiko 5.0.0); use dynamic paramiko.key_classes with fallback - SSH key auth: add PPK format detection with clear conversion guidance - SSH key auth: make SFTP verification non-fatal (fallback to /) so connections without SFTP subsystem still pass the test - Frontend: parse FastAPI default error 'detail' field (singular) in addition to 'details' (plural) for 500 responses - Certificate dropdown: show expiry date with locale-aware formatting (e.g. '27th September 2027, 5:28 AM' in EN, '2027年9月27日 05:28' in ZH) - Delete-domain dialog: align to top like uninstall dialog - Toolbar buttons: use opacity-based disabled style instead of washed-out color change
1 parent c02ef6f commit 1bbd477

5 files changed

Lines changed: 149 additions & 25 deletions

File tree

apphub/src/external/nginx_proxy_manager_api.py

Lines changed: 56 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
import socket
44
from urllib.parse import urlparse
55

6+
import requests
7+
68
from src.core.apiHelper import APIHelper
79
from src.core.config import ConfigManager
810
from src.core.logger import logger
@@ -216,22 +218,69 @@ def create_custom_certificate(self, nice_name: str, certificate_pem: str, key_pe
216218
"""
217219
Upload a custom (non-Let's Encrypt) SSL certificate.
218220
221+
NPM requires a two-step flow for custom certificates:
222+
1. Create a certificate shell (provider + nice_name only)
223+
2. Upload the PEM files so NPM can parse and store the real expires_on
224+
219225
Args:
220226
nice_name (str): Display name for the certificate
221227
certificate_pem (str): PEM-encoded certificate content
222228
key_pem (str): PEM-encoded private key content
223229
224230
Returns:
225-
Response: Response from Nginx Proxy Manager API
231+
Response: Response from Nginx Proxy Manager API (the final certificate with correct expires_on)
226232
"""
227-
return self.api.post(
233+
# Step 1: Create certificate shell
234+
create_resp = self.api.post(
228235
path="nginx/certificates",
229236
json={
230237
"provider": "other",
231238
"nice_name": nice_name,
232-
"meta": {
233-
"certificate": certificate_pem,
234-
"certificate_key": key_pem,
235-
},
236239
},
237-
)
240+
)
241+
if create_resp.status_code not in [200, 201]:
242+
return create_resp
243+
244+
certificate = create_resp.json()
245+
cert_id = certificate.get("id")
246+
if not cert_id:
247+
return create_resp
248+
249+
# Step 2: Upload certificate files so NPM parses them and updates expires_on
250+
base_url = self.api.base_url
251+
url = f"{base_url}/nginx/certificates/{cert_id}/upload"
252+
merged_headers = dict(self.api.headers)
253+
merged_headers.pop("Content-Type", None) # Let requests set the multipart boundary
254+
255+
try:
256+
upload_resp = requests.post(
257+
url,
258+
files={
259+
"certificate": ("certificate.pem", certificate_pem, "application/x-pem-file"),
260+
"certificate_key": ("certificate_key.pem", key_pem, "application/x-pem-file"),
261+
},
262+
headers=merged_headers,
263+
verify=self.api.verify,
264+
)
265+
except requests.RequestException as exc:
266+
logger.error(f"Certificate file upload failed: {exc}")
267+
# Return the create response as fallback; the caller can still use the cert shell
268+
return create_resp
269+
270+
if upload_resp.status_code not in [200, 201]:
271+
logger.error(
272+
f"NPM upload returned {upload_resp.status_code}: {upload_resp.text[:500]}"
273+
)
274+
return create_resp
275+
276+
# Step 3: Fetch the updated certificate to get the real expires_on
277+
get_resp = self.api.get(
278+
path=f"nginx/certificates/{cert_id}",
279+
params={"expand": "owner,proxy_hosts,dead_hosts,redirection_hosts"},
280+
)
281+
if get_resp.status_code == 200:
282+
# Patch the response so downstream code sees a proper status code
283+
get_resp.status_code = create_resp.status_code
284+
return get_resp
285+
286+
return create_resp

apphub/src/services/host_access.py

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1036,7 +1036,12 @@ def _verify_profile(self, profile: dict[str, Any]) -> dict[str, Any]:
10361036
except CustomException:
10371037
raise
10381038
except Exception as exc:
1039-
raise CustomException(500, "SFTP Error", f"Failed to open SFTP session: {exc}")
1039+
logger.warning(
1040+
f"SFTP verification failed for {verified.get('username')}@{verified.get('host')}: "
1041+
f"{type(exc).__name__}: {exc}. Falling back to root directory."
1042+
)
1043+
if not str(verified.get("working_directory") or "").strip():
1044+
verified["working_directory"] = "/"
10401045
finally:
10411046
if sftp is not None:
10421047
sftp.close()
@@ -1208,10 +1213,32 @@ def _ensure_parent_directory_exists(self, sftp: paramiko.SFTPClient, path: str)
12081213
self._resolve_directory_path(sftp, parent_path)
12091214

12101215
def _parse_private_key(self, private_key: str, passphrase: Optional[str]) -> paramiko.PKey:
1211-
errors = []
1212-
for key_class in (paramiko.RSAKey, paramiko.Ed25519Key, paramiko.ECDSAKey, paramiko.DSSKey):
1216+
stripped = (private_key or "").strip()
1217+
if stripped.startswith("PuTTY-User-Key-File"):
1218+
raise CustomException(
1219+
400,
1220+
"Unsupported Key Format",
1221+
"PuTTY PPK keys are not supported. Use PuTTYgen to convert the key to OpenSSH format "
1222+
"(Conversions → Export OpenSSH key), then paste the exported content.",
1223+
)
1224+
# Use paramiko's built-in key_classes list when available (paramiko ≥5.x),
1225+
# otherwise fall back to a hard-coded tuple compatible with older versions.
1226+
try:
1227+
candidates = list(paramiko.key_classes) if hasattr(paramiko, "key_classes") else [
1228+
paramiko.RSAKey,
1229+
paramiko.Ed25519Key,
1230+
paramiko.ECDSAKey,
1231+
]
1232+
except AttributeError:
1233+
candidates = [
1234+
paramiko.RSAKey,
1235+
paramiko.Ed25519Key,
1236+
paramiko.ECDSAKey,
1237+
]
1238+
errors: list[str] = []
1239+
for key_class in candidates:
12131240
try:
1214-
return key_class.from_private_key(StringIO(private_key), password=passphrase)
1241+
return key_class.from_private_key(StringIO(stripped), password=passphrase)
12151242
except Exception as exc:
12161243
errors.append(str(exc))
12171244
raise CustomException(400, "Invalid Private Key", "; ".join(errors[:1]) or "Unable to parse private key")

console/src/features/my-apps/my-app-access-panel.tsx

Lines changed: 58 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -130,12 +130,53 @@ function IconEntryEmpty() {
130130
return <svg viewBox="0 0 24 24" width="28" height="28" fill="currentColor"><path d="M10 3h4v4h5a2 2 0 0 1 2 2v7h-2V9h-4v4H9V9H5v10h7v2H5a2 2 0 0 1-2-2V9a2 2 0 0 1 2-2h5V3zm1 1v3h2V4h-2zm7 14 3 3-1.4 1.4L17 19.8l-2.6 2.6L13 21l3-3-3-3 1.4-1.4L17 16.2l2.6-2.6L21 15l-3 3z" /></svg>
131131
}
132132

133-
function formatCertificateLabel(certificate: { nice_name?: string | null; domain_names?: string[]; provider?: string | null } | null | undefined) {
133+
function getOrdinal(day: number): string {
134+
if (day >= 11 && day <= 13) return 'th'
135+
const last = day % 10
136+
if (last === 1) return 'st'
137+
if (last === 2) return 'nd'
138+
if (last === 3) return 'rd'
139+
return 'th'
140+
}
141+
142+
function formatExpiryDate(isoString: string, locale: string): string {
143+
const date = new Date(isoString)
144+
if (isNaN(date.getTime())) return ''
145+
const isZh = String(locale || '').toLowerCase().startsWith('zh')
146+
if (isZh) {
147+
const y = date.getFullYear()
148+
const m = date.getMonth() + 1
149+
const d = date.getDate()
150+
const hh = String(date.getHours()).padStart(2, '0')
151+
const mm = String(date.getMinutes()).padStart(2, '0')
152+
return `${y}${m}${d}${hh}:${mm}`
153+
}
154+
const months = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']
155+
const day = date.getDate()
156+
const month = months[date.getMonth()] ?? ''
157+
const year = date.getFullYear()
158+
let hours = date.getHours()
159+
const ampm = hours >= 12 ? 'PM' : 'AM'
160+
hours = hours % 12 || 12
161+
const minutes = String(date.getMinutes()).padStart(2, '0')
162+
return `${day}${getOrdinal(day)} ${month} ${year}, ${hours}:${minutes} ${ampm}`
163+
}
164+
165+
function formatCertificateLabel(
166+
certificate: { nice_name?: string | null; domain_names?: string[]; provider?: string | null; expires_on?: string | null } | null | undefined,
167+
locale?: string,
168+
) {
134169
if (!certificate) return ''
135-
if (certificate.nice_name?.trim()) return certificate.nice_name.trim()
136-
if (certificate.domain_names?.length) return certificate.domain_names.join(', ')
137-
if (certificate.provider?.trim()) return certificate.provider.trim()
138-
return ''
170+
const name = certificate.nice_name?.trim() || certificate.domain_names?.join(', ') || certificate.provider?.trim() || ''
171+
if (!name) return ''
172+
if (certificate.expires_on) {
173+
const expiryLabel = formatExpiryDate(certificate.expires_on, locale || '')
174+
if (expiryLabel) {
175+
const isZh = String(locale || '').toLowerCase().startsWith('zh')
176+
return isZh ? `${name}(过期:${expiryLabel})` : `${name} (Expires: ${expiryLabel})`
177+
}
178+
}
179+
return name
139180
}
140181

141182
function formatAccountLabel(key: string, t: (key: string) => string) {
@@ -189,7 +230,7 @@ function getConnectionTitle(t: (key: string, options?: Record<string, unknown>)
189230
}
190231

191232
export function MyAppAccessPanel({ appId, env, isComposeApp, onUpdated, scopeRect, isDarkMode = false }: MyAppAccessPanelProps) {
192-
const { t } = useTranslation('shell')
233+
const { t, i18n } = useTranslation('shell')
193234
const palette = getSurfacePalette(isDarkMode)
194235
const [selectedProxyId, setSelectedProxyId] = useState<number | null>(null)
195236
const [selectedDomainName, setSelectedDomainName] = useState<string | null>(null)
@@ -269,7 +310,7 @@ export function MyAppAccessPanel({ appId, env, isComposeApp, onUpdated, scopeRec
269310
() => data?.certificates.find((certificate) => certificate.id === currentProxyHost?.certificate_id) ?? null,
270311
[currentProxyHost?.certificate_id, data?.certificates],
271312
)
272-
const currentCertificateLabel = currentProxyHost?.certificate_name || formatCertificateLabel(currentCertificate)
313+
const currentCertificateLabel = currentProxyHost?.certificate_name || formatCertificateLabel(currentCertificate, i18n.resolvedLanguage)
273314
const containerOptions = useMemo(() => {
274315
const seen = new Set<string>()
275316
return (data?.candidates ?? []).filter((candidate) => {
@@ -1465,7 +1506,7 @@ ${customCertIntermediate.trim()}`
14651506
<option value="none">{t('myAppsDetailPage.accessPanel.selectCertPlaceholder')}</option>
14661507
{availableCertificates.map((cert) => (
14671508
<option key={cert.id} value={cert.id}>
1468-
{formatCertificateLabel(cert) || `#${cert.id}`}
1509+
{formatCertificateLabel(cert, i18n.resolvedLanguage) || `#${cert.id}`}
14691510
</option>
14701511
))}
14711512
</select>
@@ -1676,7 +1717,15 @@ ${customCertIntermediate.trim()}`
16761717
scopeRect={scopeRect ?? null}
16771718
contentStrategy="viewport-fixed"
16781719
darkMode={isDarkMode}
1679-
sx={{ zIndex: 1510 }}
1720+
sx={{
1721+
zIndex: 1510,
1722+
'& .MuiDialog-container': {
1723+
alignItems: 'flex-start',
1724+
justifyContent: 'center',
1725+
pt: { xs: 3, md: 3 },
1726+
pb: { xs: 1.5, md: 2.5 },
1727+
},
1728+
}}
16801729
paperSx={{
16811730
maxWidth: '480px',
16821731
borderRadius: 0,

console/src/features/my-apps/my-app-detail-page.tsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -772,9 +772,8 @@ export function MyAppDetailPage() {
772772
}) as CSSProperties, [])
773773
const disabledPrimaryToolbarButtonStyle = useMemo(() => ({
774774
...primaryToolbarButtonStyle,
775-
backgroundColor: isDarkMode ? 'rgba(23,103,209,0.2)' : '#dce9f8',
776-
color: 'rgba(255,255,255,0.72)',
777-
}) as CSSProperties, [primaryToolbarButtonStyle, isDarkMode])
775+
opacity: 0.35,
776+
}) as CSSProperties, [primaryToolbarButtonStyle])
778777
const closeToolbarButtonStyle = useMemo(() => ({
779778
width: 40,
780779
height: 40,

console/src/features/terminal/terminal-page.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1088,8 +1088,8 @@ async function requestJson<T>(input: string, init?: RequestInit): Promise<T> {
10881088
if (!response.ok) {
10891089
let detail = `Request failed: ${response.status}`
10901090
try {
1091-
const body = (await response.json()) as { details?: string; message?: string }
1092-
detail = body.details || body.message || detail
1091+
const body = (await response.json()) as { detail?: string; details?: string; message?: string }
1092+
detail = body.details || body.detail || body.message || detail
10931093
} catch {
10941094
}
10951095
throw new Error(detail)

0 commit comments

Comments
 (0)