Merge branch 'dev' into feat/tls-config

This commit is contained in:
Aveline 2025-04-07 13:28:26 +02:00 committed by GitHub
commit 01022eaa64
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 454 additions and 216 deletions

View File

@ -1,12 +1,22 @@
--- ---
linters: linters:
enable: enable:
# - goimports - forbidigo
# - misspell - goimports
- misspell
# - revive # - revive
- whitespace
issues: issues:
exclude-rules: exclude-rules:
- path: _test.go - path: _test.go
linters: linters:
- errcheck - errcheck
linters-settings:
forbidigo:
forbid:
- p: ^fmt\.Print.*$
msg: Do not commit print statements. Use logger package.
- p: ^log\.(Fatal|Panic|Print)(f|ln)?.*$
msg: Do not commit log statements. Use logger package.

View File

@ -23,7 +23,7 @@ We welcome contributions from the community! Whether it's improving the firmware
## I need help ## I need help
The best place to search for answers is our [Documentation](https://jetkvm.com/docs). If you can't find the answer there, check our [Discord Server](https://discord.gg/8MaAhua7NW). The best place to search for answers is our [Documentation](https://jetkvm.com/docs). If you can't find the answer there, check our [Discord Server](https://jetkvm.com/discord).
## I want to report an issue ## I want to report an issue

153
cloud.go
View File

@ -10,6 +10,8 @@ import (
"time" "time"
"github.com/coder/websocket/wsjson" "github.com/coder/websocket/wsjson"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"github.com/coreos/go-oidc/v3/oidc" "github.com/coreos/go-oidc/v3/oidc"
@ -36,6 +38,97 @@ const (
CloudWebSocketPingInterval = 15 * time.Second CloudWebSocketPingInterval = 15 * time.Second
) )
var (
metricCloudConnectionStatus = promauto.NewGauge(
prometheus.GaugeOpts{
Name: "jetkvm_cloud_connection_status",
Help: "The status of the cloud connection",
},
)
metricCloudConnectionEstablishedTimestamp = promauto.NewGauge(
prometheus.GaugeOpts{
Name: "jetkvm_cloud_connection_established_timestamp",
Help: "The timestamp when the cloud connection was established",
},
)
metricCloudConnectionLastPingTimestamp = promauto.NewGauge(
prometheus.GaugeOpts{
Name: "jetkvm_cloud_connection_last_ping_timestamp",
Help: "The timestamp when the last ping response was received",
},
)
metricCloudConnectionLastPingDuration = promauto.NewGauge(
prometheus.GaugeOpts{
Name: "jetkvm_cloud_connection_last_ping_duration",
Help: "The duration of the last ping response",
},
)
metricCloudConnectionPingDuration = promauto.NewHistogram(
prometheus.HistogramOpts{
Name: "jetkvm_cloud_connection_ping_duration",
Help: "The duration of the ping response",
Buckets: []float64{
0.1, 0.5, 1, 10,
},
},
)
metricCloudConnectionTotalPingCount = promauto.NewCounter(
prometheus.CounterOpts{
Name: "jetkvm_cloud_connection_total_ping_count",
Help: "The total number of pings sent to the cloud",
},
)
metricCloudConnectionSessionRequestCount = promauto.NewCounter(
prometheus.CounterOpts{
Name: "jetkvm_cloud_connection_session_total_request_count",
Help: "The total number of session requests received from the cloud",
},
)
metricCloudConnectionSessionRequestDuration = promauto.NewHistogram(
prometheus.HistogramOpts{
Name: "jetkvm_cloud_connection_session_request_duration",
Help: "The duration of session requests",
Buckets: []float64{
0.1, 0.5, 1, 10,
},
},
)
metricCloudConnectionLastSessionRequestTimestamp = promauto.NewGauge(
prometheus.GaugeOpts{
Name: "jetkvm_cloud_connection_last_session_request_timestamp",
Help: "The timestamp of the last session request",
},
)
metricCloudConnectionLastSessionRequestDuration = promauto.NewGauge(
prometheus.GaugeOpts{
Name: "jetkvm_cloud_connection_last_session_request_duration",
Help: "The duration of the last session request",
},
)
metricCloudConnectionFailureCount = promauto.NewCounter(
prometheus.CounterOpts{
Name: "jetkvm_cloud_connection_failure_count",
Help: "The number of times the cloud connection has failed",
},
)
)
func cloudResetMetrics(established bool) {
metricCloudConnectionLastPingTimestamp.Set(-1)
metricCloudConnectionLastPingDuration.Set(-1)
metricCloudConnectionLastSessionRequestTimestamp.Set(-1)
metricCloudConnectionLastSessionRequestDuration.Set(-1)
if established {
metricCloudConnectionEstablishedTimestamp.SetToCurrentTime()
metricCloudConnectionStatus.Set(1)
} else {
metricCloudConnectionEstablishedTimestamp.Set(-1)
metricCloudConnectionStatus.Set(-1)
}
}
func handleCloudRegister(c *gin.Context) { func handleCloudRegister(c *gin.Context) {
var req CloudRegisterRequest var req CloudRegisterRequest
@ -90,11 +183,6 @@ func handleCloudRegister(c *gin.Context) {
return return
} }
if config.CloudToken == "" {
cloudLogger.Info("Starting websocket client due to adoption")
go RunWebsocketClient()
}
config.CloudToken = tokenResp.SecretToken config.CloudToken = tokenResp.SecretToken
provider, err := oidc.NewProvider(c, "https://accounts.google.com") provider, err := oidc.NewProvider(c, "https://accounts.google.com")
@ -130,19 +218,23 @@ func runWebsocketClient() error {
time.Sleep(5 * time.Second) time.Sleep(5 * time.Second)
return fmt.Errorf("cloud token is not set") return fmt.Errorf("cloud token is not set")
} }
wsURL, err := url.Parse(config.CloudURL) wsURL, err := url.Parse(config.CloudURL)
if err != nil { if err != nil {
return fmt.Errorf("failed to parse config.CloudURL: %w", err) return fmt.Errorf("failed to parse config.CloudURL: %w", err)
} }
if wsURL.Scheme == "http" { if wsURL.Scheme == "http" {
wsURL.Scheme = "ws" wsURL.Scheme = "ws"
} else { } else {
wsURL.Scheme = "wss" wsURL.Scheme = "wss"
} }
header := http.Header{} header := http.Header{}
header.Set("X-Device-ID", GetDeviceID()) header.Set("X-Device-ID", GetDeviceID())
header.Set("Authorization", "Bearer "+config.CloudToken) header.Set("Authorization", "Bearer "+config.CloudToken)
dialCtx, cancelDial := context.WithTimeout(context.Background(), CloudWebSocketConnectTimeout) dialCtx, cancelDial := context.WithTimeout(context.Background(), CloudWebSocketConnectTimeout)
defer cancelDial() defer cancelDial()
c, _, err := websocket.Dial(dialCtx, wsURL.String(), &websocket.DialOptions{ c, _, err := websocket.Dial(dialCtx, wsURL.String(), &websocket.DialOptions{
HTTPHeader: header, HTTPHeader: header,
@ -152,17 +244,35 @@ func runWebsocketClient() error {
} }
defer c.CloseNow() //nolint:errcheck defer c.CloseNow() //nolint:errcheck
cloudLogger.Infof("websocket connected to %s", wsURL) cloudLogger.Infof("websocket connected to %s", wsURL)
// set the metrics when we successfully connect to the cloud.
cloudResetMetrics(true)
runCtx, cancelRun := context.WithCancel(context.Background()) runCtx, cancelRun := context.WithCancel(context.Background())
defer cancelRun() defer cancelRun()
go func() { go func() {
for { for {
time.Sleep(CloudWebSocketPingInterval) time.Sleep(CloudWebSocketPingInterval)
// set the timer for the ping duration
timer := prometheus.NewTimer(prometheus.ObserverFunc(func(v float64) {
metricCloudConnectionLastPingDuration.Set(v)
metricCloudConnectionPingDuration.Observe(v)
}))
err := c.Ping(runCtx) err := c.Ping(runCtx)
if err != nil { if err != nil {
cloudLogger.Warnf("websocket ping error: %v", err) cloudLogger.Warnf("websocket ping error: %v", err)
cancelRun() cancelRun()
return return
} }
// dont use `defer` here because we want to observe the duration of the ping
timer.ObserveDuration()
metricCloudConnectionTotalPingCount.Inc()
metricCloudConnectionLastPingTimestamp.SetToCurrentTime()
} }
}() }()
for { for {
@ -184,6 +294,8 @@ func runWebsocketClient() error {
cloudLogger.Infof("new session request: %v", req.OidcGoogle) cloudLogger.Infof("new session request: %v", req.OidcGoogle)
cloudLogger.Tracef("session request info: %v", req) cloudLogger.Tracef("session request info: %v", req)
metricCloudConnectionSessionRequestCount.Inc()
metricCloudConnectionLastSessionRequestTimestamp.SetToCurrentTime()
err = handleSessionRequest(runCtx, c, req) err = handleSessionRequest(runCtx, c, req)
if err != nil { if err != nil {
cloudLogger.Infof("error starting new session: %v", err) cloudLogger.Infof("error starting new session: %v", err)
@ -193,6 +305,12 @@ func runWebsocketClient() error {
} }
func handleSessionRequest(ctx context.Context, c *websocket.Conn, req WebRTCSessionRequest) error { func handleSessionRequest(ctx context.Context, c *websocket.Conn, req WebRTCSessionRequest) error {
timer := prometheus.NewTimer(prometheus.ObserverFunc(func(v float64) {
metricCloudConnectionLastSessionRequestDuration.Set(v)
metricCloudConnectionSessionRequestDuration.Observe(v)
}))
defer timer.ObserveDuration()
oidcCtx, cancelOIDC := context.WithTimeout(ctx, CloudOidcRequestTimeout) oidcCtx, cancelOIDC := context.WithTimeout(ctx, CloudOidcRequestTimeout)
defer cancelOIDC() defer cancelOIDC()
provider, err := oidc.NewProvider(oidcCtx, "https://accounts.google.com") provider, err := oidc.NewProvider(oidcCtx, "https://accounts.google.com")
@ -253,9 +371,34 @@ func handleSessionRequest(ctx context.Context, c *websocket.Conn, req WebRTCSess
func RunWebsocketClient() { func RunWebsocketClient() {
for { for {
// reset the metrics when we start the websocket client.
cloudResetMetrics(false)
// If the cloud token is not set, we don't need to run the websocket client.
if config.CloudToken == "" {
time.Sleep(5 * time.Second)
continue
}
// If the network is not up, well, we can't connect to the cloud.
if !networkState.Up {
cloudLogger.Warn("waiting for network to be up, will retry in 3 seconds")
time.Sleep(3 * time.Second)
continue
}
// If the system time is not synchronized, the API request will fail anyway because the TLS handshake will fail.
if isTimeSyncNeeded() && !timeSyncSuccess {
cloudLogger.Warn("system time is not synced, will retry in 3 seconds")
time.Sleep(3 * time.Second)
continue
}
err := runWebsocketClient() err := runWebsocketClient()
if err != nil { if err != nil {
cloudLogger.Errorf("websocket client error: %v", err) cloudLogger.Errorf("websocket client error: %v", err)
metricCloudConnectionStatus.Set(0)
metricCloudConnectionFailureCount.Inc()
time.Sleep(5 * time.Second) time.Sleep(5 * time.Second)
} }
} }

View File

@ -74,11 +74,9 @@ func Main() {
initCertStore() initCertStore()
go RunWebSecureServer() go RunWebSecureServer()
} }
// If the cloud token isn't set, the client won't be started by default. // As websocket client already checks if the cloud token is set, we can start it here.
// However, if the user adopts the device via the web interface, handleCloudRegister will start the client. go RunWebsocketClient()
if config.CloudToken != "" {
go RunWebsocketClient()
}
initSerialPort() initSerialPort()
sigs := make(chan os.Signal, 1) sigs := make(chan os.Signal, 1)
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)

View File

@ -1,15 +1,11 @@
package kvm package kvm
import ( import (
"net/http"
"github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus"
versioncollector "github.com/prometheus/client_golang/prometheus/collectors/version" versioncollector "github.com/prometheus/client_golang/prometheus/collectors/version"
"github.com/prometheus/common/version" "github.com/prometheus/common/version"
) )
var promHandler http.Handler
func initPrometheus() { func initPrometheus() {
// A Prometheus metrics endpoint. // A Prometheus metrics endpoint.
version.Version = builtAppVersion version.Version = builtAppVersion

View File

@ -66,7 +66,6 @@ func runATXControl() {
newLedPWRState != ledPWRState || newLedPWRState != ledPWRState ||
newBtnRSTState != btnRSTState || newBtnRSTState != btnRSTState ||
newBtnPWRState != btnPWRState { newBtnPWRState != btnPWRState {
logger.Debugf("Status changed: HDD LED: %v, PWR LED: %v, RST BTN: %v, PWR BTN: %v", logger.Debugf("Status changed: HDD LED: %v, PWR LED: %v, RST BTN: %v, PWR BTN: %v",
newLedHDDState, newLedPWRState, newBtnRSTState, newBtnPWRState) newLedHDDState, newLedPWRState, newBtnRSTState, newBtnPWRState)

View File

@ -36,7 +36,7 @@ export default function DashboardNavbar({
picture, picture,
kvmName, kvmName,
}: NavbarProps) { }: NavbarProps) {
const peerConnectionState = useRTCStore(state => state.peerConnection?.connectionState); const peerConnection = useRTCStore(state => state.peerConnection);
const setUser = useUserStore(state => state.setUser); const setUser = useUserStore(state => state.setUser);
const navigate = useNavigate(); const navigate = useNavigate();
const onLogout = useCallback(async () => { const onLogout = useCallback(async () => {
@ -82,14 +82,14 @@ export default function DashboardNavbar({
<div className="hidden items-center gap-x-2 md:flex"> <div className="hidden items-center gap-x-2 md:flex">
<div className="w-[159px]"> <div className="w-[159px]">
<PeerConnectionStatusCard <PeerConnectionStatusCard
state={peerConnectionState} state={peerConnection?.connectionState}
title={kvmName} title={kvmName}
/> />
</div> </div>
<div className="hidden w-[159px] md:block"> <div className="hidden w-[159px] md:block">
<USBStateStatus <USBStateStatus
state={usbState} state={usbState}
peerConnectionState={peerConnectionState} peerConnectionState={peerConnection?.connectionState}
/> />
</div> </div>
</div> </div>

View File

@ -9,19 +9,22 @@ const PeerConnectionStatusMap = {
failed: "Connection failed", failed: "Connection failed",
closed: "Closed", closed: "Closed",
new: "Connecting", new: "Connecting",
}; } as Record<RTCPeerConnectionState | "error" | "closing", string>;
export type PeerConnections = keyof typeof PeerConnectionStatusMap; export type PeerConnections = keyof typeof PeerConnectionStatusMap;
type StatusProps = Record<PeerConnections, { type StatusProps = Record<
PeerConnections,
{
statusIndicatorClassName: string; statusIndicatorClassName: string;
}>; }
>;
export default function PeerConnectionStatusCard({ export default function PeerConnectionStatusCard({
state, state,
title, title,
}: { }: {
state?: PeerConnections; state?: RTCPeerConnectionState | null;
title?: string; title?: string;
}) { }) {
if (!state) return null; if (!state) return null;

View File

@ -8,11 +8,14 @@ import { HidState } from "@/hooks/stores";
type USBStates = HidState["usbState"]; type USBStates = HidState["usbState"];
type StatusProps = Record<USBStates, { type StatusProps = Record<
USBStates,
{
icon: React.FC<{ className: string | undefined }>; icon: React.FC<{ className: string | undefined }>;
iconClassName: string; iconClassName: string;
statusIndicatorClassName: string; statusIndicatorClassName: string;
}>; }
>;
const USBStateMap: Record<USBStates, string> = { const USBStateMap: Record<USBStates, string> = {
configured: "Connected", configured: "Connected",
@ -27,9 +30,8 @@ export default function USBStateStatus({
peerConnectionState, peerConnectionState,
}: { }: {
state: USBStates; state: USBStates;
peerConnectionState?: RTCPeerConnectionState; peerConnectionState?: RTCPeerConnectionState | null;
}) { }) {
const StatusCardProps: StatusProps = { const StatusCardProps: StatusProps = {
configured: { configured: {
icon: ({ className }) => ( icon: ({ className }) => (

View File

@ -1,6 +1,6 @@
import React from "react"; import React from "react";
import { ExclamationTriangleIcon } from "@heroicons/react/24/solid"; import { ExclamationTriangleIcon } from "@heroicons/react/24/solid";
import { ArrowRightIcon } from "@heroicons/react/16/solid"; import { ArrowPathIcon, ArrowRightIcon } from "@heroicons/react/16/solid";
import { motion, AnimatePresence } from "framer-motion"; import { motion, AnimatePresence } from "framer-motion";
import { LuPlay } from "react-icons/lu"; import { LuPlay } from "react-icons/lu";
@ -25,12 +25,12 @@ interface LoadingOverlayProps {
show: boolean; show: boolean;
} }
export function LoadingOverlay({ show }: LoadingOverlayProps) { export function LoadingVideoOverlay({ show }: LoadingOverlayProps) {
return ( return (
<AnimatePresence> <AnimatePresence>
{show && ( {show && (
<motion.div <motion.div
className="absolute inset-0 aspect-video h-full w-full" className="aspect-video h-full w-full"
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
@ -55,21 +55,59 @@ export function LoadingOverlay({ show }: LoadingOverlayProps) {
); );
} }
interface ConnectionErrorOverlayProps { interface LoadingConnectionOverlayProps {
show: boolean; show: boolean;
text: string;
} }
export function LoadingConnectionOverlay({ show, text }: LoadingConnectionOverlayProps) {
export function ConnectionErrorOverlay({ show }: ConnectionErrorOverlayProps) {
return ( return (
<AnimatePresence> <AnimatePresence>
{show && ( {show && (
<motion.div <motion.div
className="absolute inset-0 z-10 aspect-video h-full w-full" className="aspect-video h-full w-full"
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} exit={{ opacity: 0, transition: { duration: 0 } }}
transition={{ transition={{
duration: 0.3, duration: 0.4,
ease: "easeInOut",
}}
>
<OverlayContent>
<div className="flex flex-col items-center justify-center gap-y-1">
<div className="animate flex h-12 w-12 items-center justify-center">
<LoadingSpinner className="h-8 w-8 text-blue-800 dark:text-blue-200" />
</div>
<p className="text-center text-sm text-slate-700 dark:text-slate-300">
{text}
</p>
</div>
</OverlayContent>
</motion.div>
)}
</AnimatePresence>
);
}
interface ConnectionErrorOverlayProps {
show: boolean;
setupPeerConnection: () => Promise<void>;
}
export function ConnectionErrorOverlay({
show,
setupPeerConnection,
}: ConnectionErrorOverlayProps) {
return (
<AnimatePresence>
{show && (
<motion.div
className="aspect-video h-full w-full"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0, transition: { duration: 0 } }}
transition={{
duration: 0.4,
ease: "easeInOut", ease: "easeInOut",
}} }}
> >
@ -87,14 +125,21 @@ export function ConnectionErrorOverlay({ show }: ConnectionErrorOverlayProps) {
<li>Try restarting both the device and your computer</li> <li>Try restarting both the device and your computer</li>
</ul> </ul>
</div> </div>
<div> <div className="flex items-center gap-x-2">
<LinkButton <LinkButton
to={"https://jetkvm.com/docs/getting-started/troubleshooting"} to={"https://jetkvm.com/docs/getting-started/troubleshooting"}
theme="light" theme="primary"
text="Troubleshooting Guide" text="Troubleshooting Guide"
TrailingIcon={ArrowRightIcon} TrailingIcon={ArrowRightIcon}
size="SM" size="SM"
/> />
<Button
onClick={() => setupPeerConnection()}
LeadingIcon={ArrowPathIcon}
text="Try again"
size="SM"
theme="light"
/>
</div> </div>
</div> </div>
</div> </div>

View File

@ -19,9 +19,8 @@ import { useJsonRpc } from "@/hooks/useJsonRpc";
import { import {
HDMIErrorOverlay, HDMIErrorOverlay,
LoadingVideoOverlay,
NoAutoplayPermissionsOverlay, NoAutoplayPermissionsOverlay,
ConnectionErrorOverlay,
LoadingOverlay,
} from "./VideoOverlay"; } from "./VideoOverlay";
export default function WebRTCVideo() { export default function WebRTCVideo() {
@ -46,15 +45,13 @@ export default function WebRTCVideo() {
// RTC related states // RTC related states
const peerConnection = useRTCStore(state => state.peerConnection); const peerConnection = useRTCStore(state => state.peerConnection);
const peerConnectionState = useRTCStore(state => state.peerConnectionState);
// HDMI and UI states // HDMI and UI states
const hdmiState = useVideoStore(state => state.hdmiState); const hdmiState = useVideoStore(state => state.hdmiState);
const hdmiError = ["no_lock", "no_signal", "out_of_range"].includes(hdmiState); const hdmiError = ["no_lock", "no_signal", "out_of_range"].includes(hdmiState);
const isLoading = !hdmiError && !isPlaying; const isVideoLoading = !isPlaying;
const isConnectionError = ["error", "failed", "disconnected", "closed"].includes(
peerConnectionState || "", // console.log("peerConnection?.connectionState", peerConnection?.connectionState);
);
// Keyboard related states // Keyboard related states
const { setIsNumLockActive, setIsCapsLockActive, setIsScrollLockActive } = const { setIsNumLockActive, setIsCapsLockActive, setIsScrollLockActive } =
@ -379,25 +376,52 @@ export default function WebRTCVideo() {
} }
}, []); }, []);
const addStreamToVideoElm = useCallback(
(mediaStream: MediaStream) => {
if (!videoElm.current) return;
const videoElmRefValue = videoElm.current;
console.log("Adding stream to video element", videoElmRefValue);
videoElmRefValue.srcObject = mediaStream;
updateVideoSizeStore(videoElmRefValue);
},
[updateVideoSizeStore],
);
useEffect(
function updateVideoStreamOnNewTrack() {
if (!peerConnection) return;
const abortController = new AbortController();
const signal = abortController.signal;
peerConnection.addEventListener(
"track",
(e: RTCTrackEvent) => {
console.log("Adding stream to video element");
addStreamToVideoElm(e.streams[0]);
},
{ signal },
);
return () => {
abortController.abort();
};
},
[addStreamToVideoElm, peerConnection],
);
useEffect( useEffect(
function updateVideoStream() { function updateVideoStream() {
if (!mediaStream) return; if (!mediaStream) return;
if (!videoElm.current) return; console.log("Updating video stream from mediaStream");
if (peerConnection?.iceConnectionState !== "connected") return; // We set the as early as possible
addStreamToVideoElm(mediaStream);
setTimeout(() => {
if (videoElm?.current) {
videoElm.current.srcObject = mediaStream;
}
}, 0);
updateVideoSizeStore(videoElm.current);
}, },
[ [
setVideoClientSize, setVideoClientSize,
setVideoSize,
mediaStream, mediaStream,
updateVideoSizeStore, updateVideoSizeStore,
peerConnection?.iceConnectionState, peerConnection,
addStreamToVideoElm,
], ],
); );
@ -474,6 +498,8 @@ export default function WebRTCVideo() {
const local = resetMousePosition; const local = resetMousePosition;
window.addEventListener("blur", local, { signal }); window.addEventListener("blur", local, { signal });
document.addEventListener("visibilitychange", local, { signal }); document.addEventListener("visibilitychange", local, { signal });
const preventContextMenu = (e: MouseEvent) => e.preventDefault();
videoElmRefValue.addEventListener("contextmenu", preventContextMenu, { signal });
return () => { return () => {
abortController.abort(); abortController.abort();
@ -517,17 +543,17 @@ export default function WebRTCVideo() {
); );
const hasNoAutoPlayPermissions = useMemo(() => { const hasNoAutoPlayPermissions = useMemo(() => {
if (peerConnectionState !== "connected") return false; if (peerConnection?.connectionState !== "connected") return false;
if (isPlaying) return false; if (isPlaying) return false;
if (hdmiError) return false; if (hdmiError) return false;
if (videoHeight === 0 || videoWidth === 0) return false; if (videoHeight === 0 || videoWidth === 0) return false;
return true; return true;
}, [peerConnectionState, isPlaying, hdmiError, videoHeight, videoWidth]); }, [peerConnection?.connectionState, isPlaying, hdmiError, videoHeight, videoWidth]);
return ( return (
<div className="grid h-full w-full grid-rows-layout"> <div className="grid h-full w-full grid-rows-layout">
<div className="min-h-[39.5px]"> <div className="min-h-[39.5px]">
<fieldset disabled={peerConnectionState !== "connected"}> <fieldset disabled={peerConnection?.connectionState !== "connected"}>
<Actionbar <Actionbar
requestFullscreen={async () => requestFullscreen={async () =>
videoElm.current?.requestFullscreen({ videoElm.current?.requestFullscreen({
@ -575,28 +601,29 @@ export default function WebRTCVideo() {
"cursor-none": "cursor-none":
settings.mouseMode === "absolute" && settings.mouseMode === "absolute" &&
settings.isCursorHidden, settings.isCursorHidden,
"opacity-0": isLoading || isConnectionError || hdmiError, "opacity-0": isVideoLoading || hdmiError,
"animate-slideUpFade border border-slate-800/30 opacity-0 shadow dark:border-slate-300/20": "animate-slideUpFade border border-slate-800/30 opacity-0 shadow dark:border-slate-300/20":
isPlaying, isPlaying,
}, },
)} )}
/> />
<div {peerConnection?.connectionState == "connected" && (
style={{ animationDuration: "500ms" }} <div
className="pointer-events-none absolute inset-0 flex animate-slideUpFade items-center justify-center opacity-0" style={{ animationDuration: "500ms" }}
> className="pointer-events-none absolute inset-0 flex animate-slideUpFade items-center justify-center opacity-0"
<div className="relative h-full max-h-[720px] w-full max-w-[1280px] rounded-md"> >
<LoadingOverlay show={isLoading} /> <div className="relative h-full max-h-[720px] w-full max-w-[1280px] rounded-md">
<ConnectionErrorOverlay show={isConnectionError} /> <LoadingVideoOverlay show={isVideoLoading} />
<HDMIErrorOverlay show={hdmiError} hdmiState={hdmiState} /> <HDMIErrorOverlay show={hdmiError} hdmiState={hdmiState} />
<NoAutoplayPermissionsOverlay <NoAutoplayPermissionsOverlay
show={hasNoAutoPlayPermissions} show={hasNoAutoPlayPermissions}
onPlayClick={() => { onPlayClick={() => {
videoElm.current?.play(); videoElm.current?.play();
}} }}
/> />
</div>
</div> </div>
</div> )}
</div> </div>
</div> </div>
<VirtualKeyboard /> <VirtualKeyboard />

View File

@ -6,7 +6,7 @@ import LogoBlue from "@/assets/logo-blue.svg";
import LogoWhite from "@/assets/logo-white.svg"; import LogoWhite from "@/assets/logo-white.svg";
interface ContextType { interface ContextType {
connectWebRTC: () => Promise<void>; setupPeerConnection: () => Promise<void>;
} }
/* TODO: Migrate to using URLs instead of the global state. To simplify the refactoring, we'll keep the global state for now. */ /* TODO: Migrate to using URLs instead of the global state. To simplify the refactoring, we'll keep the global state for now. */
@ -16,7 +16,7 @@ export default function OtherSessionRoute() {
// Function to handle closing the modal // Function to handle closing the modal
const handleClose = () => { const handleClose = () => {
outletContext?.connectWebRTC().then(() => navigate("..")); outletContext?.setupPeerConnection().then(() => navigate(".."));
}; };
return ( return (

View File

@ -45,6 +45,10 @@ import Modal from "../components/Modal";
import { useDeviceUiNavigation } from "../hooks/useAppNavigation"; import { useDeviceUiNavigation } from "../hooks/useAppNavigation";
import { FeatureFlagProvider } from "../providers/FeatureFlagProvider"; import { FeatureFlagProvider } from "../providers/FeatureFlagProvider";
import notifications from "../notifications"; import notifications from "../notifications";
import {
ConnectionErrorOverlay,
LoadingConnectionOverlay,
} from "../components/VideoOverlay";
import { SystemVersionInfo } from "./devices.$id.settings.general.update"; import { SystemVersionInfo } from "./devices.$id.settings.general.update";
import { DeviceStatus } from "./welcome-local"; import { DeviceStatus } from "./welcome-local";
@ -126,8 +130,6 @@ export default function KvmIdRoute() {
const setIsTurnServerInUse = useRTCStore(state => state.setTurnServerInUse); const setIsTurnServerInUse = useRTCStore(state => state.setTurnServerInUse);
const peerConnection = useRTCStore(state => state.peerConnection); const peerConnection = useRTCStore(state => state.peerConnection);
const setPeerConnectionState = useRTCStore(state => state.setPeerConnectionState);
const setMediaMediaStream = useRTCStore(state => state.setMediaStream); const setMediaMediaStream = useRTCStore(state => state.setMediaStream);
const setPeerConnection = useRTCStore(state => state.setPeerConnection); const setPeerConnection = useRTCStore(state => state.setPeerConnection);
const setDiskChannel = useRTCStore(state => state.setDiskChannel); const setDiskChannel = useRTCStore(state => state.setDiskChannel);
@ -135,77 +137,55 @@ export default function KvmIdRoute() {
const setTransceiver = useRTCStore(state => state.setTransceiver); const setTransceiver = useRTCStore(state => state.setTransceiver);
const location = useLocation(); const location = useLocation();
const [connectionAttempts, setConnectionAttempts] = useState(0);
const [startedConnectingAt, setStartedConnectingAt] = useState<Date | null>(null);
const [connectedAt, setConnectedAt] = useState<Date | null>(null);
const [connectionFailed, setConnectionFailed] = useState(false); const [connectionFailed, setConnectionFailed] = useState(false);
const navigate = useNavigate(); const navigate = useNavigate();
const { otaState, setOtaState, setModalView } = useUpdateStore(); const { otaState, setOtaState, setModalView } = useUpdateStore();
const [loadingMessage, setLoadingMessage] = useState("Connecting to device...");
const closePeerConnection = useCallback( const closePeerConnection = useCallback(
function closePeerConnection() { function closePeerConnection() {
console.log("Closing peer connection");
setConnectionFailed(true);
connectionFailedRef.current = true;
peerConnection?.close(); peerConnection?.close();
// "closed" is a valid RTCPeerConnection state according to the WebRTC spec signalingAttempts.current = 0;
// https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/connectionState#closed
// However, the onconnectionstatechange event doesn't fire when close() is called manually
// So we need to explicitly update our state to maintain consistency
// I don't know why this is happening, but this is the best way I can think of to handle it
setPeerConnectionState("closed");
}, },
[peerConnection, setPeerConnectionState], [peerConnection],
); );
// We need to track connectionFailed in a ref to avoid stale closure issues
// This is necessary because syncRemoteSessionDescription is a callback that captures
// the connectionFailed value at creation time, but we need the latest value
// when the function is actually called. Without this ref, the function would use
// a stale value of connectionFailed in some conditions.
//
// We still need the state variable for UI rendering, so we sync the ref with the state.
// This pattern is a workaround for what useEvent hook would solve more elegantly
// (which would give us a callback that always has access to latest state without re-creation).
const connectionFailedRef = useRef(false);
useEffect(() => { useEffect(() => {
const connectionAttemptsThreshold = 30; connectionFailedRef.current = connectionFailed;
if (connectionAttempts > connectionAttemptsThreshold) { }, [connectionFailed]);
console.log(`Connection failed after ${connectionAttempts} attempts.`);
setConnectionFailed(true);
closePeerConnection();
}
}, [connectionAttempts, closePeerConnection]);
useEffect(() => {
// Skip if already connected
if (connectedAt) return;
// Skip if connection is declared as failed
if (connectionFailed) return;
const interval = setInterval(() => {
console.log("Checking connection status");
// Skip if connection hasn't started
if (!startedConnectingAt) return;
const elapsedTime = Math.floor(
new Date().getTime() - startedConnectingAt.getTime(),
);
// Fail connection if it's been over X seconds since we started connecting
if (elapsedTime > 60 * 1000) {
console.error(`Connection failed after ${elapsedTime} ms.`);
setConnectionFailed(true);
closePeerConnection();
}
}, 1000);
return () => clearInterval(interval);
}, [closePeerConnection, connectedAt, connectionFailed, startedConnectingAt]);
const sdp = useCallback(
async (event: RTCPeerConnectionIceEvent, pc: RTCPeerConnection) => {
if (!pc) return;
if (event.candidate !== null) return;
const signalingAttempts = useRef(0);
const syncRemoteSessionDescription = useCallback(
async function syncRemoteSessionDescription(pc: RTCPeerConnection) {
try { try {
if (!pc) return;
const sd = btoa(JSON.stringify(pc.localDescription)); const sd = btoa(JSON.stringify(pc.localDescription));
const sessionUrl = isOnDevice const sessionUrl = isOnDevice
? `${DEVICE_API}/webrtc/session` ? `${DEVICE_API}/webrtc/session`
: `${CLOUD_API}/webrtc/session`; : `${CLOUD_API}/webrtc/session`;
console.log("Trying to get remote session description");
setLoadingMessage(
`Getting remote session description... ${signalingAttempts.current > 0 ? `(attempt ${signalingAttempts.current + 1})` : ""}`,
);
const res = await api.POST(sessionUrl, { const res = await api.POST(sessionUrl, {
sd, sd,
// When on device, we don't need to specify the device id, as it's already known // When on device, we don't need to specify the device id, as it's already known
@ -213,66 +193,109 @@ export default function KvmIdRoute() {
}); });
const json = await res.json(); const json = await res.json();
if (res.status === 401) return navigate(isOnDevice ? "/login-local" : "/login");
if (isOnDevice) { if (!res.ok) {
if (res.status === 401) { console.error("Error getting SDP", { status: res.status, json });
return navigate("/login-local"); throw new Error("Error getting SDP");
}
} }
if (isInCloud) { console.log("Successfully got Remote Session Description. Setting.");
// The cloud API returns a 401 if the user is not logged in setLoadingMessage("Setting remote session description...");
// Most likely the session has expired
if (res.status === 401) return navigate("/login");
// If can be a few things const decodedSd = atob(json.sd);
// - In cloud mode, the cloud api would return a 404, if the device hasn't contacted the cloud yet const parsedSd = JSON.parse(decodedSd);
// - In device mode, the device api would timeout, the fetch would throw an error, therefore the catch block would be hit pc.setRemoteDescription(new RTCSessionDescription(parsedSd));
// Regardless, we should close the peer connection and let the useInterval handle reconnecting
if (!res.ok) {
closePeerConnection();
console.error(`Error setting SDP - Status: ${res.status}}`, json);
return;
}
}
pc.setRemoteDescription( await new Promise((resolve, reject) => {
new RTCSessionDescription(JSON.parse(atob(json.sd))), console.log("Waiting for remote description to be set");
).catch(e => console.log(`Error setting remote description: ${e}`)); const maxAttempts = 10;
const interval = 1000;
let attempts = 0;
const checkInterval = setInterval(() => {
attempts++;
// When vivaldi has disabled "Broadcast IP for Best WebRTC Performance", this never connects
if (pc.sctp?.state === "connected") {
console.log("Remote description set");
clearInterval(checkInterval);
resolve(true);
} else if (attempts >= maxAttempts) {
console.log(
`Failed to get remote description after ${maxAttempts} attempts`,
);
closePeerConnection();
clearInterval(checkInterval);
reject(
new Error(
`Failed to get remote description after ${maxAttempts} attempts`,
),
);
} else {
console.log("Waiting for remote description to be set");
}
}, interval);
});
} catch (error) { } catch (error) {
console.error(`Error setting SDP: ${error}`); console.error("Error getting SDP", { error });
closePeerConnection(); console.log("Connection failed", connectionFailedRef.current);
if (connectionFailedRef.current) return;
if (signalingAttempts.current < 5) {
signalingAttempts.current++;
await new Promise(resolve => setTimeout(resolve, 500));
console.log("Attempting to get SDP again", signalingAttempts.current);
syncRemoteSessionDescription(pc);
} else {
closePeerConnection();
}
} }
}, },
[closePeerConnection, navigate, params.id], [closePeerConnection, navigate, params.id],
); );
const connectWebRTC = useCallback(async () => { const setupPeerConnection = useCallback(async () => {
console.log("Attempting to connect WebRTC"); console.log("Setting up peer connection");
setConnectionFailed(false);
setLoadingMessage("Connecting to device...");
// Track connection status to detect failures and show error overlay let pc: RTCPeerConnection;
setConnectionAttempts(x => x + 1); try {
setStartedConnectingAt(new Date()); console.log("Creating peer connection");
setConnectedAt(null); setLoadingMessage("Creating peer connection...");
pc = new RTCPeerConnection({
const pc = new RTCPeerConnection({ // We only use STUN or TURN servers if we're in the cloud
// We only use STUN or TURN servers if we're in the cloud ...(isInCloud && iceConfig?.iceServers
...(isInCloud && iceConfig?.iceServers ? { iceServers: [iceConfig?.iceServers] }
? { iceServers: [iceConfig?.iceServers] } : {}),
: {}), });
}); console.log("Peer connection created", pc);
setLoadingMessage("Peer connection created");
} catch (e) {
console.error(`Error creating peer connection: ${e}`);
setTimeout(() => {
closePeerConnection();
}, 1000);
return;
}
// Set up event listeners and data channels // Set up event listeners and data channels
pc.onconnectionstatechange = () => { pc.onconnectionstatechange = () => {
// If the connection state is connected, we reset the connection attempts. console.log("Connection state changed", pc.connectionState);
if (pc.connectionState === "connected") {
setConnectionAttempts(0);
setConnectedAt(new Date());
}
setPeerConnectionState(pc.connectionState);
}; };
pc.onicecandidate = event => sdp(event, pc); pc.onicegatheringstatechange = event => {
const pc = event.currentTarget as RTCPeerConnection;
console.log("ICE Gathering State Changed", pc.iceGatheringState);
if (pc.iceGatheringState === "complete") {
console.log("ICE Gathering completed");
setLoadingMessage("ICE Gathering completed");
// We can now start the https/ws connection to get the remote session description from the KVM device
syncRemoteSessionDescription(pc);
} else if (pc.iceGatheringState === "gathering") {
console.log("ICE Gathering Started");
setLoadingMessage("Gathering ICE candidates...");
}
};
pc.ontrack = function (event) { pc.ontrack = function (event) {
setMediaMediaStream(event.streams[0]); setMediaMediaStream(event.streams[0]);
@ -290,60 +313,32 @@ export default function KvmIdRoute() {
setDiskChannel(diskDataChannel); setDiskChannel(diskDataChannel);
}; };
setPeerConnection(pc);
try { try {
const offer = await pc.createOffer(); const offer = await pc.createOffer();
await pc.setLocalDescription(offer); await pc.setLocalDescription(offer);
setPeerConnection(pc);
} catch (e) { } catch (e) {
console.error(`Error creating offer: ${e}`); console.error(`Error creating offer: ${e}`, new Date().toISOString());
closePeerConnection();
} }
}, [ }, [
closePeerConnection,
iceConfig?.iceServers, iceConfig?.iceServers,
sdp,
setDiskChannel, setDiskChannel,
setMediaMediaStream, setMediaMediaStream,
setPeerConnection, setPeerConnection,
setPeerConnectionState,
setRpcDataChannel, setRpcDataChannel,
setTransceiver, setTransceiver,
]); syncRemoteSessionDescription,
useEffect(() => {
console.log("Attempting to connect WebRTC");
// If we're in an other session, we don't need to connect
if (location.pathname.includes("other-session")) return;
// If we're already connected or connecting, we don't need to connect
if (
["connected", "connecting", "new"].includes(peerConnection?.connectionState ?? "")
) {
return;
}
// In certain cases, we want to never connect again. This happens when we've tried for a long time and failed
if (connectionFailed) {
console.log("Connection failed. We won't attempt to connect again.");
return;
}
const interval = setInterval(() => {
connectWebRTC();
}, 3000);
return () => clearInterval(interval);
}, [
connectWebRTC,
connectionFailed,
location.pathname,
peerConnection?.connectionState,
]); ]);
// On boot, if the connection state is undefined, we connect to the WebRTC // On boot, if the connection state is undefined, we connect to the WebRTC
useEffect(() => { useEffect(() => {
if (peerConnection?.connectionState === undefined) { if (peerConnection?.connectionState === undefined) {
connectWebRTC(); setupPeerConnection();
} }
}, [connectWebRTC, peerConnection?.connectionState]); }, [setupPeerConnection, peerConnection?.connectionState]);
// Cleanup effect // Cleanup effect
const clearInboundRtpStats = useRTCStore(state => state.clearInboundRtpStats); const clearInboundRtpStats = useRTCStore(state => state.clearInboundRtpStats);
@ -597,7 +592,27 @@ export default function KvmIdRoute() {
kvmName={deviceName || "JetKVM Device"} kvmName={deviceName || "JetKVM Device"}
/> />
<div className="flex h-full overflow-hidden"> <div className="flex h-full w-full overflow-hidden">
<div className="pointer-events-none fixed inset-0 isolate z-50 flex h-full w-full items-center justify-center">
<div className="my-2 h-full max-h-[720px] w-full max-w-[1280px] rounded-md">
<LoadingConnectionOverlay
show={
!connectionFailed &&
(["connecting", "new"].includes(
peerConnection?.connectionState || "",
) ||
peerConnection === null) &&
!location.pathname.includes("other-session")
}
text={loadingMessage}
/>
<ConnectionErrorOverlay
show={connectionFailed && !location.pathname.includes("other-session")}
setupPeerConnection={setupPeerConnection}
/>
</div>
</div>
<WebRTCVideo /> <WebRTCVideo />
<SidebarContainer sidebarView={sidebarView} /> <SidebarContainer sidebarView={sidebarView} />
</div> </div>
@ -614,7 +629,7 @@ export default function KvmIdRoute() {
> >
<Modal open={outlet !== null} onClose={onModalClose}> <Modal open={outlet !== null} onClose={onModalClose}>
{/* The 'used by other session' modal needs to have access to the connectWebRTC function */} {/* The 'used by other session' modal needs to have access to the connectWebRTC function */}
<Outlet context={{ connectWebRTC }} /> <Outlet context={{ setupPeerConnection }} />
</Modal> </Modal>
</div> </div>

2
web.go
View File

@ -16,6 +16,7 @@ import (
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
) )
//nolint:typecheck
//go:embed all:static //go:embed all:static
var staticFiles embed.FS var staticFiles embed.FS
@ -419,7 +420,6 @@ func handleSetup(c *gin.Context) {
// Set the cookie // Set the cookie
c.SetCookie("authToken", config.LocalAuthToken, 7*24*60*60, "/", "", false, true) c.SetCookie("authToken", config.LocalAuthToken, 7*24*60*60, "/", "", false, true)
} else { } else {
// For noPassword mode, ensure the password field is empty // For noPassword mode, ensure the password field is empty
config.HashedPassword = "" config.HashedPassword = ""