Compare commits

...

6 Commits

Author SHA1 Message Date
Aveline 87adfee033
Merge b49d67c87d into 36f06a064a 2025-11-06 23:40:54 +01:00
Siyuan b49d67c87d fix: RX goroutine cleanup 2025-11-06 22:40:44 +00:00
Siyuan 621be3c1d9 fix: PR issues 2025-11-06 21:54:56 +00:00
tadic-luka 36f06a064a
feat: add web route for sending WOL package to given mac addr (#945)
* feat: add web route for sending WOL package to given mac addr

```

adds a new route /device/send-wol/:mac-addr to send the magic WOL package
to the specified mac-addr.
Method is POST and is protected.

Useful for custom wake up scripts: example is sending HTTP request through iOS shortcut

Test plan:
calling the API with curl
```
$ curl -X POST   http://<jetkvm-ip>/device/send-wol/xx:xx:xx:xx:xx:xx
WOL sent to xx:xx:xx:xx:xx:xx
```

and observing the magic packet on my laptop/PC:
```
$ ncat -u -l 9 -k | xxd
00000000: ffff ffff ffff d050 9978 a620 d050 9978  .......P.x. .P.x
00000010: a620 d050 9978 a620 d050 9978 a620 d050  . .P.x. .P.x. .P
00000020: 9978 a620 d050 9978 a620 d050 9978 a620  .x. .P.x. .P.x.
00000030: d050 9978 a620 d050 9978 a620 d050 9978  .P.x. .P.x. .P.x
00000040: a620 d050 9978 a620 d050 9978 a620 d050  . .P.x. .P.x. .P
00000050: 9978 a620 d050 9978 a620 d050 9978 a620  .x. .P.x. .P.x.
```

calling the api with invalid mac addr returns HTTP 400 error
```
$ curl -X POST -v http://<jetkvm-ip>/device/send-wol/abcd
...
* Request completely sent off
< HTTP/1.1 400 Bad Request
...
...
Invalid mac address provided

* Resolve golint complaint

---------

Co-authored-by: Marc Brooks <IDisposable@gmail.com>
2025-11-06 21:39:22 +01:00
Adam Shiervani 5f15d8b2f6
refactor: More robust handling of jsonrpc calls (#915)
Co-authored-by: Marc Brooks <IDisposable@gmail.com>
2025-11-06 11:12:19 +01:00
Aveline 28919bf37c
fix: await sleep needs to be called inside async function (#946) 2025-11-05 13:25:13 -06:00
8 changed files with 205 additions and 113 deletions

View File

@ -30,15 +30,17 @@ type LLDP struct {
advertiseOptions *AdvertiseOptions advertiseOptions *AdvertiseOptions
onChange func(neighbors []Neighbor) onChange func(neighbors []Neighbor)
neighbors *ttlcache.Cache[string, Neighbor] neighbors *ttlcache.Cache[neighborCacheKey, Neighbor]
// State tracking // State tracking
rxRunning bool
txRunning bool txRunning bool
txCtx context.Context txCtx context.Context
txCancel context.CancelFunc txCancel context.CancelFunc
rxCtx context.Context
rxCancel context.CancelFunc rxRunning bool
rxWaitGroup *sync.WaitGroup
rxCtx context.Context
rxCancel context.CancelFunc
} }
type AdvertiseOptions struct { type AdvertiseOptions struct {
@ -72,8 +74,9 @@ func NewLLDP(opts *Options) *LLDP {
advertiseOptions: opts.AdvertiseOptions, advertiseOptions: opts.AdvertiseOptions,
enableRx: opts.EnableRx, enableRx: opts.EnableRx,
enableTx: opts.EnableTx, enableTx: opts.EnableTx,
rxWaitGroup: &sync.WaitGroup{},
l: opts.Logger, l: opts.Logger,
neighbors: ttlcache.New(ttlcache.WithTTL[string, Neighbor](1 * time.Hour)), neighbors: ttlcache.New(ttlcache.WithTTL[neighborCacheKey, Neighbor](1 * time.Hour)),
onChange: opts.OnChange, onChange: opts.OnChange,
} }
} }

View File

@ -1,7 +1,6 @@
package lldp package lldp
import ( import (
"fmt"
"time" "time"
) )
@ -27,22 +26,28 @@ type Neighbor struct {
Values map[string]string `json:"values"` Values map[string]string `json:"values"`
} }
func (n *Neighbor) cacheKey() string { type neighborCacheKey struct {
return fmt.Sprintf("%s-%s", n.Mac, n.Source) mac string
source string
}
func (n *Neighbor) cacheKey() neighborCacheKey {
return neighborCacheKey{mac: n.Mac, source: n.Source}
} }
func (l *LLDP) addNeighbor(neighbor *Neighbor, ttl time.Duration) { func (l *LLDP) addNeighbor(neighbor *Neighbor, ttl time.Duration) {
logger := l.l.With(). logger := l.l.With().
Str("source", neighbor.Source).
Str("mac", neighbor.Mac). Str("mac", neighbor.Mac).
Interface("neighbor", neighbor). Interface("neighbor", neighbor).
Logger() Logger()
key := neighbor.cacheKey() key := neighbor.cacheKey()
current_neigh := l.neighbors.Get(key) currentNeighbor := l.neighbors.Get(key)
if current_neigh != nil { if currentNeighbor != nil {
current_source := current_neigh.Value().Source currentSource := currentNeighbor.Value().Source
if current_source == "lldp" && neighbor.Source != "lldp" { if currentSource == "lldp" && neighbor.Source != "lldp" {
logger.Info().Msg("skip updating neighbor, as LLDP has higher priority") logger.Info().Msg("skip updating neighbor, as LLDP has higher priority")
return return
} }
@ -56,6 +61,7 @@ func (l *LLDP) addNeighbor(neighbor *Neighbor, ttl time.Duration) {
func (l *LLDP) deleteNeighbor(neighbor *Neighbor) { func (l *LLDP) deleteNeighbor(neighbor *Neighbor) {
logger := l.l.With(). logger := l.l.With().
Str("source", neighbor.Source).
Str("mac", neighbor.Mac). Str("mac", neighbor.Mac).
Logger() Logger()

View File

@ -87,51 +87,47 @@ func (l *LLDP) setUpCapture() error {
return nil return nil
} }
func (l *LLDP) doCapture(logger *zerolog.Logger, rxCtx context.Context) { func (l *LLDP) doCapture(logger *zerolog.Logger) {
defer func() { l.rxWaitGroup.Add(1)
l.mu.Lock() defer l.rxWaitGroup.Done()
l.rxRunning = false
l.mu.Unlock()
}()
// TODO: use a channel to handle the packets // TODO: use a channel to handle the packets
// PacketSource.Packets() is not reliable and can cause panics and the upstream hasn't fixed it yet // PacketSource.Packets() is not reliable and can cause panics and the upstream hasn't fixed it yet
for rxCtx.Err() == nil { for {
if l.pktSourceRx == nil || l.tPacketRx == nil { // check if the context is done before blocking call
logger.Error().Msg("packet source or TPacketRx not initialized") select {
break case <-l.rxCtx.Done():
logger.Info().Msg("RX context cancelled")
return
default:
} }
logger.Trace().Msg("waiting for next packet")
packet, err := l.pktSourceRx.NextPacket() packet, err := l.pktSourceRx.NextPacket()
if err == nil { logger.Trace().Interface("packet", packet).Err(err).Msg("got next packet")
if handleErr := l.handlePacket(packet, logger); handleErr != nil {
logger.Error(). if err != nil {
Err(handleErr). logger.Error().
Msg("error handling packet") Err(err).
Msg("error getting next packet")
// Immediately break for known unrecoverable errors
if err == io.EOF || err == io.ErrUnexpectedEOF ||
err == io.ErrNoProgress || err == io.ErrClosedPipe || err == io.ErrShortBuffer ||
err == syscall.EBADF ||
strings.Contains(err.Error(), "use of closed file") {
return
} }
continue continue
} }
// Immediately retry for temporary network errors and EAGAIN if err := l.handlePacket(packet, logger); err != nil {
// temporary has been deprecated and most cases are timeouts logger.Error().
if nerr, ok := err.(net.Error); ok && nerr.Timeout() { Err(err).
Msg("error handling packet")
continue continue
} }
if err == syscall.EAGAIN {
continue
}
// Immediately break for known unrecoverable errors
if err == io.EOF || err == io.ErrUnexpectedEOF ||
err == io.ErrNoProgress || err == io.ErrClosedPipe || err == io.ErrShortBuffer ||
err == syscall.EBADF ||
strings.Contains(err.Error(), "use of closed file") {
break
}
logger.Error().
Err(err).
Msg("error receiving LLDP packet")
} }
} }
@ -158,9 +154,7 @@ func (l *LLDP) startCapture() error {
l.rxCtx, l.rxCancel = context.WithCancel(context.Background()) l.rxCtx, l.rxCancel = context.WithCancel(context.Background())
l.rxRunning = true l.rxRunning = true
// Capture context in closure go l.doCapture(&logger)
rxCtx := l.rxCtx
go l.doCapture(&logger, rxCtx)
return nil return nil
} }
@ -369,8 +363,24 @@ func (l *LLDP) stopCapture() error {
logger.Info().Msg("cancelled RX context, waiting for goroutine to finish") logger.Info().Msg("cancelled RX context, waiting for goroutine to finish")
} }
// Wait a bit for goroutine to finish // stop the TPacketRx
time.Sleep(500 * time.Millisecond) go func() {
if l.tPacketRx == nil {
return
}
// write an empty packet to the TPacketRx to interrupt the blocking read
// it's a shitty workaround until https://github.com/google/gopacket/pull/777 is merged,
// or we have a better solution, see https://github.com/google/gopacket/issues/1064
l.tPacketRx.WritePacketData([]byte{})
}()
// wait for the goroutine to finish
start := time.Now()
l.rxWaitGroup.Wait()
logger.Info().Dur("duration", time.Since(start)).Msg("RX goroutine finished")
l.rxRunning = false
if l.tPacketRx != nil { if l.tPacketRx != nil {
logger.Info().Msg("closing TPacketRx") logger.Info().Msg("closing TPacketRx")

View File

@ -60,10 +60,10 @@ var (
func toLLDPCapabilitiesBytes(capabilities []string) uint16 { func toLLDPCapabilitiesBytes(capabilities []string) uint16 {
r := uint16(0) r := uint16(0)
for _, capability := range capabilities { for _, capability := range capabilities {
if _, ok := capabilityMap[capability]; !ok { mask, ok := capabilityMap[capability]
continue if ok {
r |= mask
} }
r |= capabilityMap[capability]
} }
return r return r
} }

View File

@ -116,7 +116,7 @@ export interface RTCState {
peerConnection: RTCPeerConnection | null; peerConnection: RTCPeerConnection | null;
setPeerConnection: (pc: RTCState["peerConnection"]) => void; setPeerConnection: (pc: RTCState["peerConnection"]) => void;
setRpcDataChannel: (channel: RTCDataChannel) => void; setRpcDataChannel: (channel: RTCDataChannel | null) => void;
rpcDataChannel: RTCDataChannel | null; rpcDataChannel: RTCDataChannel | null;
hidRpcDisabled: boolean; hidRpcDisabled: boolean;
@ -178,41 +178,42 @@ export const useRTCStore = create<RTCState>(set => ({
setPeerConnection: (pc: RTCState["peerConnection"]) => set({ peerConnection: pc }), setPeerConnection: (pc: RTCState["peerConnection"]) => set({ peerConnection: pc }),
rpcDataChannel: null, rpcDataChannel: null,
setRpcDataChannel: (channel: RTCDataChannel) => set({ rpcDataChannel: channel }), setRpcDataChannel: channel => set({ rpcDataChannel: channel }),
hidRpcDisabled: false, hidRpcDisabled: false,
setHidRpcDisabled: (disabled: boolean) => set({ hidRpcDisabled: disabled }), setHidRpcDisabled: disabled => set({ hidRpcDisabled: disabled }),
rpcHidProtocolVersion: null, rpcHidProtocolVersion: null,
setRpcHidProtocolVersion: (version: number | null) => set({ rpcHidProtocolVersion: version }), setRpcHidProtocolVersion: version => set({ rpcHidProtocolVersion: version }),
rpcHidChannel: null, rpcHidChannel: null,
setRpcHidChannel: (channel: RTCDataChannel) => set({ rpcHidChannel: channel }), setRpcHidChannel: channel => set({ rpcHidChannel: channel }),
rpcHidUnreliableChannel: null, rpcHidUnreliableChannel: null,
setRpcHidUnreliableChannel: (channel: RTCDataChannel) => set({ rpcHidUnreliableChannel: channel }), setRpcHidUnreliableChannel: channel => set({ rpcHidUnreliableChannel: channel }),
rpcHidUnreliableNonOrderedChannel: null, rpcHidUnreliableNonOrderedChannel: null,
setRpcHidUnreliableNonOrderedChannel: (channel: RTCDataChannel) => set({ rpcHidUnreliableNonOrderedChannel: channel }), setRpcHidUnreliableNonOrderedChannel: channel =>
set({ rpcHidUnreliableNonOrderedChannel: channel }),
transceiver: null, transceiver: null,
setTransceiver: (transceiver: RTCRtpTransceiver) => set({ transceiver }), setTransceiver: transceiver => set({ transceiver }),
peerConnectionState: null, peerConnectionState: null,
setPeerConnectionState: (state: RTCPeerConnectionState) => set({ peerConnectionState: state }), setPeerConnectionState: state => set({ peerConnectionState: state }),
mediaStream: null, mediaStream: null,
setMediaStream: (stream: MediaStream) => set({ mediaStream: stream }), setMediaStream: stream => set({ mediaStream: stream }),
videoStreamStats: null, videoStreamStats: null,
appendVideoStreamStats: (stats: RTCInboundRtpStreamStats) => set({ videoStreamStats: stats }), appendVideoStreamStats: stats => set({ videoStreamStats: stats }),
videoStreamStatsHistory: new Map(), videoStreamStatsHistory: new Map(),
isTurnServerInUse: false, isTurnServerInUse: false,
setTurnServerInUse: (inUse: boolean) => set({ isTurnServerInUse: inUse }), setTurnServerInUse: inUse => set({ isTurnServerInUse: inUse }),
inboundRtpStats: new Map(), inboundRtpStats: new Map(),
appendInboundRtpStats: (stats: RTCInboundRtpStreamStats) => { appendInboundRtpStats: stats => {
set(prevState => ({ set(prevState => ({
inboundRtpStats: appendStatToMap(stats, prevState.inboundRtpStats), inboundRtpStats: appendStatToMap(stats, prevState.inboundRtpStats),
})); }));
@ -220,7 +221,7 @@ export const useRTCStore = create<RTCState>(set => ({
clearInboundRtpStats: () => set({ inboundRtpStats: new Map() }), clearInboundRtpStats: () => set({ inboundRtpStats: new Map() }),
candidatePairStats: new Map(), candidatePairStats: new Map(),
appendCandidatePairStats: (stats: RTCIceCandidatePairStats) => { appendCandidatePairStats: stats => {
set(prevState => ({ set(prevState => ({
candidatePairStats: appendStatToMap(stats, prevState.candidatePairStats), candidatePairStats: appendStatToMap(stats, prevState.candidatePairStats),
})); }));
@ -228,21 +229,21 @@ export const useRTCStore = create<RTCState>(set => ({
clearCandidatePairStats: () => set({ candidatePairStats: new Map() }), clearCandidatePairStats: () => set({ candidatePairStats: new Map() }),
localCandidateStats: new Map(), localCandidateStats: new Map(),
appendLocalCandidateStats: (stats: RTCIceCandidateStats) => { appendLocalCandidateStats: stats => {
set(prevState => ({ set(prevState => ({
localCandidateStats: appendStatToMap(stats, prevState.localCandidateStats), localCandidateStats: appendStatToMap(stats, prevState.localCandidateStats),
})); }));
}, },
remoteCandidateStats: new Map(), remoteCandidateStats: new Map(),
appendRemoteCandidateStats: (stats: RTCIceCandidateStats) => { appendRemoteCandidateStats: stats => {
set(prevState => ({ set(prevState => ({
remoteCandidateStats: appendStatToMap(stats, prevState.remoteCandidateStats), remoteCandidateStats: appendStatToMap(stats, prevState.remoteCandidateStats),
})); }));
}, },
diskDataChannelStats: new Map(), diskDataChannelStats: new Map(),
appendDiskDataChannelStats: (stats: RTCDataChannelStats) => { appendDiskDataChannelStats: stats => {
set(prevState => ({ set(prevState => ({
diskDataChannelStats: appendStatToMap(stats, prevState.diskDataChannelStats), diskDataChannelStats: appendStatToMap(stats, prevState.diskDataChannelStats),
})); }));
@ -250,7 +251,7 @@ export const useRTCStore = create<RTCState>(set => ({
// Add these new properties to the store implementation // Add these new properties to the store implementation
terminalChannel: null, terminalChannel: null,
setTerminalChannel: (channel: RTCDataChannel) => set({ terminalChannel: channel }), setTerminalChannel: channel => set({ terminalChannel: channel }),
})); }));
export interface MouseMove { export interface MouseMove {
@ -270,12 +271,20 @@ export interface MouseState {
export const useMouseStore = create<MouseState>(set => ({ export const useMouseStore = create<MouseState>(set => ({
mouseX: 0, mouseX: 0,
mouseY: 0, mouseY: 0,
setMouseMove: (move?: MouseMove) => set({ mouseMove: move }), setMouseMove: move => set({ mouseMove: move }),
setMousePosition: (x: number, y: number) => set({ mouseX: x, mouseY: y }), setMousePosition: (x, y) => set({ mouseX: x, mouseY: y }),
})); }));
export type HdmiStates = "ready" | "no_signal" | "no_lock" | "out_of_range" | "connecting"; export type HdmiStates =
export type HdmiErrorStates = Extract<VideoState["hdmiState"], "no_signal" | "no_lock" | "out_of_range"> | "ready"
| "no_signal"
| "no_lock"
| "out_of_range"
| "connecting";
export type HdmiErrorStates = Extract<
VideoState["hdmiState"],
"no_signal" | "no_lock" | "out_of_range"
>;
export interface HdmiState { export interface HdmiState {
ready: boolean; ready: boolean;
@ -290,10 +299,7 @@ export interface VideoState {
setClientSize: (width: number, height: number) => void; setClientSize: (width: number, height: number) => void;
setSize: (width: number, height: number) => void; setSize: (width: number, height: number) => void;
hdmiState: HdmiStates; hdmiState: HdmiStates;
setHdmiState: (state: { setHdmiState: (state: { ready: boolean; error?: HdmiErrorStates }) => void;
ready: boolean;
error?: HdmiErrorStates;
}) => void;
} }
export const useVideoStore = create<VideoState>(set => ({ export const useVideoStore = create<VideoState>(set => ({
@ -304,7 +310,8 @@ export const useVideoStore = create<VideoState>(set => ({
clientHeight: 0, clientHeight: 0,
// The video element's client size // The video element's client size
setClientSize: (clientWidth: number, clientHeight: number) => set({ clientWidth, clientHeight }), setClientSize: (clientWidth: number, clientHeight: number) =>
set({ clientWidth, clientHeight }),
// Resolution // Resolution
setSize: (width: number, height: number) => set({ width, height }), setSize: (width: number, height: number) => set({ width, height }),
@ -451,13 +458,15 @@ export interface MountMediaState {
export const useMountMediaStore = create<MountMediaState>(set => ({ export const useMountMediaStore = create<MountMediaState>(set => ({
remoteVirtualMediaState: null, remoteVirtualMediaState: null,
setRemoteVirtualMediaState: (state: MountMediaState["remoteVirtualMediaState"]) => set({ remoteVirtualMediaState: state }), setRemoteVirtualMediaState: (state: MountMediaState["remoteVirtualMediaState"]) =>
set({ remoteVirtualMediaState: state }),
modalView: "mode", modalView: "mode",
setModalView: (view: MountMediaState["modalView"]) => set({ modalView: view }), setModalView: (view: MountMediaState["modalView"]) => set({ modalView: view }),
isMountMediaDialogOpen: false, isMountMediaDialogOpen: false,
setIsMountMediaDialogOpen: (isOpen: MountMediaState["isMountMediaDialogOpen"]) => set({ isMountMediaDialogOpen: isOpen }), setIsMountMediaDialogOpen: (isOpen: MountMediaState["isMountMediaDialogOpen"]) =>
set({ isMountMediaDialogOpen: isOpen }),
uploadedFiles: [], uploadedFiles: [],
addUploadedFile: (file: { name: string; size: string; uploadedAt: string }) => addUploadedFile: (file: { name: string; size: string; uploadedAt: string }) =>
@ -474,7 +483,7 @@ export interface KeyboardLedState {
compose: boolean; compose: boolean;
kana: boolean; kana: boolean;
shift: boolean; // Optional, as not all keyboards have a shift LED shift: boolean; // Optional, as not all keyboards have a shift LED
}; }
export const hidKeyBufferSize = 6; export const hidKeyBufferSize = 6;
export const hidErrorRollOver = 0x01; export const hidErrorRollOver = 0x01;
@ -509,14 +518,23 @@ export interface HidState {
} }
export const useHidStore = create<HidState>(set => ({ export const useHidStore = create<HidState>(set => ({
keyboardLedState: { num_lock: false, caps_lock: false, scroll_lock: false, compose: false, kana: false, shift: false } as KeyboardLedState, keyboardLedState: {
setKeyboardLedState: (ledState: KeyboardLedState): void => set({ keyboardLedState: ledState }), num_lock: false,
caps_lock: false,
scroll_lock: false,
compose: false,
kana: false,
shift: false,
} as KeyboardLedState,
setKeyboardLedState: (ledState: KeyboardLedState): void =>
set({ keyboardLedState: ledState }),
keysDownState: { modifier: 0, keys: [0, 0, 0, 0, 0, 0] } as KeysDownState, keysDownState: { modifier: 0, keys: [0, 0, 0, 0, 0, 0] } as KeysDownState,
setKeysDownState: (state: KeysDownState): void => set({ keysDownState: state }), setKeysDownState: (state: KeysDownState): void => set({ keysDownState: state }),
isVirtualKeyboardEnabled: false, isVirtualKeyboardEnabled: false,
setVirtualKeyboardEnabled: (enabled: boolean): void => set({ isVirtualKeyboardEnabled: enabled }), setVirtualKeyboardEnabled: (enabled: boolean): void =>
set({ isVirtualKeyboardEnabled: enabled }),
isPasteInProgress: false, isPasteInProgress: false,
setPasteModeEnabled: (enabled: boolean): void => set({ isPasteInProgress: enabled }), setPasteModeEnabled: (enabled: boolean): void => set({ isPasteInProgress: enabled }),
@ -568,7 +586,7 @@ export interface OtaState {
systemUpdateProgress: number; systemUpdateProgress: number;
systemUpdatedAt: string | null; systemUpdatedAt: string | null;
}; }
export interface UpdateState { export interface UpdateState {
isUpdatePending: boolean; isUpdatePending: boolean;
@ -580,7 +598,7 @@ export interface UpdateState {
otaState: OtaState; otaState: OtaState;
setOtaState: (state: OtaState) => void; setOtaState: (state: OtaState) => void;
modalView: UpdateModalViews modalView: UpdateModalViews;
setModalView: (view: UpdateModalViews) => void; setModalView: (view: UpdateModalViews) => void;
updateErrorMessage: string | null; updateErrorMessage: string | null;
@ -620,12 +638,11 @@ export const useUpdateStore = create<UpdateState>(set => ({
setModalView: (view: UpdateModalViews) => set({ modalView: view }), setModalView: (view: UpdateModalViews) => set({ modalView: view }),
updateErrorMessage: null, updateErrorMessage: null,
setUpdateErrorMessage: (errorMessage: string) => set({ updateErrorMessage: errorMessage }), setUpdateErrorMessage: (errorMessage: string) =>
set({ updateErrorMessage: errorMessage }),
})); }));
export type UsbConfigModalViews = export type UsbConfigModalViews = "updateUsbConfig" | "updateUsbConfigSuccess";
| "updateUsbConfig"
| "updateUsbConfigSuccess";
export interface UsbConfigModalState { export interface UsbConfigModalState {
modalView: UsbConfigModalViews; modalView: UsbConfigModalViews;
@ -867,12 +884,12 @@ export interface MacrosState {
loadMacros: () => Promise<void>; loadMacros: () => Promise<void>;
saveMacros: (macros: KeySequence[]) => Promise<void>; saveMacros: (macros: KeySequence[]) => Promise<void>;
sendFn: sendFn:
| (( | ((
method: string, method: string,
params: unknown, params: unknown,
callback?: ((resp: JsonRpcResponse) => void) | undefined, callback?: ((resp: JsonRpcResponse) => void) | undefined,
) => void) ) => void)
| null; | null;
setSendFn: ( setSendFn: (
sendFn: ( sendFn: (
method: string, method: string,
@ -1012,5 +1029,5 @@ export const useMacrosStore = create<MacrosState>((set, get) => ({
} finally { } finally {
set({ loading: false }); set({ loading: false });
} }
} },
})); }));

View File

@ -559,8 +559,9 @@ export default function KvmIdRoute() {
clearCandidatePairStats(); clearCandidatePairStats();
setSidebarView(null); setSidebarView(null);
setPeerConnection(null); setPeerConnection(null);
setRpcDataChannel(null);
}; };
}, [clearCandidatePairStats, clearInboundRtpStats, setPeerConnection, setSidebarView]); }, [clearCandidatePairStats, clearInboundRtpStats, setPeerConnection, setSidebarView, setRpcDataChannel]);
// TURN server usage detection // TURN server usage detection
useEffect(() => { useEffect(() => {

View File

@ -24,17 +24,47 @@ export interface JsonRpcCallResponse<T = unknown> {
let rpcCallCounter = 0; let rpcCallCounter = 0;
// Helper: wait for RTC data channel to be ready // Helper: wait for RTC data channel to be ready
// This waits indefinitely for the channel to be ready, only aborting via the signal
// Throws if the channel instance changed while waiting (stale connection detected)
async function waitForRtcReady(signal: AbortSignal): Promise<RTCDataChannel> { async function waitForRtcReady(signal: AbortSignal): Promise<RTCDataChannel> {
const pollInterval = 100; const pollInterval = 100;
let lastSeenChannel: RTCDataChannel | null = null;
while (!signal.aborted) { while (!signal.aborted) {
const state = useRTCStore.getState(); const state = useRTCStore.getState();
if (state.rpcDataChannel?.readyState === "open") { const currentChannel = state.rpcDataChannel;
return state.rpcDataChannel;
// Channel instance changed (new connection replaced old one)
if (lastSeenChannel && currentChannel && lastSeenChannel !== currentChannel) {
console.debug("[waitForRtcReady] Channel instance changed, aborting wait");
throw new Error("RTC connection changed while waiting for readiness");
} }
// Channel was removed from store (connection closed)
if (lastSeenChannel && !currentChannel) {
console.debug("[waitForRtcReady] Channel was removed from store, aborting wait");
throw new Error("RTC connection was closed while waiting for readiness");
}
// No channel yet, keep waiting
if (!currentChannel) {
await sleep(pollInterval);
continue;
}
// Track this channel instance
lastSeenChannel = currentChannel;
// Channel is ready!
if (currentChannel.readyState === "open") {
return currentChannel;
}
await sleep(pollInterval); await sleep(pollInterval);
} }
// Signal was aborted for some reason
console.debug("[waitForRtcReady] Aborted via signal");
throw new Error("RTC readiness check aborted"); throw new Error("RTC readiness check aborted");
} }
@ -97,25 +127,26 @@ export async function callJsonRpc<T = unknown>(
const timeout = options.attemptTimeoutMs || 5000; const timeout = options.attemptTimeoutMs || 5000;
for (let attempt = 0; attempt < maxAttempts; attempt++) { for (let attempt = 0; attempt < maxAttempts; attempt++) {
const abortController = new AbortController();
const timeoutId = setTimeout(() => abortController.abort(), timeout);
// Exponential backoff for retries that starts at 500ms up to a maximum of 10 seconds // Exponential backoff for retries that starts at 500ms up to a maximum of 10 seconds
const backoffMs = Math.min(500 * Math.pow(2, attempt), 10000); const backoffMs = Math.min(500 * Math.pow(2, attempt), 10000);
let timeoutId: ReturnType<typeof setTimeout> | null = null;
try { try {
// Wait for RTC readiness // Wait for RTC readiness without timeout - this allows time for WebRTC to connect
const rpcDataChannel = await waitForRtcReady(abortController.signal); const readyAbortController = new AbortController();
const rpcDataChannel = await waitForRtcReady(readyAbortController.signal);
// Now apply timeout only to the actual RPC request/response
const rpcAbortController = new AbortController();
timeoutId = setTimeout(() => rpcAbortController.abort(), timeout);
// Send RPC request and wait for response // Send RPC request and wait for response
const response = await sendRpcRequest<T>( const response = await sendRpcRequest<T>(
rpcDataChannel, rpcDataChannel,
options, options,
abortController.signal, rpcAbortController.signal,
); );
clearTimeout(timeoutId);
// Retry on error if attempts remain // Retry on error if attempts remain
if (response.error && attempt < maxAttempts - 1) { if (response.error && attempt < maxAttempts - 1) {
await sleep(backoffMs); await sleep(backoffMs);
@ -124,8 +155,6 @@ export async function callJsonRpc<T = unknown>(
return response; return response;
} catch (error) { } catch (error) {
clearTimeout(timeoutId);
// Retry on timeout/error if attempts remain // Retry on timeout/error if attempts remain
if (attempt < maxAttempts - 1) { if (attempt < maxAttempts - 1) {
await sleep(backoffMs); await sleep(backoffMs);
@ -135,6 +164,10 @@ export async function callJsonRpc<T = unknown>(
throw error instanceof Error throw error instanceof Error
? error ? error
: new Error(`JSON-RPC call failed after ${timeout}ms`); : new Error(`JSON-RPC call failed after ${timeout}ms`);
} finally {
if (timeoutId !== null) {
clearTimeout(timeoutId);
}
} }
} }

24
web.go
View File

@ -8,6 +8,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"io/fs" "io/fs"
"net"
"net/http" "net/http"
"net/http/pprof" "net/http/pprof"
"path/filepath" "path/filepath"
@ -184,6 +185,8 @@ func setupRouter() *gin.Engine {
protected.PUT("/auth/password-local", handleUpdatePassword) protected.PUT("/auth/password-local", handleUpdatePassword)
protected.DELETE("/auth/local-password", handleDeletePassword) protected.DELETE("/auth/local-password", handleDeletePassword)
protected.POST("/storage/upload", handleUploadHttp) protected.POST("/storage/upload", handleUploadHttp)
protected.POST("/device/send-wol/:mac-addr", handleSendWOLMagicPacket)
} }
// Catch-all route for SPA // Catch-all route for SPA
@ -341,7 +344,6 @@ func handleWebRTCSignalWsMessages(
l.Trace().Msg("sending ping frame") l.Trace().Msg("sending ping frame")
err := wsCon.Ping(runCtx) err := wsCon.Ping(runCtx)
if err != nil { if err != nil {
l.Warn().Str("error", err.Error()).Msg("websocket ping error") l.Warn().Str("error", err.Error()).Msg("websocket ping error")
cancelRun() cancelRun()
@ -807,3 +809,23 @@ func handleSetup(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "Device setup completed successfully"}) c.JSON(http.StatusOK, gin.H{"message": "Device setup completed successfully"})
} }
func handleSendWOLMagicPacket(c *gin.Context) {
inputMacAddr := c.Param("mac-addr")
macAddr, err := net.ParseMAC(inputMacAddr)
if err != nil {
logger.Warn().Err(err).Str("sendWol", inputMacAddr).Msg("Invalid mac address provided")
c.String(http.StatusBadRequest, "Invalid mac address provided")
return
}
macAddrString := macAddr.String()
err = rpcSendWOLMagicPacket(macAddrString)
if err != nil {
logger.Warn().Err(err).Str("sendWOL", macAddrString).Msg("Failed to send WOL magic packet")
c.String(http.StatusInternalServerError, "Failed to send WOL to %s: %v", macAddrString, err)
return
}
c.String(http.StatusOK, "WOL sent to %s ", macAddr)
}