release 0.3.9 (#349)

This commit is contained in:
Aveline 2025-04-10 16:47:19 +02:00 committed by GitHub
commit 5452d7c721
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 172 additions and 53 deletions

View File

@ -4,6 +4,7 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
@ -59,6 +60,13 @@ var (
},
[]string{"type", "source"},
)
metricConnectionLastPingReceivedTimestamp = promauto.NewGaugeVec(
prometheus.GaugeOpts{
Name: "jetkvm_connection_last_ping_received_timestamp",
Help: "The timestamp when the last ping request was received",
},
[]string{"type", "source"},
)
metricConnectionLastPingDuration = promauto.NewGaugeVec(
prometheus.GaugeOpts{
Name: "jetkvm_connection_last_ping_duration",
@ -76,16 +84,23 @@ var (
},
[]string{"type", "source"},
)
metricConnectionTotalPingCount = promauto.NewCounterVec(
metricConnectionTotalPingSentCount = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "jetkvm_connection_total_ping_count",
Name: "jetkvm_connection_total_ping_sent",
Help: "The total number of pings sent to the connection",
},
[]string{"type", "source"},
)
metricConnectionTotalPingReceivedCount = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "jetkvm_connection_total_ping_received",
Help: "The total number of pings received from the connection",
},
[]string{"type", "source"},
)
metricConnectionSessionRequestCount = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "jetkvm_connection_session_total_request_count",
Name: "jetkvm_connection_session_total_requests",
Help: "The total number of session requests received",
},
[]string{"type", "source"},
@ -131,6 +146,8 @@ func wsResetMetrics(established bool, sourceType string, source string) {
metricConnectionLastPingTimestamp.WithLabelValues(sourceType, source).Set(-1)
metricConnectionLastPingDuration.WithLabelValues(sourceType, source).Set(-1)
metricConnectionLastPingReceivedTimestamp.WithLabelValues(sourceType, source).Set(-1)
metricConnectionLastSessionRequestTimestamp.WithLabelValues(sourceType, source).Set(-1)
metricConnectionLastSessionRequestDuration.WithLabelValues(sourceType, source).Set(-1)
@ -275,18 +292,31 @@ func runWebsocketClient() error {
defer cancelDial()
c, _, err := websocket.Dial(dialCtx, wsURL.String(), &websocket.DialOptions{
HTTPHeader: header,
OnPingReceived: func(ctx context.Context, payload []byte) bool {
websocketLogger.Infof("ping frame received: %v, source: %s, sourceType: cloud", payload, wsURL.Host)
metricConnectionTotalPingReceivedCount.WithLabelValues("cloud", wsURL.Host).Inc()
metricConnectionLastPingReceivedTimestamp.WithLabelValues("cloud", wsURL.Host).SetToCurrentTime()
return true
},
})
// if the context is canceled, we don't want to return an error
if err != nil {
if errors.Is(err, context.Canceled) {
cloudLogger.Infof("websocket connection canceled")
return nil
}
return err
}
defer c.CloseNow() //nolint:errcheck
cloudLogger.Infof("websocket connected to %s", wsURL)
// set the metrics when we successfully connect to the cloud.
wsResetMetrics(true, "cloud", "")
wsResetMetrics(true, "cloud", wsURL.Host)
// we don't have a source for the cloud connection
return handleWebRTCSignalWsMessages(c, true, "")
return handleWebRTCSignalWsMessages(c, true, wsURL.Host)
}
func authenticateSession(ctx context.Context, c *websocket.Conn, req WebRTCSessionRequest) error {
@ -375,9 +405,6 @@ func handleSessionRequest(ctx context.Context, c *websocket.Conn, req WebRTCSess
func RunWebsocketClient() {
for {
// reset the metrics when we start the websocket client.
wsResetMetrics(false, "cloud", "")
// If the cloud token is not set, we don't need to run the websocket client.
if config.CloudToken == "" {
time.Sleep(5 * time.Second)

View File

@ -1,3 +1,5 @@
#!/usr/bin/env bash
#
# Exit immediately if a command exits with a non-zero status
set -e
@ -16,7 +18,6 @@ show_help() {
echo "Example:"
echo " $0 -r 192.168.0.17"
echo " $0 -r 192.168.0.17 -u admin"
exit 0
}
# Default values
@ -70,10 +71,10 @@ cd bin
ssh "${REMOTE_USER}@${REMOTE_HOST}" "killall jetkvm_app_debug || true"
# Copy the binary to the remote host
cat jetkvm_app | ssh "${REMOTE_USER}@${REMOTE_HOST}" "cat > $REMOTE_PATH/jetkvm_app_debug"
ssh "${REMOTE_USER}@${REMOTE_HOST}" "cat > ${REMOTE_PATH}/jetkvm_app_debug" < jetkvm_app
# Deploy and run the application on the remote host
ssh "${REMOTE_USER}@${REMOTE_HOST}" ash <<EOF
ssh "${REMOTE_USER}@${REMOTE_HOST}" ash << EOF
set -e
# Set the library path to include the directory where librockit.so is located
@ -84,13 +85,13 @@ killall jetkvm_app || true
killall jetkvm_app_debug || true
# Navigate to the directory where the binary will be stored
cd "$REMOTE_PATH"
cd "${REMOTE_PATH}"
# Make the new binary executable
chmod +x jetkvm_app_debug
# Run the application in the background
PION_LOG_TRACE=jetkvm,cloud ./jetkvm_app_debug
PION_LOG_TRACE=jetkvm,cloud,websocket ./jetkvm_app_debug
EOF
echo "Deployment complete."

2
go.mod
View File

@ -7,7 +7,7 @@ toolchain go1.21.1
require (
github.com/Masterminds/semver/v3 v3.3.0
github.com/beevik/ntp v1.3.1
github.com/coder/websocket v1.8.12
github.com/coder/websocket v1.8.13
github.com/coreos/go-oidc/v3 v3.11.0
github.com/creack/pty v1.1.23
github.com/gin-gonic/gin v1.9.1

2
go.sum
View File

@ -18,6 +18,8 @@ github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo=
github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
github.com/coder/websocket v1.8.13 h1:f3QZdXy7uGVz+4uCJy2nTZyM0yTBj8yANEHhqlXZ9FE=
github.com/coder/websocket v1.8.13/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
github.com/coreos/go-oidc/v3 v3.11.0 h1:Ia3MxdwpSw702YW0xgfmP1GVCMA9aEFWu12XUZ3/OtI=
github.com/coreos/go-oidc/v3 v3.11.0/go.mod h1:gE3LgjOgFoHi9a4ce4/tJczr0Ai2/BoDhf0r5lltWI0=
github.com/creack/goselect v0.1.2 h1:2DNy14+JPjRBgPzAd1thbQp4BSIihxcBf0IXhQXDRa0=

6
ota.go
View File

@ -126,12 +126,10 @@ func downloadFile(ctx context.Context, path string, url string, downloadProgress
return fmt.Errorf("error creating request: %w", err)
}
// TODO: set a separate timeout for the download but keep the TLS handshake short
// use Transport here will cause CA certificate validation failure so we temporarily removed it
client := http.Client{
// allow a longer timeout for the download but keep the TLS handshake short
Timeout: 10 * time.Minute,
Transport: &http.Transport{
TLSHandshakeTimeout: 1 * time.Minute,
},
}
resp, err := client.Do(req)

View File

@ -1,4 +1,4 @@
#!/bin/bash
#!/usr/bin/env bash
# Check if a commit message was provided
if [ -z "$1" ]; then
@ -26,7 +26,7 @@ git checkout -b release-temp
if git ls-remote --heads public main | grep -q 'refs/heads/main'; then
git reset --soft public/main
else
git reset --soft $(git rev-list --max-parents=0 HEAD)
git reset --soft "$(git rev-list --max-parents=0 HEAD)"
fi
# Merge changes from main

View File

@ -1,4 +1,4 @@
#!/bin/bash
#!/usr/bin/env bash
# Check if an IP address was provided as an argument
if [ -z "$1" ]; then
@ -16,4 +16,4 @@ echo "└───────────────────────
# Set the environment variable and run Vite
echo "Starting development server with JetKVM device at: $ip_address"
sleep 1
JETKVM_PROXY_URL="http://$ip_address" npx vite dev --mode=device
JETKVM_PROXY_URL="ws://$ip_address" npx vite dev --mode=device

View File

@ -15,12 +15,12 @@ const Modal = React.memo(function Modal({
onClose: () => void;
}) {
return (
<Dialog open={open} onClose={onClose} className="relative z-10">
<Dialog open={open} onClose={onClose} className="relative z-20">
<DialogBackdrop
transition
className="fixed inset-0 bg-gray-500/75 transition-opacity data-[closed]:opacity-0 data-[enter]:duration-500 data-[leave]:duration-200 data-[enter]:ease-out data-[leave]:ease-in dark:bg-slate-900/90"
/>
<div className="fixed inset-0 z-10 w-screen overflow-y-auto">
<div className="fixed inset-0 z-20 w-screen overflow-y-auto">
{/* TODO: This doesn't work well with other-sessions */}
<div className="flex min-h-full items-end justify-center p-4 text-center md:items-baseline md:p-4">
<DialogPanel

View File

@ -28,6 +28,7 @@ export default function WebRTCVideo() {
const videoElm = useRef<HTMLVideoElement>(null);
const mediaStream = useRTCStore(state => state.mediaStream);
const [isPlaying, setIsPlaying] = useState(false);
const peerConnectionState = useRTCStore(state => state.peerConnectionState);
// Store hooks
const settings = useSettingsStore();
@ -601,7 +602,10 @@ export default function WebRTCVideo() {
"cursor-none":
settings.mouseMode === "absolute" &&
settings.isCursorHidden,
"opacity-0": isVideoLoading || hdmiError,
"opacity-0":
isVideoLoading ||
hdmiError ||
peerConnectionState !== "connected",
"animate-slideUpFade border border-slate-800/30 opacity-0 shadow dark:border-slate-300/20":
isPlaying,
},

View File

@ -243,7 +243,7 @@ export default function KvmIdRoute() {
{
heartbeat: true,
retryOnError: true,
reconnectAttempts: 5,
reconnectAttempts: 15,
reconnectInterval: 1000,
onReconnectStop: () => {
console.log("Reconnect stopped");
@ -398,11 +398,6 @@ export default function KvmIdRoute() {
setConnectionFailed(false);
setLoadingMessage("Connecting to device...");
if (peerConnection?.signalingState === "stable") {
console.log("[setupPeerConnection] Peer connection already established");
return;
}
let pc: RTCPeerConnection;
try {
console.log("[setupPeerConnection] Creating peer connection");
@ -499,7 +494,6 @@ export default function KvmIdRoute() {
cleanupAndStopReconnecting,
iceConfig?.iceServers,
legacyHTTPSignaling,
peerConnection?.signalingState,
sendWebRTCSignal,
setDiskChannel,
setMediaMediaStream,
@ -791,6 +785,7 @@ export default function KvmIdRoute() {
<button className="absolute top-0" tabIndex={-1} id="videoFocusTrap" />
</div>
</FocusTrap>
<div className="grid h-full select-none grid-rows-headerBody">
<DashboardNavbar
primaryLinks={isOnDevice ? [] : [{ title: "Cloud Devices", to: "/devices" }]}
@ -801,21 +796,23 @@ export default function KvmIdRoute() {
kvmName={deviceName || "JetKVM Device"}
/>
<div className="flex h-full w-full overflow-hidden">
<div className="pointer-events-none fixed inset-0 isolate z-20 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">
<div className="relative flex h-full w-full overflow-hidden">
<WebRTCVideo />
<div
style={{ animationDuration: "500ms" }}
className="pointer-events-none absolute inset-0 flex animate-slideUpFade items-center justify-center p-4 opacity-0"
>
<div className="relative h-full max-h-[720px] w-full max-w-[1280px] rounded-md">
{!!ConnectionStatusElement && ConnectionStatusElement}
</div>
</div>
{peerConnectionState === "connected" && <WebRTCVideo />}
<SidebarContainer sidebarView={sidebarView} />
</div>
</div>
</div>
<div
className="isolate"
className="z-50"
onKeyUp={e => e.stopPropagation()}
onKeyDown={e => {
e.stopPropagation();

120
web.go
View File

@ -1,9 +1,11 @@
package kvm
import (
"bytes"
"context"
"embed"
"encoding/json"
"errors"
"fmt"
"io/fs"
"net/http"
@ -99,6 +101,22 @@ func setupRouter() *gin.Engine {
protected := r.Group("/")
protected.Use(protectedMiddleware())
{
/*
* Legacy WebRTC session endpoint
*
* This endpoint is maintained for backward compatibility when users upgrade from a version
* using the legacy HTTP-based signaling method to the new WebSocket-based signaling method.
*
* During the upgrade process, when the "Rebooting device after update..." message appears,
* the browser still runs the previous JavaScript code which polls this endpoint to establish
* a new WebRTC session. Once the session is established, the page will automatically reload
* with the updated code.
*
* Without this endpoint, the stale JavaScript would fail to establish a connection,
* causing users to see the "Rebooting device after update..." message indefinitely
* until they manually refresh the page, leading to a confusing user experience.
*/
protected.POST("/webrtc/session", handleWebRTCSession)
protected.GET("/webrtc/signaling/client", handleLocalWebRTCSignal)
protected.POST("/cloud/register", handleCloudRegister)
protected.GET("/cloud/state", handleCloudState)
@ -126,11 +144,59 @@ func setupRouter() *gin.Engine {
// TODO: support multiple sessions?
var currentSession *Session
func handleWebRTCSession(c *gin.Context) {
var req WebRTCSessionRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
session, err := newSession(SessionConfig{})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err})
return
}
sd, err := session.ExchangeOffer(req.Sd)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err})
return
}
if currentSession != nil {
writeJSONRPCEvent("otherSessionConnected", nil, currentSession)
peerConn := currentSession.peerConnection
go func() {
time.Sleep(1 * time.Second)
_ = peerConn.Close()
}()
}
currentSession = session
c.JSON(http.StatusOK, gin.H{"sd": sd})
}
var (
pingMessage = []byte("ping")
pongMessage = []byte("pong")
)
func handleLocalWebRTCSignal(c *gin.Context) {
cloudLogger.Infof("new websocket connection established")
// get the source from the request
source := c.ClientIP()
// Create WebSocket options with InsecureSkipVerify to bypass origin check
wsOptions := &websocket.AcceptOptions{
InsecureSkipVerify: true, // Allow connections from any origin
OnPingReceived: func(ctx context.Context, payload []byte) bool {
websocketLogger.Infof("ping frame received: %v, source: %s, sourceType: local", payload, source)
metricConnectionTotalPingReceivedCount.WithLabelValues("local", source).Inc()
metricConnectionLastPingReceivedTimestamp.WithLabelValues("local", source).SetToCurrentTime()
return true
},
}
wsCon, err := websocket.Accept(c.Writer, c.Request, wsOptions)
@ -139,9 +205,6 @@ func handleLocalWebRTCSignal(c *gin.Context) {
return
}
// get the source from the request
source := c.ClientIP()
// Now use conn for websocket operations
defer wsCon.Close(websocket.StatusNormalClosure, "")
@ -164,7 +227,6 @@ func handleWebRTCSignalWsMessages(wsCon *websocket.Conn, isCloudConnection bool,
// Add connection tracking to detect reconnections
connectionID := uuid.New().String()
cloudLogger.Infof("new websocket connection established with ID: %s", connectionID)
// connection type
var sourceType string
@ -176,29 +238,40 @@ func handleWebRTCSignalWsMessages(wsCon *websocket.Conn, isCloudConnection bool,
// probably we can use a better logging framework here
logInfof := func(format string, args ...interface{}) {
args = append(args, source, sourceType)
websocketLogger.Infof(format+", source: %s, sourceType: %s", args...)
args = append(args, source, sourceType, connectionID)
websocketLogger.Infof(format+", source: %s, sourceType: %s, id: %s", args...)
}
logWarnf := func(format string, args ...interface{}) {
args = append(args, source, sourceType)
websocketLogger.Warnf(format+", source: %s, sourceType: %s", args...)
args = append(args, source, sourceType, connectionID)
websocketLogger.Warnf(format+", source: %s, sourceType: %s, id: %s", args...)
}
logTracef := func(format string, args ...interface{}) {
args = append(args, source, sourceType)
websocketLogger.Tracef(format+", source: %s, sourceType: %s", args...)
args = append(args, source, sourceType, connectionID)
websocketLogger.Tracef(format+", source: %s, sourceType: %s, id: %s", args...)
}
logInfof("new websocket connection established")
go func() {
for {
time.Sleep(WebsocketPingInterval)
if ctxErr := runCtx.Err(); ctxErr != nil {
if !errors.Is(ctxErr, context.Canceled) {
logWarnf("websocket connection closed: %v", ctxErr)
} else {
logTracef("websocket connection closed as the context was canceled: %v")
}
return
}
// set the timer for the ping duration
timer := prometheus.NewTimer(prometheus.ObserverFunc(func(v float64) {
metricConnectionLastPingDuration.WithLabelValues(sourceType, source).Set(v)
metricConnectionPingDuration.WithLabelValues(sourceType, source).Observe(v)
}))
logInfof("pinging websocket")
logTracef("sending ping frame")
err := wsCon.Ping(runCtx)
if err != nil {
@ -208,10 +281,12 @@ func handleWebRTCSignalWsMessages(wsCon *websocket.Conn, isCloudConnection bool,
}
// dont use `defer` here because we want to observe the duration of the ping
timer.ObserveDuration()
duration := timer.ObserveDuration()
metricConnectionTotalPingCount.WithLabelValues(sourceType, source).Inc()
metricConnectionTotalPingSentCount.WithLabelValues(sourceType, source).Inc()
metricConnectionLastPingTimestamp.WithLabelValues(sourceType, source).SetToCurrentTime()
logTracef("received pong frame, duration: %v", duration)
}
}()
@ -249,6 +324,20 @@ func handleWebRTCSignalWsMessages(wsCon *websocket.Conn, isCloudConnection bool,
Data json.RawMessage `json:"data"`
}
if bytes.Equal(msg, pingMessage) {
logInfof("ping message received: %s", string(msg))
err = wsCon.Write(context.Background(), websocket.MessageText, pongMessage)
if err != nil {
logWarnf("unable to write pong message: %v", err)
return err
}
metricConnectionTotalPingReceivedCount.WithLabelValues(sourceType, source).Inc()
metricConnectionLastPingReceivedTimestamp.WithLabelValues(sourceType, source).SetToCurrentTime()
continue
}
err = json.Unmarshal(msg, &message)
if err != nil {
logWarnf("unable to parse ws message: %v", err)
@ -264,8 +353,9 @@ func handleWebRTCSignalWsMessages(wsCon *websocket.Conn, isCloudConnection bool,
continue
}
logInfof("new session request: %v", req.OidcGoogle)
logTracef("session request info: %v", req)
if req.OidcGoogle != "" {
logInfof("new session request with OIDC Google: %v", req.OidcGoogle)
}
metricConnectionSessionRequestCount.WithLabelValues(sourceType, source).Inc()
metricConnectionLastSessionRequestTimestamp.WithLabelValues(sourceType, source).SetToCurrentTime()