Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
8199c46
Add option to enforce TLS by redirecting HTTP requests to HTTPS (#1092)
DigitalDJ Dec 23, 2025
08e74f9
Ensure TLS Enforce can never be on when HTTPS mode is disabled (#1092)
DigitalDJ Dec 24, 2025
ae8bddf
Fix nesting spacing for custom TLS certificates (#1092)
DigitalDJ Dec 24, 2025
cbd4490
Change flow of Enforce TLS enablement. Require pop-up acknolwedgement…
DigitalDJ Dec 24, 2025
7fbd475
Fix web secure server not fully shutting down due to waiting on stopT…
DigitalDJ Dec 24, 2025
f72a4de
Be more clear about setupRouter argument; don't pass config down, use…
DigitalDJ Dec 24, 2025
ce20043
Disable use of HSTS (#1092)
DigitalDJ Dec 24, 2025
897dde8
Delay handler swap when turning on TLS enforce to allow any pending W…
DigitalDJ Dec 24, 2025
779fcaa
Close update router channel on web server close (#1092)
DigitalDJ Dec 24, 2025
2cef0ea
Fix getTLSState check TLSMode string "disabled" - config uses empty s…
DigitalDJ Dec 24, 2025
19e29bd
Be more defensive about enforcing TLS. Ensure TLS is successfully ena…
DigitalDJ Dec 24, 2025
96276d4
Update translations to be more consistent for Enforce TLS. Change Enf…
DigitalDJ Dec 31, 2025
f882abc
Allow UI to be IFRAME'd when using HTTPS
DigitalDJ Dec 31, 2025
93e5697
Clarify use of isChanged and re-route HTTP server after we signal the…
DigitalDJ Dec 31, 2025
a700a0b
Merge branch 'dev' into feat/enforce-tls
DigitalDJ Dec 31, 2025
4919cd9
Fix hidden Update TLS Settings button when mode is self-signed
DigitalDJ Jan 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions config.go
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ type Config struct {
DisplayDimAfterSec int `json:"display_dim_after_sec"`
DisplayOffAfterSec int `json:"display_off_after_sec"`
TLSMode string `json:"tls_mode"` // options: "self-signed", "user-defined", ""
TLSEnforce bool `json:"tls_enforce"`
UsbConfig *usbgadget.Config `json:"usb_config"`
UsbDevices *usbgadget.Devices `json:"usb_devices"`
NetworkConfig *types.NetworkConfig `json:"network_config"`
Expand Down Expand Up @@ -188,6 +189,7 @@ func getDefaultConfig() Config {
// This is the "Standard" jiggler option in the UI
JigglerConfig: func() *JigglerConfig { c := defaultJigglerConfig; return &c }(),
TLSMode: "",
TLSEnforce: false,
UsbConfig: func() *usbgadget.Config { c := defaultUsbConfig; return &c }(),
UsbDevices: func() *usbgadget.Devices { c := defaultUsbDevices; return &c }(),
NetworkConfig: func() *types.NetworkConfig {
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/gin-gonic/contrib v0.0.0-20250521004450-2b1292699c15 // indirect
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ github.com/gin-contrib/logger v1.2.6 h1:EPolruKUTzNXMVBD9LuAFQmRjTs7AH7yKGuXgYqr
github.com/gin-contrib/logger v1.2.6/go.mod h1:7niPrd7F0Nscw/zvgz8RiGJxSdbKM2yfQNy8xCHcm64=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/contrib v0.0.0-20250521004450-2b1292699c15 h1:AoSudS8CW8Mc9rRf5sO1vBtNxr2Ok6TaAICjgg5oKUY=
github.com/gin-gonic/contrib v0.0.0-20250521004450-2b1292699c15/go.mod h1:iqneQ2Df3omzIVTkIfn7c1acsVnMGiSLn4XF5Blh3Yg=
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-co-op/gocron/v2 v2.17.0 h1:e/oj6fcAM8vOOKZxv2Cgfmjo+s8AXC46po5ZPtaSea4=
Expand Down
2 changes: 2 additions & 0 deletions ui/localization/messages/da.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
"access_description": "Administrer adgangskontrollen for enheden",
"access_disable_protection": "Deaktiver beskyttelse",
"access_enable_password": "Aktivér adgangskode",
"access_enforce_tls_label": "TLS afdwingen",
"access_enforce_tls_description": "Alle HTTP-verzoeken doorsturen naar HTTPS",
"access_failed_deregister": "Kunne ikke afregistrere enhed: {error}",
"access_failed_update_cloud_url": "Kunne ikke opdatere cloud-URL: {error}",
"access_failed_update_tls": "Kunne ikke opdatere TLS-indstillinger: {error}",
Expand Down
2 changes: 2 additions & 0 deletions ui/localization/messages/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
"access_description": "Verwalten Sie die Zugriffssteuerung Ihres Geräts",
"access_disable_protection": "Schutz deaktivieren",
"access_enable_password": "Kennwort aktivieren",
"access_enforce_tls_label": "TLS erzwingen",
"access_enforce_tls_description": "Alle HTTP-Anfragen auf HTTPS umleiten",
"access_failed_deregister": "Abmeldung des Geräts fehlgeschlagen: {error}",
"access_failed_update_cloud_url": "Fehler beim Aktualisieren der Cloud-URL: {error}",
"access_failed_update_tls": "TLS-Einstellungen konnten nicht aktualisiert werden: {error}",
Expand Down
2 changes: 2 additions & 0 deletions ui/localization/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
"access_description": "Manage the Access Control of the device",
"access_disable_protection": "Disable Protection",
"access_enable_password": "Enable Password",
"access_enforce_tls_label": "Enforce TLS",
"access_enforce_tls_description": "Redirect all HTTP requests to HTTPS",
"access_failed_deregister": "Failed to de-register device: {error}",
"access_failed_update_cloud_url": "Failed to update cloud URL: {error}",
"access_failed_update_tls": "Failed to update TLS settings: {error}",
Expand Down
2 changes: 2 additions & 0 deletions ui/localization/messages/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
"access_description": "Administre el control de acceso del dispositivo",
"access_disable_protection": "Desactivar la protección",
"access_enable_password": "Activar contraseña",
"access_enforce_tls_label": "Aplicar TLS",
"access_enforce_tls_description": "Redirigir todas las solicitudes HTTP a HTTPS",
"access_failed_deregister": "No se pudo cancelar el registro del dispositivo: {error}",
"access_failed_update_cloud_url": "No se pudo actualizar la URL de la nube: {error}",
"access_failed_update_tls": "No se pudo actualizar la configuración de TLS: {error}",
Expand Down
2 changes: 2 additions & 0 deletions ui/localization/messages/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
"access_description": "Gérer le contrôle d'accès de l'appareil",
"access_disable_protection": "Désactiver la protection",
"access_enable_password": "Activer le mot de passe",
"access_enforce_tls_label": "Appliquer TLS",
"access_enforce_tls_description": "Rediriger toutes les requêtes HTTP vers HTTPS",
"access_failed_deregister": "Échec de la désinscription du périphérique : {error}",
"access_failed_update_cloud_url": "Échec de la mise à jour de l'URL du cloud : {error}",
"access_failed_update_tls": "Échec de la mise à jour des paramètres TLS : {error}",
Expand Down
2 changes: 2 additions & 0 deletions ui/localization/messages/it.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
"access_description": "Gestisci il controllo degli accessi del dispositivo",
"access_disable_protection": "Disattiva la protezione",
"access_enable_password": "Attiva password",
"access_enforce_tls_label": "Applica TLS",
"access_enforce_tls_description": "Reindirizza tutte le richieste HTTP a HTTPS",
"access_failed_deregister": "Impossibile annullare la registrazione del dispositivo: {error}",
"access_failed_update_cloud_url": "Impossibile aggiornare l'URL del cloud: {error}",
"access_failed_update_tls": "Impossibile aggiornare le impostazioni TLS: {error}",
Expand Down
2 changes: 2 additions & 0 deletions ui/localization/messages/nb.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
"access_description": "Administrer tilgang til enheten",
"access_disable_protection": "Deaktiver beskyttelse",
"access_enable_password": "Aktiver passord",
"access_enforce_tls_label": "Håndhev TLS",
"access_enforce_tls_description": "Omdiriger alle HTTP-forespørsler til HTTPS",
"access_failed_deregister": "Kunne ikke avregistrere enheten: {error}",
"access_failed_update_cloud_url": "Kunne ikke oppdatere nettadressen til skyen: {error}",
"access_failed_update_tls": "Kunne ikke oppdatere TLS-innstillingene: {error}",
Expand Down
2 changes: 2 additions & 0 deletions ui/localization/messages/sv.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
"access_description": "Hantera enhetens åtkomstkontroll",
"access_disable_protection": "Inaktivera skydd",
"access_enable_password": "Aktivera lösenord",
"access_enforce_tls_label": "Tillämpa TLS",
"access_enforce_tls_description": "Omdirigera alla HTTP-förfrågningar till HTTPS",
"access_failed_deregister": "Misslyckades med att avregistrera enheten: {error}",
"access_failed_update_cloud_url": "Misslyckades med att uppdatera moln-URL: {error}",
"access_failed_update_tls": "Misslyckades med att uppdatera TLS-inställningarna: {error}",
Expand Down
2 changes: 2 additions & 0 deletions ui/localization/messages/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
"access_description": "管理设备的访问控制。",
"access_disable_protection": "禁用保护",
"access_enable_password": "启用密码",
"access_enforce_tls_label": "强制执行 TLS",
"access_enforce_tls_description": "将所有 HTTP 请求重定向到 HTTPS",
"access_failed_deregister": "注销设备失败:{error}",
"access_failed_update_cloud_url": "更新云地址失败:{error}",
"access_failed_update_tls": "更新 TLS 设置失败:{error}",
Expand Down
94 changes: 61 additions & 33 deletions ui/src/routes/devices.$id.settings.access._index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { ShieldCheckIcon } from "@heroicons/react/24/outline";

import { useDeviceUiNavigation } from "@hooks/useAppNavigation";
import { JsonRpcResponse, useJsonRpc } from "@hooks/useJsonRpc";
import { CheckboxWithLabel } from "@components/Checkbox";
import { GridCard } from "@components/Card";
import { Button, LinkButton } from "@components/Button";
import { InputFieldWithLabel } from "@components/InputField";
Expand All @@ -24,6 +25,7 @@ import { CloudState } from "./adopt";

export interface TLSState {
mode: "self-signed" | "custom" | "disabled";
enforce: boolean;
certificate?: string;
privateKey?: string;
}
Expand Down Expand Up @@ -54,6 +56,7 @@ export default function SettingsAccessIndexRoute() {
// Use a simple string identifier for the selected provider
const [selectedProvider, setSelectedProvider] = useState<string>("jetkvm");
const [tlsMode, setTlsMode] = useState<string>("unknown");
const [tlsEnforce, setTlsEnforce] = useState<boolean>(false);
const [tlsCert, setTlsCert] = useState<string>("");
const [tlsKey, setTlsKey] = useState<string>("");

Expand Down Expand Up @@ -84,6 +87,7 @@ export default function SettingsAccessIndexRoute() {
const tlsState = resp.result as TLSState;

setTlsMode(tlsState.mode);
setTlsEnforce(tlsState.enforce);
if (tlsState.certificate) setTlsCert(tlsState.certificate);
if (tlsState.privateKey) setTlsKey(tlsState.privateKey);
});
Expand Down Expand Up @@ -148,12 +152,13 @@ export default function SettingsAccessIndexRoute() {

// Function to update TLS state - accepts a mode parameter
const updateTlsState = useCallback(
(mode: string, cert?: string, key?: string) => {
(mode: string, enforce: boolean, cert?: string, key?: string) => {
const state = { mode } as TLSState;
if (cert && key) {
state.certificate = cert;
state.privateKey = key;
}
state.enforce = enforce;

send("setTLSState", { state }, (resp: JsonRpcResponse) => {
if ("error" in resp) {
Expand All @@ -172,10 +177,11 @@ export default function SettingsAccessIndexRoute() {
// Handle TLS mode change
const handleTlsModeChange = (value: string) => {
setTlsMode(value);
setTlsEnforce(value === "disabled" ? false : tlsEnforce);

// For "disabled" and "self-signed" modes, immediately apply the settings
if (value !== "custom") {
updateTlsState(value);
updateTlsState(value, tlsEnforce);
}
};

Expand All @@ -187,9 +193,17 @@ export default function SettingsAccessIndexRoute() {
setTlsKey(value);
};

const handleTlsEnforceChange = (value: boolean) => {
setTlsEnforce(value);
// For "disabled" and "self-signed" modes, immediately apply the settings
if (tlsMode !== "custom") {
updateTlsState(tlsMode, value);
}
};

// Update the custom TLS settings button click handler
const handleCustomTlsUpdate = () => {
updateTlsState(tlsMode, tlsCert, tlsKey);
updateTlsState(tlsMode, tlsEnforce, tlsCert, tlsKey);
};

// Fetch device ID and cloud state on component mount
Expand Down Expand Up @@ -233,36 +247,50 @@ export default function SettingsAccessIndexRoute() {
/>
</SettingsItem>

{tlsMode === "custom" && (
<NestedSettingsGroup className="mt-4">
<SettingsItem
title={m.access_tls_certificate_title()}
description={m.access_tls_certificate_description()}
/>
<TextAreaWithLabel
label={m.access_certificate_label()}
rows={3}
placeholder={"-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----"}
value={tlsCert}
onChange={e => handleTlsCertChange(e.target.value)}
/>
<TextAreaWithLabel
label={m.access_private_key_label()}
description={m.access_private_key_description()}
rows={3}
placeholder={"-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----"}
value={tlsKey}
onChange={e => handleTlsKeyChange(e.target.value)}
/>
<div className="flex items-center gap-x-2">
<Button
size="SM"
theme="primary"
text={m.access_update_tls_settings()}
onClick={handleCustomTlsUpdate}
/>
</div>
</NestedSettingsGroup>
{(tlsMode === "custom" || tlsMode == "self-signed") && (
<>
<NestedSettingsGroup className="mt-4">
<div>
<CheckboxWithLabel
label={m.access_enforce_tls_label()}
description={m.access_enforce_tls_description()}
checked={tlsEnforce}
onChange={e => handleTlsEnforceChange(e.target.checked)}
/>
</div>
{tlsMode === "custom" && (
<>
<SettingsItem
title={m.access_tls_certificate_title()}
description={m.access_tls_certificate_description()}
/>
<TextAreaWithLabel
label={m.access_certificate_label()}
rows={3}
placeholder={"-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----"}
value={tlsCert}
onChange={e => handleTlsCertChange(e.target.value)}
/>
<TextAreaWithLabel
label={m.access_private_key_label()}
description={m.access_private_key_description()}
rows={3}
placeholder={"-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----"}
value={tlsKey}
onChange={e => handleTlsKeyChange(e.target.value)}
/>
<div className="flex items-center gap-x-2">
<Button
size="SM"
theme="primary"
text={m.access_update_tls_settings()}
onClick={handleCustomTlsUpdate}
/>
</div>
</>
)}
</NestedSettingsGroup>
</>
)}

<SettingsItem
Expand Down
39 changes: 36 additions & 3 deletions web.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
"github.com/coder/websocket"
"github.com/coder/websocket/wsjson"
gin_logger "github.com/gin-contrib/logger"
"github.com/gin-gonic/contrib/secure"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/jetkvm/kvm/internal/logging"
Expand Down Expand Up @@ -73,7 +74,7 @@ var cachableFileExtensions = []string{
".jpg", ".jpeg", ".png", ".svg", ".gif", ".webp", ".ico", ".woff2",
}

func setupRouter() *gin.Engine {
func setupRouter(secureRedirect bool) *gin.Engine {
gin.SetMode(gin.ReleaseMode)
gin.DisableConsoleColor()
r := gin.Default()
Expand All @@ -83,6 +84,22 @@ func setupRouter() *gin.Engine {
}),
))

if secureRedirect {
r.Use(secure.Secure(secure.Options{
AllowedHosts: []string{},
SSLRedirect: true,
SSLHost: "",
SSLProxyHeaders: map[string]string{"X-Forwarded-Proto": "https"},
STSSeconds: 315360000,
STSIncludeSubdomains: true,
FrameDeny: true,
ContentTypeNosniff: true,
BrowserXssFilter: true,
ContentSecurityPolicy: "default-src 'self'",
}))
return r
}

staticFS, err := fs.Sub(staticFiles, "static")
if err != nil {
logger.Fatal().Err(err).Msg("failed to get rooted static files subdirectory")
Expand Down Expand Up @@ -564,8 +581,12 @@ func basicAuthProtectedMiddleware(requireDeveloperMode bool) gin.HandlerFunc {
}
}

var (
updateWebRouter = make(chan struct{})
)

func RunWebServer() {
r := setupRouter()
r := setupRouter(config.TLSMode != "" && config.TLSEnforce)

// Determine the binding address based on the config
var bindAddress string
Expand All @@ -592,7 +613,19 @@ func RunWebServer() {
}

logger.Info().Str("bindAddress", bindAddress).Bool("loopbackOnly", config.LocalLoopbackOnly).Msg("Starting web server")
if err := r.Run(bindAddress); err != nil {
server := &http.Server{
Addr: bindAddress,
Handler: r,
}

go func() {
for range updateWebRouter {
server.Handler = setupRouter(config.TLSMode != "" && config.TLSEnforce)
}
}()

err := server.ListenAndServe()
if !errors.Is(err, http.ErrServerClosed) {
panic(err)
}
}
Expand Down
Loading