Compare commits

...

289 Commits

Author SHA1 Message Date
Alex b6693c3c97
Merge a81544070b into 74e64f69a7 2025-10-16 23:33:59 +02:00
Alex P a81544070b Merge branch 'dev' into feat/audio-support
This merge integrates the latest dev branch changes while preserving all
audio support functionality. Key changes include:

- Network stack refactoring: migrated from internal/network to pkg/nmlite
- New NetworkManager architecture with jetdhcpc client
- Function-based config pattern to avoid shared pointer bugs
- Updated UI components for network settings
- GitHub workflow and PR templates

- config.go: Retained AudioOutputSource field in Config struct
- config.go: Kept Audio: true in defaultUsbDevices
- config.go: Set AudioOutputSource: "usb" as default
- config.go: Adopted dev's types.NetworkConfig import path
- config.go: Adopted dev's function-based getDefaultConfig() pattern

1. config.go Config struct: Combined audio fields with dev's network refactoring
2. config.go default config: Adopted dev's function-based pattern while preserving audio defaults
3. ui/src/utils/jsonrpc.ts: Fixed unused error variable in catch block

All linters pass with 0 errors and 0 warnings.
2025-10-17 00:33:32 +03:00
Adam Shiervani 74e64f69a7
Add stale issues and PRs workflow (#890) 2025-10-16 16:21:37 +02:00
Adam Shiervani eb68c0ea5f
chore: add PR templates (feature, bug fix) (#889) 2025-10-16 15:51:56 +02:00
Aveline c775979ccb
feat: refactoring network stack (#878)
Co-authored-by: Adam Shiervani <adam.shiervani@gmail.com>
2025-10-15 18:32:58 +02:00
Alex 0951f150bf
Merge branch 'dev' into feat/audio-support 2025-10-09 22:42:16 +03:00
Alex 71553bcef7
Merge branch 'dev' into feat/audio-support 2025-10-07 14:30:07 +03:00
Alex P 532e83e514 Cleanup: reduce drift from dev 2025-10-07 14:00:49 +03:00
Alex P bb5634be58 refactor: Remove subprocess audio infrastructure, use CGO-only
Remove all subprocess-based audio code to simplify the audio system and
reduce complexity. Audio now uses CGO in-process mode exclusively.

Changes:
- Remove subprocess mode: Deleted Supervisor, IPCSource, embed.go
- Remove audio mode selection from UI (Settings → Audio)
- Remove audio mode from backend config (AudioMode field)
- Remove JSON-RPC handlers: getAudioMode/setAudioMode
- Remove Makefile targets: build_audio_output/input/binaries
- Remove standalone C binaries: jetkvm_audio_{input,output}.c
- Remove IPC protocol implementation: ipc_protocol.{c,h}
- Remove unused IPC functions from audio_common.{c,h}
- Simplify audio.go: startAudio() instead of startAudioSubprocesses()
- Update all function calls and comments to remove subprocess references
- Add constants to cgo_source.go (ipcMaxFrameSize, ipcMsgTypeOpus)
- Keep update_opus_encoder_params() for potential future runtime config

Benefits:
- Simpler codebase: -1,734 lines of code
- Better performance: No IPC overhead on embedded hardware
- Easier maintenance: Single audio implementation
- Smaller binary: No embedded audio subprocess binaries

The audio system now works exclusively via CGO direct C function calls,
with ALSA device selection (HDMI vs USB) still configurable via settings.
2025-10-07 13:34:03 +03:00
Alex P 1bca60ae6b Merge branch 'feat/dual-audio-mode' into feat/audio-support 2025-10-07 11:26:09 +03:00
Alex P dd09cbcdc3 Cleanup: PR Comments 2025-10-07 11:14:16 +03:00
Alex P 035ba4c35f [WIP] Updates: support in-process mode 2025-10-07 11:00:59 +03:00
Alex P 00b8da45d9 [WIP] Updates: support in-process mode 2025-10-07 10:17:06 +03:00
Alex P 9abb2aa026 [WIP] Updates: support in-process mode 2025-10-07 10:13:11 +03:00
Alex P 0f16e0b11a [WIP] Updates: support in-process mode 2025-10-07 09:54:48 +03:00
Alex P 9d7fd878a1 [WIP] Updates: support in-process mode 2025-10-07 09:51:08 +03:00
Alex P 58fad71112 [WIP] Updates: support in-process mode 2025-10-07 08:49:49 +03:00
Alex P 9c72db913b feat: Optimize audio quality and default to USB audio
Audio quality improvements:
- Enable constrained VBR to prevent bitrate starvation at low volumes
- Increase Opus complexity from 2 to 5 for better quality
- Enable DTX for bandwidth optimization
- Enable FEC (Forward Error Correction)
- Add DTX and FEC signaling in SDP (usedtx=1;useinbandfec=1)

Default configuration changes:
- Change default audio output source from HDMI to USB
- Enable USB Audio device by default
- USB audio works on current stable image (HDMI requires newer device tree)

These changes fix crackling issues at low volumes and provide better
overall audio quality for both USB and HDMI audio paths.
2025-10-07 01:38:42 +03:00
Alex P 19fe908426 refactor: Simplify audio implementation
Remove dynamic gain code and rely on Opus encoder quality improvements:
- Increase Opus complexity from 2 to 5 for better quality
- Change bandwidth from FULLBAND (20kHz) to SUPERWIDEBAND (16kHz) for better quality at 128kbps
- Disable FEC to allocate all bits to audio quality
- Increase ALSA buffer from 40ms to 80ms for stability

The dynamic gain code was adding complexity without solving the underlying
issue: TC358743 HDMI chip captures digital audio at whatever volume the
source outputs. Users should adjust volume at the source or in their browser.
2025-10-07 00:25:45 +03:00
Alex P 04dd37f58f fix: Add noise gate to prevent amplifying silence artifacts
Add noise gate threshold at peak > 256 (-42dB) to prevent dynamic gain
from amplifying quantization noise and hardware noise floor. This fixes
crackling, buzzing, and static-like noise when HDMI audio is at very
low volume or during silence.

Without the gate, signals below -42dB (peak < 256) would get 8x gain
applied, amplifying noise floor to audible levels. Now these signals
pass through unmodified, eliminating the artifacts.
2025-10-06 22:23:23 +03:00
Alex P 5158c89103 fix: Replace 'any' with proper React.ChangeEvent type in AudioPopover
Replace 'as any' type assertions with proper React.ChangeEvent<HTMLInputElement>
type for synthetic events passed to audio toggle handlers.
2025-10-06 22:13:21 +03:00
Alex P 141e2f9099 fix: Address linting errors in audio code
- Check SetReadDeadline error in IPC client
- Explicitly ignore Kill() error (process may be dead)
- Remove init() function and rely on explicit ExtractEmbeddedBinaries() call
2025-10-06 22:12:20 +03:00
Alex P 7872ddc8fc Refactor: Simplify / rewrite Audio 2025-10-06 21:59:44 +03:00
Alex P 67447e4e5e [WIP] Updates: reduce PR complexity 2025-10-02 00:08:42 +03:00
Alex P c8e220334d [WIP] Updates: reduce PR complexity 2025-10-01 22:58:37 +03:00
Alex P 178c7486cc [WIP] Updates: reduce PR complexity 2025-10-01 22:54:37 +03:00
Alex P 257993ec20 [WIP] Updates: reduce PR complexity 2025-10-01 22:21:04 +03:00
Alex P 56c02f1067 [WIP] Updates: reduce PR complexity 2025-10-01 22:07:45 +03:00
Alex P 4c12783107 [WIP] Updates: reduce PR complexity 2025-10-01 21:20:30 +03:00
Alex P 6ccd9fdf19 [WIP] Updates: use native C binaries for audio 2025-10-01 20:13:13 +03:00
Alex P 74f73d9496 Merge branch 'dev' into feat/audio-support 2025-10-01 13:09:03 +00:00
Alex P ef5c25efcf Updates: integrate all dev branch changes 2025-10-01 15:50:39 +03:00
Alex P bdcac6a468 [WIP] Updates: update build flows to work with the CGO jetkvm_native 2025-10-01 10:04:42 +03:00
Alex P 160a925f40 Merge branch 'dev' into feat/audio-support 2025-09-30 20:08:33 +00:00
Alex P 70ef7193fd Cleanup: remove silence detection 2025-09-30 14:32:36 +00:00
Alex P 35b5dbd034 [WIP] Cleanup: cleanup audio code after HDMI switch 2025-09-30 13:32:56 +00:00
Alex P 7dc57bcdf3 [WIP] Fix: crackling sound when seeking forward after migrating to HDMI Audio 2025-09-30 13:14:15 +00:00
Alex P 05b347fe74 [WIP] Fix: crackling sound when seeking forwars 2025-09-30 13:11:16 +00:00
Alex P e989cad633 [WIP] Fix: crackling sound when seeking forwars 2025-09-30 12:58:49 +00:00
Alex P fc38830af1 [WIP] Updates: simplify audio system 2025-09-30 12:36:41 +00:00
Alex P 76b80da157 Updates: adjust gain to avoid audio artifacts 2025-09-30 12:05:03 +00:00
Alex P 01719e01dd [WIP] Updates: simplify audio system 2025-09-30 11:41:17 +00:00
Alex P 753c613708 [WIP] Updates: simplify audio system 2025-09-30 11:03:34 +00:00
Alex P f6dd605ea6 [WIP] Updates: simplify audio system 2025-09-30 09:36:19 +00:00
Alex P 680607e82e [WIP] Updates: simplify audio system 2025-09-30 09:08:55 +00:00
Alex P 6c6a1def28 [WIP] Updates: simplify audio system 2025-09-30 09:04:07 +00:00
Alex P dcce0fefb7 Merge branch 'dev' into feat/audio-support 2025-09-26 12:53:23 +00:00
Alex P 630571da25 [WIP] Improvements, Bugfixes: Improve audio experience when running in HTTP mode 2025-09-21 22:05:19 +00:00
Alex P d311dee4c6 [WIP] Improvements, Bugfixes: Improve audio experience when running in HTTP mode 2025-09-21 21:20:05 +00:00
Alex P 7060f9e8d6 [WIP] Improvements, Bugfixes: Improve audio experience when running in HTTP mode 2025-09-21 21:11:30 +00:00
Alex P b63af01d73 [WIP] Improvements, Bugfixes: Improve audio experience when running in HTTP mode 2025-09-21 21:07:41 +00:00
Alex P 4dec696c4a [WIP] Improvements, Bugfixes: Improve audio experience when running in HTTP mode 2025-09-21 21:04:08 +00:00
Alex P 093f2bbe22 Improvement: improve audio input restauration mechanism 2025-09-21 17:41:12 +00:00
Alex P dec0b9d3db [WIP] Improvements: Improve audio resume mechanism after Hardware Settings deactivation & reactivation 2025-09-21 18:17:43 +03:00
Alex P f2ad918dfd [WIP] Improvements: Improve audio resume mechanism after Hardware Settings deactivation & reactivation 2025-09-21 17:52:55 +03:00
Alex P a84f63c0c4 Fix: make sure audio output enable / disable doesn't need a refresh in order for audio to become audible again 2025-09-20 21:07:41 +00:00
Alex P 439f57c3c8 [WIP] CLeanup: Remove unused or redundant code or comments 2025-09-20 23:38:03 +03:00
Alex P b6d093f399 [WIP] Cleanup: PR cleanup - restore commented logs 2025-09-20 20:12:01 +00:00
Alex P cd87aa499c [WIP] Cleanup: PR Cleanup 2025-09-20 22:57:19 +03:00
Alex P 274854b198 [WIP] Cleanup: PR Cleanup 2025-09-20 01:38:16 +03:00
Alex P 6ee79b79c3 [WIP] Cleanup: PR Cleanup 2025-09-20 01:14:41 +03:00
Alex P 8b86124be1 [WIP] Cleanup: PR Cleanup 2025-09-20 00:57:57 +03:00
Alex P f2edfa66f0 [WIP] Cleanup: PR Cleanup 2025-09-20 00:54:14 +03:00
Alex P 432303e228 [WIP] Cleanup: PR Cleanup 2025-09-20 00:21:09 +03:00
Alex P 1dbc6c9d06 [WIP] Cleanup: PR cleanup 2025-09-19 23:53:40 +03:00
Alex P 3e24a3c186 Merge branch 'dev' into feat/audio-support 2025-09-19 12:41:46 +00:00
Alex P 17c3c4be9a PR Cleanup: reset .golangci.yml to its original state 2025-09-19 11:38:35 +00:00
Alex P 140a803ccf perf(audio): add ARM NEON SIMD optimizations for audio processing
Implement SIMD-optimized audio operations using ARM NEON for Cortex-A7 targets
Update Makefile and CI configuration to support NEON compilation flags
Add SIMD implementations for common audio operations including:
- Sample clearing and interleaving
- Volume scaling and format conversion
- Channel manipulation and balance adjustment
- Endianness swapping and prefetching
2025-09-16 18:18:19 +00:00
Alex P eca3c52513 PR Review Optimization: As recommended, use ternary operators instead of if/else for better readability 2025-09-16 16:17:56 +03:00
Alex P 55bcfb5a22 Consistency: keep if block multi-line 2025-09-16 16:08:16 +03:00
Alex P 0027001390 Cleanup: removed redundant code 2025-09-16 16:03:20 +03:00
Alex P caa0a60ebb Cleanup: removed redundant code 2025-09-16 16:00:55 +03:00
Alex P a5fb3bf30c Fix: remove misplaced const 2025-09-16 15:52:53 +03:00
Alex P 26e71806cb Cleanup, Optimizations: Small aaudio optimizations 2025-09-16 15:46:55 +03:00
Alex P 2f7bf55f22 Cleanup, Optimizations: Small aaudio optimizations 2025-09-16 15:45:03 +03:00
Alex P 8a3f1b6c32 Cleanup, Optimizations: Small aaudio optimizations 2025-09-16 15:37:23 +03:00
Alex P 7ffb9e1d59 Cleanup: removed redundant code, comments, etc. 2025-09-16 15:31:10 +03:00
Alex P 647eca4292 Cleanup: removed redundant code, comments, etc. 2025-09-16 15:23:16 +03:00
Alex P a8b58b5d34 [WIP] Cleanup: removed redundant code 2025-09-16 15:17:49 +03:00
Alex P b23cc50d6c [WIP] Cleanup: removed redundant code 2025-09-16 15:14:00 +03:00
Alex P 1f88dab95f [WIP] Maintainability Improvement: Add debug logging throughout the audio system for easy debugging and troubleshooting 2025-09-16 15:05:08 +03:00
Alex P 0944c886e5 [WIP] Maintainability Improvement: Add debug logging throughout the audio system for easy debugging and troubleshooting 2025-09-16 11:27:18 +03:00
Alex P 5e257b3144 [WIP] Add debug logging throughout the audio system 2025-09-16 11:26:48 +03:00
Alex P fb98c4edcb [WIP] Maintainability: Add debug / trace logs to make it easy to debug audio input issues 2025-09-16 11:11:18 +03:00
Alex P e894470ca8 [WIP] Cleanup: function naming 2025-09-16 07:33:34 +00:00
Alex P 996016b0da [WIP] Cleanup: remove unnecessary complexity 2025-09-15 23:00:03 +00:00
Alex P 7ab4a0e41d [WIP] Simplification: PR Simplification 2025-09-16 00:44:26 +03:00
Alex P ebb79600b0 Fix: pcm_snd_wait won't work when device is busy 2025-09-16 00:32:19 +03:00
Alex P b040b8feaf [WIP] Optimizations: Optimize audio system 2025-09-15 21:22:02 +00:00
Alex P ca38ebee0c [WIP] Fix: add commented code back 2025-09-15 12:04:24 +00:00
Alex P cca1fe720d [WIP] Cleanpup: Remove audio input quality settings to reduce complexity 2025-09-15 12:02:47 +00:00
Alex P 9d6bd997d9 [WIP] Cleanpup: Remove audio input quality settings to reduce complexity 2025-09-15 11:59:21 +00:00
Alex P e29694921b Fix: indentation 2025-09-15 14:29:02 +03:00
Alex P c8630e7c7f [WIP] Cleanpup: Remove audio input quality settings to reduce complexity 2025-09-15 08:40:28 +00:00
Alex P b6858ab155 [WIP] Cleanup / Refinement: PR Review 2025-09-14 21:55:31 +00:00
Alex P 0eaad6ba16 Merge branch 'dev' into feat/audio-support 2025-09-13 06:35:25 +00:00
Alex P 557aa5891a Cleanup: Simplify Makefile by exporting all needed env vars at the top 2025-09-12 09:47:11 +00:00
Alex P 49d62f8eb0 Cleanup: only set PKG_CONFIG_PATH once 2025-09-12 09:22:14 +00:00
Alex P 9e4392127e Fix: add accidentally removed test file back, devcontainer build process on clean devcontainer 2025-09-12 09:10:31 +00:00
Alex P 15baf9323b Merge branch 'dev' into feat/audio-support 2025-09-12 01:18:46 +03:00
Alex P 0e76023c39 Improvement (Maintainability): Make all C code more manageable by moving it to its own dedicated file 2025-09-11 13:27:10 +03:00
Alex P 5da357ba01 [WIP] Cleanup: Remove hid optimization code, as it is out of scope 2025-09-09 23:31:58 +00:00
Alex P eab0261344 Cleanup: remove devLog with calculated param 2025-09-09 22:44:33 +00:00
Alex P e0b6e612c0 Updates: defer the mutex unlock 2025-09-09 22:03:35 +00:00
Alex P f48c3fe25a [WIP] Updates, Cleanup: use uint64 for non-negative values 2025-09-09 21:54:36 +00:00
Alex P d4c10aef87 Updates: use uint64 since we won't have negative numbers here 2025-09-09 21:37:08 +00:00
Alex P 2a81497d34 Improvements: input performance 2025-09-09 20:58:34 +00:00
Alex P 8cff7d600b pr-optimizations,perf(input): optimize JSON-RPC input handling with ultra-fast path
Add ultra-fast path for input methods that completely bypasses float64 conversions and reflection
Use direct JSON unmarshaling to target types for maximum
2025-09-09 18:56:54 +00:00
Alex P eca1e6a80d Cleanup: implement PR Review suggestions 2025-09-09 18:09:19 +00:00
Alex P 02acee0c75 Fix: Audio Output Enable / Disable 2025-09-09 10:39:47 +00:00
Alex P 0f2aa9abe4 feat(audio): improve socket handling and validation performance
- Add retry logic for socket file removal and listener creation
- Optimize message writing by combining header and data writes
- Move socket paths from temp dir to /var/run
- Refactor OPUS parameter lookup to use map for better readability
- Simplify validation functions for better performance in hotpaths
2025-09-09 10:16:53 +00:00
Alex P 5d4f4d8e10 UX Improvement: keep Mic state accross page refreshes 2025-09-09 09:43:39 +00:00
Alex P a5d1ef1225 refactor(audio): optimize performance and simplify code
- Replace mutex locks with atomic operations for counters
- Remove redundant logging calls to reduce overhead
- Simplify error handling and buffer validation
- Add exponential backoff for audio relay stability
- Streamline CGO audio operations for hotpath optimization
2025-09-09 09:12:05 +00:00
Alex P bda92b4a62 [Milestone] Fix: in-flight audio input quality updates 2025-09-09 08:56:24 +00:00
Alex P 3c6184d0e8 [Milestone] Improvement: In-flight audio output quality update 2025-09-09 07:44:37 +00:00
Alex P 2bc7e50391 [WIP] Cleanup, Refactor: Reduce PR complexity, common IPC layer 2025-09-09 07:08:32 +00:00
Alex P f71d18039b [WIP] Cleanup: reduce PR complexity 2025-09-09 06:59:55 +00:00
Alex P 00e5148eef [WIP] Cleanup: reduce PR complexity 2025-09-09 06:52:40 +00:00
Alex P 0ebfc762f7 [WIP] Cleanup: PR SImplification 2025-09-09 05:41:20 +00:00
Alex P 845eadec18 [WIP] Fix: Audio Latency issues: move audio to a dedicated media stream
For more details please see: https://groups.google.com/g/discuss-webrtc/c/ZvAHvkHsb0E
2025-09-09 00:23:15 +00:00
Alex P aa21b4b459 Updates: increase congestion treshold multiplier 2025-09-08 22:58:49 +00:00
Alex P 89e68f5cdb [WIP] Change playback latency spikes on Audio Output Quality changes 2025-09-08 22:55:19 +00:00
Alex P f873b50469 fix(audio): adjust congestion and CPU thresholds for single-core system
Update congestion threshold multiplier and CPU thresholds to better suit single-core ARM RV1106G3 processor characteristics. Adjust memory thresholds for systems with 200MB total memory.
2025-09-08 22:03:11 +00:00
Alex P 0893eb88ac feat(audio): improve reliability with graceful degradation and async updates
- Implement graceful degradation for congestion handling with configurable thresholds
- Refactor audio relay track updates to be async to prevent deadlocks
- Add timeout-based supervisor stop during quality changes
- Optimize buffer pool configuration and cleanup strategies
2025-09-08 21:47:39 +00:00
Alex P 8cf0b639af perf(audio): increase buffer sizes and timeouts for quality change bursts
Significantly increase message pool, channel buffer, and adaptive buffer sizes to better handle quality change bursts. Adjust timeouts and intervals for improved responsiveness.
2025-09-08 21:17:06 +00:00
Alex P 6f10010d71 refactor(audio): remove redundant config variable assignments
Replace repeated local config variable assignments with direct Config access
to reduce memory allocations and improve code maintainability
2025-09-08 21:04:07 +00:00
Alex P 1d1658db15 refactor(audio): replace GetConfig() calls with direct Config access
This change replaces all instances of GetConfig() function calls with direct access to the Config variable throughout the audio package. The modification improves performance by eliminating function call overhead and simplifies the codebase by removing unnecessary indirection.

The commit also includes minor optimizations in validation logic and connection handling, while maintaining all existing functionality. Error handling remains robust with appropriate fallbacks when config values are not available.

Additional improvements include:
- Enhanced connection health monitoring in UnifiedAudioClient
- Optimized validation functions using cached config values
- Reduced memory allocations in hot paths
- Improved error recovery during quality changes
2025-09-08 17:30:49 +00:00
Alex P 91f9dba4c6 feat(audio): improve audio quality handling and recovery mechanisms
- Add server stats reset and frame drop recovery functions
- Implement global audio server instance management
- Add WebRTC audio track replacement capability
- Improve audio relay initialization with retry logic
- Enhance quality change handling with adaptive buffer management
- Add global helper functions for audio quality control
2025-09-08 12:48:22 +00:00
Alex P 219c972e33 Merge branch 'dev' into feat/audio-support 2025-09-08 11:17:08 +00:00
Alex P df58e04846 feat(audio): implement zero-copy batch processing with reference counting
Add batch reference counting and zero-copy frame management for optimized audio processing. Includes:
- BatchReferenceManager for efficient reference counting
- ZeroCopyFrameSlice utilities for frame management
- BatchZeroCopyProcessor for high-performance batch operations
- Adaptive optimization interval based on stability metrics
- Improved memory management with zero-copy frames
2025-09-08 09:08:07 +00:00
Alex P 323d2587b7 refactor(audio): improve memory management with atomic operations and chunk allocation
- Replace mutex-protected refCount with atomic operations in ZeroCopyFramePool
- Implement chunk-based allocation in AudioBufferPool to reduce allocations
- Add proper reference counting with atomic operations in ZeroCopyAudioFrame
- Optimize buffer pool sizing based on buffer size
2025-09-08 08:25:42 +00:00
Alex P a6913bf33b perf(audio): make refCount operations atomic and optimize frame pooling
Replace mutex-protected refCount operations with atomic operations to improve performance in concurrent scenarios.
Simplify frame release logic and add hitCount metric for pool usage tracking.
2025-09-08 08:20:43 +00:00
Alex P 6890f17a54 refactor(audio): consolidate supervision logic into base implementation
Move common supervision loop logic to BaseSupervisor with configurable parameters
Simplify input/output supervisor implementations by using base template
Update function comments to be more concise
2025-09-08 05:53:06 +00:00
Alex P a2a87b46b8 refactor(audio): move channel and process management to base supervisor
Consolidate duplicate channel and process management code from input/output supervisors into BaseSupervisor
Add new methods for channel initialization and cleanup
Standardize process termination and monitoring behavior
2025-09-07 20:14:33 +00:00
Alex P 96a6a0f8f9 refactor(audio): improve process management and error handling
- Remove unused setRunning method from BaseSupervisor
- Refactor IPC input reader to use running flag and mutex
- Add atomic state management to InputSupervisor
- Implement proper channel cleanup and process termination
- Improve error handling and logging throughout
2025-09-07 19:35:14 +00:00
Alex P bfdbbdc557 [WIP] Simplification 2025-09-07 19:13:35 +00:00
Alex P e3b4bb2002 refactor(audio): replace mute functionality with start/stop for microphone
- Replace MuteMicrophone calls with StartMicrophone/StopMicrophone for clearer behavior
- Update microphone state broadcasting to reflect actual subprocess status
- Modify UI to use enable/disable terminology instead of mute/unmute
- Ensure microphone device changes properly restart the active microphone
2025-09-07 18:32:42 +00:00
Alex P 7d39a2741e [WIP] Improvements: improve Audio Input Activation / Deactivation process so it is faster 2025-09-07 16:17:06 +00:00
Alex P e27f1cfa59 fix(AudioControlPopover): swap mute button theme and labels for clarity & consistency 2025-09-06 20:37:18 +00:00
Alex P b267348084 feat(audio): add microphone stop endpoint and improve mute handling
- Implement new POST /microphone/stop endpoint
- Refactor mute handling to properly start/stop audio processes
- Add callback mechanism for audio relay to reconnect to current session
- Simplify UI microphone controls by combining mute/start-stop functionality
2025-09-06 20:17:29 +00:00
Alex P 5a0dce9984 Fix: Microphone & audio on page load, PID field parsing in audio input supervisor 2025-09-06 06:11:29 +00:00
Alex P d3e2b2dff2 fix(audio): correct mute state broadcast in audio control service
Use BroadcastAudioMuteChanged instead of BroadcastAudioDeviceChanged to properly reflect mute state changes
2025-09-05 22:10:14 +00:00
Alex P 947b4f9528 [WIP] Updates: Reduce PR complexity 2025-09-05 21:47:21 +00:00
Alex P 8a189ba1b9 [WIP] Updates / FIles reorg: reduce PR complexity 2025-09-05 21:34:23 +00:00
Alex P d3bbe1bf0a refactor(audio): standardize component logging names and cleanup
- Add component name constants for consistent logging across audio modules
- Remove redundant debug logs and outdated comments
- Delete obsolete naming standards documentation file
2025-09-05 21:12:17 +00:00
Alex P 158437352c Fix: quality not updating in the Audio Control Popover when changed 2025-09-05 17:36:43 +00:00
Alex P e45bec4a9c Fix: currently selected preset not coming from the API 2025-09-05 17:33:13 +00:00
Alex P 2c2f2d416b Refactoring: Move most audio business logic into the audio package 2025-09-05 17:22:14 +00:00
Alex P 0a38451c95 feat(audio): add audio handlers and refactor session management
- Extract audio-related handlers into separate file for better organization
- Simplify session creation logic by removing duplicate code paths
- Add new Prometheus metrics for connection monitoring
- Reduce websocket ping interval from 30s to 15s for better responsiveness
2025-09-05 16:30:09 +00:00
Alex P d9072673c0 Updates: merge 'dev' intu 'feat/audio-support' 2025-09-05 15:07:54 +00:00
Alex P 9cb976ab8d Merge branch 'dev' into feat/audio-support 2025-09-05 14:40:23 +00:00
Alex P fcd07b2b59 perf(audio): enhance buffer pool performance with adaptive caching
- Increase goroutine cache size from 4 to 8 buffers for better hit rates
- Add adaptive resize and cache warmup based on usage patterns
- Implement enhanced cleanup with size limits and better TTL management
- Optimize buffer clearing and preallocation strategies
2025-09-05 13:53:00 +00:00
Alex P 1a0c7a84bc refactor(audio): rename latency config fields for clarity
Update config field names to better reflect their specific usage contexts in adaptive buffer and optimizer components. This improves code maintainability by making the purpose of each latency target more explicit.
2025-09-05 12:27:35 +00:00
Alex P 463f34e40b Fix: audio output not working due to too agressive timeout 2025-09-05 12:04:38 +00:00
Alex P 4075057c2b [WIP] Cleanup: Reduce PR Complexity 2025-09-04 22:06:48 +00:00
Alex P c1cc8dd832 refactor(audio): remove audio input subprocess pre-warming feature
The pre-warming feature was removed to simplify the audio input supervisor implementation. This feature added complexity and was not providing significant latency improvements in practice.
2025-09-04 19:48:38 +00:00
Alex P c40459664f Audio Input resiliency. Make sure the IPC client always recovers 2025-09-04 10:51:07 +00:00
Alex P cdf6731639 [WIP] Performance Updates:
Add LSB depth parameter for improved bit allocation and disable MMAP access for compatibility.
Adjust buffer sizing logic to better handle constrained environments while maintaining stability.
2025-09-04 08:47:40 +00:00
Alex P 6f15fdf965 perf(build): update ARM optimization flags for better compatibility
Replace -mcpu with -mtune in compiler flags to improve binary compatibility
while maintaining performance for Cortex-A7 targets
2025-09-04 08:25:16 +00:00
Alex P b63404c26b [WIP] Cleanup: reduce PR complexity 2025-09-03 23:50:05 +00:00
Alex P b497444d6d [WIP] Cleanup: reduce PR complexity 2025-09-03 23:13:36 +00:00
Alex P 476a245598 [WIP] Cleanup: Reduce PR complexity 2025-09-03 23:01:08 +00:00
Alex P 5dc04321a1 [WIP] Cleanup: decrease PR complexity 2025-09-03 22:48:25 +00:00
Alex P a3702dadd9 Cleanup: reduce PR complexity 2025-09-03 22:35:52 +00:00
Alex P 2568660149 feat(metrics): add configurable metrics collection and performance optimizations
- Add config flags to enable/disable metrics collection, goroutine monitoring, and latency profiling
- Optimize batch processor with configurable queue sizes and thread pinning thresholds
- Skip metrics operations when disabled to reduce overhead
- Update default config with performance-related settings
2025-09-03 20:18:07 +00:00
Alex P ca365f1acd feat(audio): add subprocess pre-warming to reduce activation latency
implement batched metrics updates to reduce atomic operations
optimize thread locking for high-throughput scenarios only
2025-09-03 19:51:18 +00:00
Alex P 5c55da0787 perf(audio): optimize metrics collection and logging overhead
- Replace direct atomic updates with sampling to reduce contention
- Simplify metrics tracking by removing buffering and using direct updates
- Optimize logging by adding level checks and sampling
- Improve validation performance using cached config values
2025-09-03 19:41:20 +00:00
Alex P 260f62efc3 perf(audio): optimize audio processing paths and reduce overhead
- Replace CGO function variable aliases with direct function calls to eliminate indirection
- Simplify audio frame validation by using cached max size and removing error formatting
- Optimize buffer pool operations by removing metrics collection and streamlining cache access
- Improve batch audio processor by pre-calculating values and reducing config lookups
- Streamline IPC message processing with inline validation and reduced error logging
2025-09-03 17:51:08 +00:00
Alex P a741f05829 fix(audio): add proper locking for config cache updates
Add mutex locking around config cache expiration checks to prevent race conditions. The cache now properly checks initialization status before attempting updates.
2025-09-03 17:08:19 +00:00
Alex P a557987629 perf(audio): optimize hotpath by removing redundant checks and logging
- Skip logging in frame validation to reduce overhead
- Only update cache when expired to avoid unnecessary operations
- Remove duplicate config caching system and simplify buffer handling
- Optimize batch processing with pre-allocated buffers and conditional time tracking
2025-09-03 16:55:39 +00:00
Alex P 5353c1cab2 perf(audio): optimize opus decode-write with separate buffers
- Add PCM buffer pool and config for optimized decode-write operations
- Implement separate buffer handling in CGO audio processing
- Update batch processor to support both legacy and optimized paths
2025-09-03 16:28:25 +00:00
Alex P 370178e43b fix(audio): remove arm architecture restriction from cgo build tags 2025-09-03 15:49:07 +00:00
Alex P 9f1dd28ad6 feat(audio): add batch write processing and improve thread management
- Add batch write processing functionality to match existing read processing
- Improve thread pinning logic with separate controls for read/write
- Add new batch processing configuration parameters
- Update build tags to exclude arm architecture
2025-09-03 15:21:35 +00:00
Alex P 2ab90e76e0 feat(audio): add validation cache fields to AudioConfigCache
Add atomic fields to AudioConfigCache for validation parameters to enable lock-free access
Optimize validation functions to use cached values for common cases
Move AudioFrameBatch to separate file and update validation logic
2025-09-03 14:48:41 +00:00
Alex P 1b7198aec2 feat(audio): implement sized buffer pool and config caching
Add SizedBufferPool for efficient memory management with size tracking and statistics
Introduce AudioConfigCache to minimize GetConfig() calls in hot paths
Add batch processing support for audio frames to reduce CGO overhead
Extend AudioBufferPoolDetailedStats with total bytes and average size metrics
2025-09-03 14:00:12 +00:00
Alex P f9781f170c perf(audio): increase worker pool sizes and optimize worker management
Double worker counts and queue sizes to handle higher load scenarios
Modify worker management to maintain minimum 2 persistent workers with longer idle timeout
2025-09-03 12:54:07 +00:00
Alex P d7b67e5012 feat(audio): implement goroutine pool for task processing
Add goroutine pool implementation to manage reusable workers for audio processing tasks
Add configuration constants for pool sizing and behavior
Modify audio server components to use pool for goroutine management
Add fallback to direct goroutine creation when pools are full
2025-09-03 12:41:27 +00:00
Alex P 8110be6cc6 feat(audio): optimize audio processing with batch processing and goroutine monitoring
- Add batch audio processing to reduce CGO call overhead
- Implement goroutine monitoring and cleanup for leak prevention
- Optimize buffer pool with TTL-based cache and latency-aware cleanup
- Add configurable parameters for batch processing and monitoring
- Improve CGO audio read performance with config caching
2025-09-03 12:10:54 +00:00
Alex P 950ca2bd99 fix(audio): improve process termination handling in input supervisor
Add more robust process state checking and error handling during audio input server shutdown. Use signal 0 to verify process existence before killing and handle various edge cases. Also improve logging to better track shutdown outcomes.
2025-09-01 11:08:13 +00:00
Alex P dfbf9249b9 fix(audio): improve audio initialization and process termination handling
- Add retry logic for CGO audio initialization
- Enhance process termination checks in input supervisor
- Skip initialization check in cgo_audio to avoid compilation issues
- Add proper error handling for audio system initialization failures
2025-09-01 10:24:26 +00:00
Alex P f51f6da2de fix(audio): improve logging for Opus config and subprocess status
Add detailed logging when sending Opus configuration to audio input subprocess
Include supervisor running status in log when subprocess is not connected
2025-09-01 08:07:53 +00:00
Alex P fd7608384a feat(audio): implement dynamic Opus config updates and optimize audio params
Add support for dynamic Opus encoder configuration updates without requiring subprocess restart. This allows quality changes to be applied immediately while maintaining audio stream continuity.

Optimize audio quality parameters to reduce CPU load and prevent mouse lag on RV1106 devices. Lower bitrates and complexity while adjusting signal types and bandwidths for better performance.

Add build tags for CGO requirements and improve audio input supervisor behavior to check for existing processes before starting new ones.
2025-09-01 08:02:43 +00:00
Alex P 6adcc26ff2 feat(audio): add goroutine cache cleanup and process reuse
Implement periodic cleanup of stale goroutine buffer caches to prevent memory leaks
Add ability to detect and reuse existing audio input server processes
2025-08-29 17:05:37 +00:00
Alex P 858859e317 perf(audio): optimize audio config for RV1106 SoC compatibility
Adjust bitrates, frame sizes, and OPUS parameters to balance quality and performance on RV1106. Reduce channel count for low quality to minimize CPU load. Update CGO constants for better memory efficiency.
2025-08-28 22:25:11 +00:00
Alex P 9c0aff4489 feat(audio): implement audio input supervisor and opus config management
add audio input supervisor with opus configuration support
update audio quality presets and configuration handling
restructure audio subprocess management with environment variables
2025-08-28 22:02:22 +00:00
Alex P 0d4176cf98 refactor(audio): centralize metrics collection with new registry
Introduce MetricsRegistry to serve as single source of truth for audio metrics
Remove duplicate metrics collection logic from web endpoints
Add callback mechanism for metrics updates
2025-08-28 10:01:35 +00:00
Alex P fe4571956d refactor(audio): remove granular latency metrics and histogram functionality
This commit removes the granular latency metrics collection and histogram visualization functionality across the codebase. The changes include:
- Removing latency histogram tracking from audio input/output processing
- Removing latency histogram UI components and related types
- Removing granular metrics collector's latency tracking capabilities
- Updating Prometheus metrics to use milliseconds instead of seconds
- Removing related tests and benchmarks
2025-08-28 08:44:09 +00:00
Alex P f9adb4382d feat(audio): add latency metrics collection for input and output
Add granular metrics collection for audio processing latency in both input and output paths. This enables better performance monitoring through histograms.

Also update build tags to include ARM platform and rename UI label for clarity.
2025-08-28 00:35:51 +00:00
Alex P 758bbbfff6 feat(audio): add latency histogram metrics collection and visualization
- Add LatencyHistogramData interface and implement histogram collection in granular metrics
- Create LatencyHistogram component for visualizing latency distribution
- Update audio metrics events to include histogram data
- Add comprehensive tests for histogram functionality
- Improve error handling for OPUS encoder parameter updates
- Optimize validation cache initialization
2025-08-28 00:24:30 +00:00
Alex P 3efe2f2a1d refactor(audio/validation): improve frame data validation with specific error messages
Split generic frame data validation error into specific cases for empty and oversized frames to provide better error context. The optimized validation maintains performance while improving debuggability.
2025-08-27 23:54:40 +00:00
Alex P ece36ce5fd feat(audio): optimize validation and add dynamic opus encoder configuration
Consolidate audio frame validation functions into a single optimized implementation and add dynamic OPUS encoder parameter updates based on quality settings. Initialize validation cache at startup for consistent performance.

Add latency profiler for end-to-end audio pipeline monitoring. Update test cases to use unified validation function and initialize cache.

The changes improve performance by reducing function call overhead and enabling runtime optimization of audio encoding parameters based on quality presets.
2025-08-27 23:44:16 +00:00
Alex P cdf0b20bc7 perf(audio): optimize validation and buffer pool with caching
- Cache max frame size in validation for hot path performance
- Add lock-free per-goroutine buffer cache to reduce contention
2025-08-27 22:57:07 +00:00
Alex P 25363cef90 perf(audio): replace frame validation with ultra-fast version
Use ValidateAudioFrameUltraFast in critical audio paths to reduce processing overhead
Reduce minimum frame size to 1 byte to allow smaller frames
2025-08-27 22:44:34 +00:00
Alex P e3e7b898b5 style(audio): fix formatting and add missing newlines
- Fix indentation and alignment in performance tests
- Add missing newlines at end of files
- Clean up error message formatting for consistency
2025-08-27 20:54:50 +00:00
Alex P 9dda569523 feat(audio): enhance validation and add logging standards
refactor validation functions to be more comprehensive and add detailed error messages
add new logging standards implementation for audio components
remove deprecated validation_enhanced.go file
add performance-critical tests for validation functions
2025-08-27 20:46:47 +00:00
Alex P 6355dd87be feat(audio): add pool hit tracking and optimize buffer management
refactor(ui): extract audio config display and status indicator components
refactor(audio): simplify validation and error handling
refactor(config): remove duplicate audio quality constants
perf(buffer): optimize buffer pool allocation and tracking
docs(audio): streamline package documentation
2025-08-27 19:37:34 +00:00
Alex P cb20956445 refactor(AudioControlPopover): simplify audio metrics display and remove unused code
Remove advanced metrics toggle and related complex display logic, replacing it with a simplified quick status summary. Also clean up unused imports, state variables, and helper functions.
2025-08-27 18:33:52 +00:00
Alex P 50e04192bf refactor(audio): standardize log levels and messages across components
- Change Info logs to Debug for routine operations
- Standardize log message formatting to lowercase
- Improve error message clarity and consistency
- Add new metrics for device health and memory monitoring
- Simplify config constants documentation
2025-08-27 18:11:06 +00:00
Alex P dc2db8ed2d feat(audio): add comprehensive input validation and base components
refactor(audio): restructure metrics and supervisor components into base implementations
feat(audio): add validation for all audio configurations and frames
fix(audio): fix atomic alignment in metrics structures
refactor(audio): consolidate common functionality into base manager and supervisor
feat(audio): add output IPC manager and config validation
2025-08-27 17:47:39 +00:00
Alex P 8fb0b9f9c6 feat(audio): centralize audio configuration and improve debugging
- Add debug utilities with development-only logging
- Create centralized audio configuration constants
- Implement audio quality service for managing presets
- Replace console logging with debug utilities
- Update audio metrics with unified structure
- Improve microphone error handling and state management
2025-08-27 13:01:56 +00:00
Alex P e8d12bae4b style(audio): fix formatting and add missing newlines
- Fix indentation in test files and supervisor code
- Add missing newlines at end of files
- Clean up documentation formatting
- Fix buffer pool pointer return type
2025-08-26 16:49:41 +00:00
Alex P 6a68e23d12 refactor(audio): improve error handling and memory management
- remove redundant error logging in audio supervisor stop calls
- add buffer pool for memory optimization in audio relay and ipc
- return default metrics when process is not running
- add channel closed flags to prevent double closing
- standardize component naming and logging
- add comprehensive documentation for audio components
- improve test coverage with new unit tests
2025-08-26 14:36:07 +00:00
Alex P b1f85db7de feat(audio): enhance error handling and add device health monitoring
- Implement robust error recovery with progressive backoff in audio streaming
- Add comprehensive device health monitoring system
- Improve ALSA device handling with enhanced retry logic
- Refactor IPC message handling to use shared pools
- Add validation utilities for audio frames and configuration
- Introduce atomic utilities for thread-safe metrics tracking
- Update latency histogram to use configurable buckets
- Add documentation for new metrics and configuration options
2025-08-26 12:51:11 +00:00
Alex P e4ed2b8fad refactor(audio): rename audio components for clarity and add validation
Rename audio server/client components to be more specific (AudioOutputServer/Client). Add new validation.go and ipc_common.go files for shared IPC functionality. Improve error handling and cleanup in input/output IPC components.

Disable granular metrics logging to reduce log pollution. Reset metrics on failed start and ensure proper cleanup. Add common IPC message interface and optimized message pool for reuse.
2025-08-26 10:42:25 +00:00
Alex P fff2d2b791 feat(usbgadget): add nil checks for gadget operations and cleanup tests
refactor(usbgadget): reorganize test files into logical categories

test(usbgadget): add integration tests for audio and usb gadget interactions

fix(dev_deploy): clean up /tmp directory before copying test files
2025-08-25 22:24:41 +00:00
Alex P 6898a6ef1b feat(audio): add granular metrics collection and comprehensive error handling
- Implement granular metrics collection for latency and buffer pool operations
- Add detailed error messages with context for all IPC operations
- Enhance logging with additional operational details
- Introduce comprehensive test suite including unit and integration tests
- Add package documentation explaining architecture and components
2025-08-25 21:00:54 +00:00
Alex P 34f8829e8a refactor(audio): improve configuration handling and validation
- Replace hardcoded values with configurable parameters in output streamer
- Add detailed validation rules for socket buffer configuration
- Enhance CPU percent calculation with bounds checking and validation
- Document message format and validation for IPC communication
- Update latency monitor to use configurable thresholds
- Improve adaptive buffer calculations with validation and documentation
2025-08-25 20:36:26 +00:00
Alex P 60a6e6c5c5 refactor(audio): centralize config values and improve documentation
Move hardcoded values to config and add detailed validation rules documentation for audio components. Update function comments to clearly describe validation logic and thresholds.

The changes ensure consistent configuration management and better maintainability while providing comprehensive documentation of validation rules and system behavior.
2025-08-25 20:35:40 +00:00
Alex P c5216920b3 refactor(audio): centralize config constants and update usage
Move hardcoded values to config constants and update all references to use centralized configuration. Includes:
- Audio processing timeouts and intervals
- CGO sleep durations
- Batch processing parameters
- Event formatting and timeouts
- Process monitor calculations
2025-08-25 19:30:57 +00:00
Alex P 9e343b3cc7 refactor(audio): move hardcoded values to config for better flexibility
- Replace hardcoded values with configurable parameters in audio components
- Add new config fields for adaptive buffer sizes and frame pool settings
- Implement memory guard in ZeroCopyFramePool to prevent excessive allocations
2025-08-25 19:02:29 +00:00
Alex P 35a666ed31 refactor(audio): centralize configuration constants in audio module
Replace hardcoded values with centralized config constants for better maintainability and flexibility. This includes sleep durations, buffer sizes, thresholds, and various audio processing parameters.

The changes affect multiple components including buffer pools, latency monitoring, IPC, and audio processing. This refactoring makes it easier to adjust parameters without modifying individual files.

Key changes:
- Replace hardcoded sleep durations with config values
- Centralize buffer sizes and pool configurations
- Move thresholds and limits to config
- Update audio quality presets to use config values
2025-08-25 18:08:12 +00:00
Alex P 7ec583ed6a refactor(audio): centralize config and remove debug logs
- Move hardcoded constants to centralized config system
- Remove verbose debug logging statements
- Clean up unused code and improve error handling
2025-08-25 16:49:48 +00:00
Alex P d1c192bf8b feat(audio): add real-time USB audio config updates and validation
- Add audio device change listener in UI to update USB config
- Implement audio configuration validation before enabling USB audio
- Broadcast audio device change events for all state changes
2025-08-25 14:21:49 +00:00
Alex P c89d678963 refactor(audio): remove unused context from audio input manager
Simplify AudioInputIPCManager by removing unused context and cancellation logic. The context was not providing any meaningful functionality.

fix(ui): handle audio device changes with proper sync

Add delayed microphone state synchronization when audio devices change to prevent race conditions during USB audio reconfiguration.
2025-08-25 13:53:29 +00:00
Alex P 6f02870c90 fix(audio): improve audio device state management
Add proper cleanup for both audio input manager and output supervisor when disabling audio
Ensure complete shutdown of audio processes before USB reconfiguration
2025-08-25 13:19:29 +00:00
Alex P 1a0377bbdf Improvement: automatically resume audio when the audio usb gadget is re-enabled from settings 2025-08-25 12:36:04 +00:00
Alex P f24443e072 Improvement: automatically resume audio when the audio usb gadget is re-enabled from settings 2025-08-25 11:05:42 +00:00
Alex P 2afe2ca539 Fix: USB Gadgets updates 2025-08-25 10:41:53 +00:00
Alex P bc53523fbb Fix: USB Gadgets update 2025-08-25 09:19:03 +00:00
Alex P 44a35aa5c2 feat(audio): add socket buffer configuration and monitoring
Add socket buffer configuration support with metrics collection for audio IPC connections. This improves performance monitoring and allows tuning socket buffers for optimal audio streaming performance.

- Introduce SocketBufferConfig struct with default and high-load presets
- Add socket buffer configuration to AudioServer and AudioInputServer
- Implement socket buffer metrics collection (size, utilization, overflow)
- Add new Prometheus metrics for socket buffer monitoring
2025-08-24 23:56:58 +00:00
Alex P 3a28105f56 Fix: linting errors 2025-08-24 23:36:29 +00:00
Alex P a9a1082bcc Docs: Updated documentation 2025-08-24 23:15:46 +00:00
Alex e0f7b1d930
Merge branch 'dev' into feat/audio-support 2025-08-25 02:08:12 +03:00
Marc Brooks 5188717bb9 fix: useJsonRpc "any" issue
PR #743 didn't have all the files included in the commit.
Mea culpa, many apologies.
2025-08-24 22:58:35 +00:00
Alex Ballas 70e49a1cac chore: ensure that rpc messages get processed sequentially and avoid phantom and repeated key presses (#744) 2025-08-24 22:57:37 +00:00
Marc Brooks 9d40263eed fix: compiler error (#743)
Using { send } gives the resp a type instead of any
2025-08-24 22:54:26 +00:00
Adam Shiervani 0651faeceb feat: improve custom jiggler settings and add timezone support (#742)
* feat: add timezone support to jiggler and fix custom settings persistence

- Add timezone field to JigglerConfig with comprehensive IANA timezone list
- Fix custom settings not loading current values
- Remove business hours preset, add as examples in custom settings
- Improve error handling for invalid cron expressions

* fix: format jiggler.go with gofmt

* fix: add embedded timezone data and validation

- Import time/tzdata to embed timezone database in binary
- Add timezone validation in runJigglerCronTab() to gracefully fallback to UTC
- Add timezone to debug logging in rpcSetJigglerConfig
- Fixes 'unknown time zone' errors when system lacks timezone data

* refactor: add timezone field comments from jiggler options

* chore: move tzdata to backend

* refactor: fix JigglerSetting linting

- Adjusted useEffect dependency to include send function for better data fetching
- Modified layout classes for improved responsiveness and consistency
- Cleaned up code formatting for better readability

---------

Co-authored-by: Siyuan Miao <i@xswan.net>
2025-08-24 22:54:26 +00:00
Serhii 199cca83ed Update mount list for new Debian 13 release (#739)
* Update mount list for new Debian 13 release

* Keep Debian 12 Bookworm as old-stable release
2025-08-24 22:54:26 +00:00
Marc Brooks a3b2b46f49 Lint fix from last merge. (#733) 2025-08-24 22:54:26 +00:00
jackislanding f729675a3f Added crontab scheduler for jiggler (#316) 2025-08-24 22:54:26 +00:00
Marc Brooks 785a68d923 chore(ui)/package upgrades (#724)
| Package                          | From     | To           |
| -------------------------------- | ----------- | ------------ |
| @headlessui/react        | 2.2.4     | 2.2.7        |
| framer-motion              | 12.23.3 | 12.23.12 |
| react                                | 19.1.0   | 19.1.1     |
| react-dom                       | 19.1.0   | 19.1.1    |
| react-simple-keyboard | 3.8.93   | 3.8.106  |
|@eslint/js                         | 9.30.1   | 9.32.0    |
| @types/react                  | 19.1.8   | 19.1.9    |
| @types/react-dom         | 19.1.8   | 19.1.9   |
|eslint                                 | 9.30.1   | 9.32.0    |
|eslint-config-prettier       | 10.1.5   | 10.1.8   |
| typescript                         |  5.8.3    | 5.9.2     |
2025-08-24 22:54:26 +00:00
Alex P 57b7bafcc1 feat(audio): implement comprehensive audio optimization system
- Add AdaptiveOptimizer for real-time parameter adjustment based on latency metrics
- Add AdaptiveBufferConfig for dynamic buffer sizing based on system load
- Implement BatchAudioProcessor for reduced CGO call overhead
- Add AudioBufferPool with sync.Pool for optimized memory allocation
- Implement LatencyMonitor with exponential moving averages
- Add MemoryMetrics for comprehensive memory usage tracking
- Implement PriorityScheduler with SCHED_FIFO for real-time audio processing
- Add zero-copy operations to minimize memory copying in audio pipeline
- Enhance IPC architecture with intelligent frame dropping
- Add comprehensive Prometheus metrics for performance monitoring
- Implement triple-goroutine architecture for audio input processing
- Add adaptive buffering and performance feedback loops
2025-08-24 22:33:49 +00:00
Alex P 88679cda2f refactor(audio): improve process monitoring with dynamic clock ticks
- Extract monitoring constants and configuration into centralized locations
- Implement dynamic clock ticks detection for more accurate CPU metrics
- Add warmup samples and bounds checking for CPU percentage calculation
- Replace hardcoded values with constants for better maintainability
2025-08-23 23:35:38 +00:00
Alex P 76174f4486 refactor(audio): improve performance and simplify code structure
- Move audio server logic to dedicated package and simplify main.go
- Optimize buffer pool implementation and remove redundant logging
- Improve process monitoring with synchronized metrics updates
- Enhance microphone contention manager with simplified logic
- Replace mutex with atomic operations for metrics tracking
2025-08-23 22:54:01 +00:00
Alex P 27a999c58a [WIP] Updates: audio output & input subprocesses memory & cpu usage 2025-08-23 21:51:24 +00:00
Alex P ddc2f90016 [WIP] Updates: audio output & input subprocesses memory & cpu usage 2025-08-23 21:36:57 +00:00
Alex P 692f7ddb2d [WIP] Updates: audio output & input subprocesses memory & cpu usage 2025-08-23 21:19:28 +00:00
Alex P 38ad145863 [WIP] Updates: audio output & input subprocesses memory & cpu usage 2025-08-23 21:06:02 +00:00
Alex P 879ea5e472 Fix: fix audio input by reverting change 2025-08-23 16:41:45 +00:00
Alex P 2082b1a671 refactor(audio): rename audio-server flag to audio-output-server for clarity
docs: update development documentation with new make targets
refactor: simplify audio quality presets implementation
style: remove redundant comments and align error handling
chore: add lint-ui-fix target to Makefile
2025-08-23 12:18:33 +00:00
Alex P 5e28a6c429 feat(audio): add system memory endpoint and process metrics monitoring
- Add new /system/memory endpoint to expose total system memory
- Implement process metrics collection for audio and microphone processes
- Update UI to display real-time process metrics with charts
- Replace environment variable check with CLI flag for audio input server
- Improve audio metrics broadcasting with 1-second intervals
- Add memory usage capping for CPU percentage metrics
2025-08-23 11:41:03 +00:00
Alex P 0e1c896aa2 Fix: go lint errors 2025-08-22 23:23:07 +00:00
Alex P 0ed84257f6 Improvements, Fixes: enhanced audio metrics (including prometheus format), fixed lint errors 2025-08-22 23:20:22 +00:00
Alex P 32055f5762 Cleanup: remove polling fallback for /audio/mute status 2025-08-22 22:54:05 +00:00
Alex P 97bcb3c1ea Fix: linter errors 2025-08-22 22:29:48 +00:00
Alex P 6ecb829334 Fix: linter errors 2025-08-22 22:28:15 +00:00
Alex P e360348829 Fix: linter errors 2025-08-22 22:26:15 +00:00
Alex P 1e1677b35a Fix: linter errors 2025-08-22 22:23:50 +00:00
Alex P 3c1e9b8dc2 Fix: audio subprocess handling, avg atency audio metric 2025-08-22 22:21:41 +00:00
Alex P 62d4ec2f89 Fix: audio subprocess handling 2025-08-22 22:17:27 +00:00
Alex P aeb7a12c72 Fix: linting errors 2025-08-22 22:07:35 +00:00
Alex P 671d875890 Fix: literal /home/vscode in cache paths 2025-08-22 21:49:15 +00:00
Alex P 7129bd5521 Fix: workflow indentation 2025-08-22 21:43:51 +00:00
Alex P bd4fbef6dc Tweak: steps order 2025-08-22 21:43:17 +00:00
Alex P b3373e56de Improvement: use cache save/restore actions 2025-08-22 21:41:44 +00:00
Alex P 73e8897fc3 Improvement: Automatically invalidate cache 2025-08-22 21:37:53 +00:00
Alex P de0077a351 Fix: always save cache 2025-08-22 21:34:27 +00:00
Alex P 4875c243d3 Fix: Lint env vars 2025-08-22 21:30:24 +00:00
Alex P 071129a9ec Fix: use absolute path for caching 2025-08-22 21:26:37 +00:00
Alex P dee8a0b5a1 Fix: golangci-lint 2025-08-22 21:21:09 +00:00
Alex P a976ce1da9 Updates: set LDFLAGS and CFLAGS for the lint steps 2025-08-22 21:15:25 +00:00
Alex P d5295d0e4b Updates: golangci-lint workflow 2025-08-22 21:06:40 +00:00
Alex P 423d5775e3 [WIP] Performance Enhancements: move audion processing into a separate process 2025-08-22 12:04:30 +00:00
Adam Shiervani 7e83015932 refactor(vite.config): fix local ui development proxy 2025-08-13 18:53:30 +02:00
Alex P 629cdf59a7 perf(audio): optimize audio processing with batching and atomic operations
- Implement batch audio processing to reduce CGO overhead
- Replace mutexes with atomic operations for contention management
- Add buffer pooling to reduce allocations
- Optimize microphone operation cooldown with lock-free approach
- Improve error handling with pre-allocated error objects
2025-08-13 14:49:08 +00:00
Alex P 767311ec04 [WIP] Fix: performance issues 2025-08-13 11:35:11 +00:00
Alex P c51bdc50b5 Fix: linter errors 2025-08-12 13:59:21 +00:00
Alex P 1f2c46230c build(audio): update cgo LDFLAGS to use env vars for library versions 2025-08-12 13:35:39 +00:00
Alex P 4688f9e6ca perf(build): add ARM Cortex-A7 optimization flags for audio builds
Add compiler optimization flags targeting ARM Cortex-A7 with NEON support
to improve performance of audio library builds and Go binaries. The flags
enable vectorization, fast math, and loop unrolling for better execution
speed on the target hardware.
2025-08-12 11:20:19 +00:00
Alex P a9a92c52ab feat(rpc): optimize input handling with direct path for performance
perf(audio): make audio library versions configurable in build

test(input): add comprehensive tests for input RPC validation
2025-08-12 10:56:09 +00:00
Alex P 4b693b4279 perf(usbgadget): reduce input latency by pre-opening HID files and removing throttling
Pre-open HID files during initialization to minimize I/O overhead during operation. Remove mouse event throttling mechanism to improve input responsiveness. Keep HID files open on write errors to avoid repeated file operations.
2025-08-12 10:07:58 +00:00
Alex P 5f905e7eee Fix: session duplication detection, dev_deploy.sh script 2025-08-07 10:12:50 +00:00
Alex P 94ca3fa3f4 Stability: prevent race condition when clicking on Mic Start, Stop buttons in quick succession 2025-08-05 09:02:21 +03:00
Alex P 3c1f96d49c Separation of Concerns: Move the audio-related code into the audio internal package 2025-08-05 02:04:37 +03:00
Alex P a208715cc6 Fix: goimports 2025-08-05 01:49:09 +03:00
Alex P 638d08cdc5 Fix: goimports 2025-08-05 01:47:50 +03:00
Alex P 520c218598 Efficiency Improvements: Switch to websocket-based communication for audio metrics & status 2025-08-05 01:43:58 +03:00
Alex P 3158ca59f7 Finetuning: further perf. tunning 2025-08-05 01:05:06 +03:00
Alex P 612dca3fca Fix: eslint errors 2025-08-05 00:17:08 +03:00
Alex P 3444607021 Improvements, Fixes: reduce mouse lag when audio is on 2025-08-04 23:29:47 +03:00
Alex P 3dc196bab5 Fix: lint errors 2025-08-04 20:30:39 +03:00
Alex P 575abb75f0 [WIP] Updates: audio input support 2025-08-04 20:08:54 +03:00
Alex P 09ac8c5e37 Cleanup / Fix: linting errors, code formatting, etc 2025-08-02 17:45:24 +00:00
Alex P 4f47d62079 [#315] Updates: add advanced audio support 2025-08-02 02:23:37 +00:00
Qishuai Liu 28a8fa05cc
feat: use native jetkvm-audio 2025-06-26 00:30:00 +09:00
Qishuai Liu c529c903d0
Merge remote-tracking branch 'upstream/dev' into feat/usb-audio 2025-06-22 00:36:01 +09:00
Qishuai Liu 9d12dd1e54
fix: audio rtp timestamp 2025-05-16 23:11:22 +09:00
Qishuai Liu cc83e4193f
feat: add audio encoder 2025-05-14 23:41:48 +09:00
Qishuai Liu 466271d935
feat: add usb gadget audio config 2025-05-14 23:15:45 +09:00
118 changed files with 11444 additions and 1823 deletions

View File

@ -5,7 +5,7 @@ function sudo() {
if [ "$UID" -eq 0 ]; then
"$@"
else
${SUDO_PATH} "$@"
${SUDO_PATH} -E "$@"
fi
}
@ -16,7 +16,7 @@ sudo apt-get update && \
sudo apt-get install -y --no-install-recommends \
build-essential \
device-tree-compiler \
gperf g++-multilib gcc-multilib \
gperf \
libnl-3-dev libdbus-1-dev libelf-dev libmpc-dev dwarves \
bc openssl flex bison libssl-dev python3 python-is-python3 texinfo kmod cmake \
wget zstd \
@ -30,7 +30,35 @@ pushd "${BUILDKIT_TMPDIR}" > /dev/null
wget https://github.com/jetkvm/rv1106-system/releases/download/${BUILDKIT_VERSION}/buildkit.tar.zst && \
sudo mkdir -p /opt/jetkvm-native-buildkit && \
sudo tar --use-compress-program="unzstd --long=31" -xvf buildkit.tar.zst -C /opt/jetkvm-native-buildkit && \
sudo tar --use-compress-program="zstd -d --long=31" -xvf buildkit.tar.zst -C /opt/jetkvm-native-buildkit && \
rm buildkit.tar.zst
popd
rm -rf "${BUILDKIT_TMPDIR}"
# Install audio dependencies (ALSA and Opus) for JetKVM
echo "Installing JetKVM audio dependencies..."
SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
PROJECT_ROOT="$(dirname "${SCRIPT_DIR}")"
AUDIO_DEPS_SCRIPT="${PROJECT_ROOT}/install_audio_deps.sh"
if [ -f "${AUDIO_DEPS_SCRIPT}" ]; then
echo "Running audio dependencies installation..."
# Pre-create audio libs directory with proper permissions
sudo mkdir -p /opt/jetkvm-audio-libs
sudo chmod 777 /opt/jetkvm-audio-libs
# Run installation script (now it can write without sudo)
bash "${AUDIO_DEPS_SCRIPT}"
echo "Audio dependencies installation completed."
if [ -d "/opt/jetkvm-audio-libs" ]; then
echo "Audio libraries installed in /opt/jetkvm-audio-libs"
# Set recursive permissions for all subdirectories and files
sudo chmod -R 777 /opt/jetkvm-audio-libs
echo "Permissions set to allow all users access to audio libraries"
else
echo "Error: /opt/jetkvm-audio-libs directory not found after installation."
exit 1
fi
else
echo "Warning: Audio dependencies script not found at ${AUDIO_DEPS_SCRIPT}"
echo "Skipping audio dependencies installation."
fi
rm -rf "${BUILDKIT_TMPDIR}"

View File

@ -0,0 +1,74 @@
#!/bin/bash
# .devcontainer/install_audio_deps.sh
# Build ALSA and Opus static libs for ARM in /opt/jetkvm-audio-libs
set -e
# Sudo wrapper function
SUDO_PATH=$(which sudo 2>/dev/null || echo "")
function use_sudo() {
if [ "$UID" -eq 0 ]; then
"$@"
elif [ -n "$SUDO_PATH" ]; then
${SUDO_PATH} -E "$@"
else
"$@"
fi
}
# Accept version parameters or use defaults
ALSA_VERSION="${1:-1.2.14}"
OPUS_VERSION="${2:-1.5.2}"
AUDIO_LIBS_DIR="/opt/jetkvm-audio-libs"
BUILDKIT_PATH="/opt/jetkvm-native-buildkit"
BUILDKIT_FLAVOR="arm-rockchip830-linux-uclibcgnueabihf"
CROSS_PREFIX="$BUILDKIT_PATH/bin/$BUILDKIT_FLAVOR"
# Create directory with proper permissions
use_sudo mkdir -p "$AUDIO_LIBS_DIR"
use_sudo chmod 777 "$AUDIO_LIBS_DIR"
cd "$AUDIO_LIBS_DIR"
# Download sources
[ -f alsa-lib-${ALSA_VERSION}.tar.bz2 ] || wget -N https://www.alsa-project.org/files/pub/lib/alsa-lib-${ALSA_VERSION}.tar.bz2
[ -f opus-${OPUS_VERSION}.tar.gz ] || wget -N https://downloads.xiph.org/releases/opus/opus-${OPUS_VERSION}.tar.gz
# Extract
[ -d alsa-lib-${ALSA_VERSION} ] || tar xf alsa-lib-${ALSA_VERSION}.tar.bz2
[ -d opus-${OPUS_VERSION} ] || tar xf opus-${OPUS_VERSION}.tar.gz
# Optimization flags for ARM Cortex-A7 with NEON (simplified to avoid FD_SETSIZE issues)
OPTIM_CFLAGS="-O2 -mfpu=neon -mtune=cortex-a7 -mfloat-abi=hard"
export CC="${CROSS_PREFIX}-gcc"
export CFLAGS="$OPTIM_CFLAGS"
export CXXFLAGS="$OPTIM_CFLAGS"
# Build ALSA
cd alsa-lib-${ALSA_VERSION}
if [ ! -f .built ]; then
chown -R $(whoami):$(whoami) .
# Use minimal ALSA configuration to avoid FD_SETSIZE issues in devcontainer
CFLAGS="$OPTIM_CFLAGS" ./configure --host $BUILDKIT_FLAVOR \
--enable-static=yes --enable-shared=no \
--with-pcm-plugins=rate,linear \
--disable-seq --disable-rawmidi --disable-ucm \
--disable-python --disable-old-symbols \
--disable-topology --disable-hwdep --disable-mixer \
--disable-alisp --disable-aload --disable-resmgr
make -j$(nproc)
touch .built
fi
cd ..
# Build Opus
cd opus-${OPUS_VERSION}
if [ ! -f .built ]; then
chown -R $(whoami):$(whoami) .
CFLAGS="$OPTIM_CFLAGS" ./configure --host $BUILDKIT_FLAVOR --enable-static=yes --enable-shared=no --enable-fixed-point
make -j$(nproc)
touch .built
fi
cd ..
echo "ALSA and Opus built in $AUDIO_LIBS_DIR"

View File

@ -0,0 +1,9 @@
Fixes #<issue-number>
### Summary
- What changed and why in 13 sentences.
### Checklist
- [ ] Linked to issue(s) above by issue number (e.g. `Closes #<issue-number>`)
- [ ] One problem per PR (no unrelated changes)
- [ ] Lints pass; CI green

View File

@ -0,0 +1,17 @@
Closes #<issue-number>
### Summary
- What and why in 13 sentences.
### UI Changes
- Add before/after images or a short clip.
### Checklist
- [ ] Linked to issue(s) above by issue number (e.g. `Closes #<issue-number>`)
- [ ] One problem per PR (no unrelated changes)
- [ ] Lints pass; CI green
- [ ] Tricky parts are commented in code
- [ ] Backward compatible with existing device firmware (See `DEVELOPMENT.md` for details)

100
.github/workflows/stale-issues.yml vendored Normal file
View File

@ -0,0 +1,100 @@
name: Close stale issues and PRs (dry-run)
on:
schedule:
- cron: '30 1 * * *' # Runs daily at 01:30 UTC
workflow_dispatch: # Allow manual runs from the Actions tab
permissions:
issues: write
pull-requests: write
jobs:
stale:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@v10
with:
# ──────────────────────────────────────────────────────────────────────
# OVERVIEW — HOW THIS WORKS
# 1) The job scans issues/PRs once per run.
# 2) If an item has had no activity for `days-before-stale`, its labeled `Stale`
# and receives the relevant “stale” comment.
# 3) If theres still no activity for `days-before-close` AFTER being marked
# stale, the item is closed and receives a closing comment.
# 4) Any new activity (comment, label change, commit, edit) clears `Stale`
# and resets the timers if `remove-stale-when-updated` is true.
# ──────────────────────────────────────────────────────────────────────
# ── TIMING / BEHAVIOR ────────────────────────────────────────────────
# Number of idle days before applying the `Stale` label + stale comment.
days-before-stale: 60
# Number of days AFTER staling with no activity before auto-closing.
# (Measured from when `Stale` was added.) Set to -1 to never auto-close.
days-before-close: 14
# If someone comments/updates, automatically remove `Stale` and reset timers.
remove-stale-when-updated: true
# Dont nag draft PRs; they are explicitly a work-in-progress stage.
exempt-draft-pr: true
# Fetch ordering when scanning items. `updated` helps focus on the most recently touched.
sort-by: updated
# ── MESSAGES (markdown) ──────────────────────────────────────────────
stale-issue-message: |
**This issue has been inactive for 60 days and is now marked as stale.**
To keep the tracker focused, older inactive issues are flagged.
If this still applies:
- Add a comment with **reproduction steps**, **environment details**, and **JetKVM version**.
- Verify whether it still occurs with the current build: see [OTA / Updates](https://jetkvm.com/docs/advanced-usage/ota-updates).
- Any new comment or update will remove the *Stale* label automatically.
Issues not updated within 14 days after being marked stale may be closed.
stale-pr-message: |
**This pull request has been inactive for 60 days and is now marked as stale.**
To continue:
- Push a commit or add a comment about next steps — this removes the *Stale* label automatically.
- Ensure the changes work with the current build: see [OTA / Updates](https://jetkvm.com/docs/advanced-usage/ota-updates).
- If this is blocked or awaiting review, mention that for visibility.
PRs not updated within 14 days after being marked stale may be closed.
close-issue-message: |
**Closing this issue due to extended inactivity.**
It has been 14 days since it was marked as stale without further updates.
If the problem persists:
- Reopen this issue, or open a new one with **reproduction steps**, **logs**, **environment**, and **JetKVM version**.
- Confirm behavior with the current build: [OTA / Updates](https://jetkvm.com/docs/advanced-usage/ota-updates).
close-pr-message: |
**Closing this pull request due to extended inactivity.**
It has been 14 days since it was marked as stale with no updates or commits.
If the changes are still relevant:
- Reopen this PR or submit a refreshed PR rebased on the latest code.
- Confirm that it builds and works with the current build: [OTA / Updates](https://jetkvm.com/docs/advanced-usage/ota-updates).
# ── SAFETY / ROLLOUT ────────────────────────────────────────────────
# DRY-RUN: log what would happen, but do NOT write labels/comments/close.
debug-only: true
# Print a summary of how many items were staled/closed (or would be, in dry-run).
enable-statistics: true
# Limit GitHub API operations per run (gentle start for large repos).
# Increase later (e.g., 2001000) once youre confident with behavior.
operations-per-run: 50
# ── LABELS ───────────────────────────────────────────────────────────
# Names of the labels applied when staling items. Defaults shown for clarity.
stale-issue-label: 'Stale'
stale-pr-label: 'Stale'

2
.gitignore vendored
View File

@ -12,4 +12,4 @@ node_modules
# generated during the build process
#internal/native/include
#internal/native/lib
#internal/native/lib

View File

@ -3,5 +3,12 @@
"cva",
"cx"
],
"git.ignoreLimitWarning": true
"gopls": {
"build.buildFlags": [
"-tags",
"synctrace"
]
},
"git.ignoreLimitWarning": true,
"cmake.sourceDirectory": "/workspaces/kvm-static-ip/internal/native/cgo"
}

View File

@ -6,6 +6,8 @@ ENV GOPATH=/go
ENV PATH=$GOPATH/bin:/usr/local/go/bin:$PATH
COPY install-deps.sh /install-deps.sh
COPY install_audio_deps.sh /install_audio_deps.sh
RUN /install-deps.sh
# Create build directory
@ -21,4 +23,4 @@ RUN go mod download && go mod verify
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
ENTRYPOINT [ "/entrypoint.sh" ]
ENTRYPOINT [ "/entrypoint.sh" ]

110
Makefile
View File

@ -1,10 +1,52 @@
BRANCH := $(shell git rev-parse --abbrev-ref HEAD)
BUILDDATE := $(shell date -u +%FT%T%z)
BUILDTS := $(shell date -u +%s)
REVISION := $(shell git rev-parse HEAD)
# Build ALSA and Opus static libs for ARM in /opt/jetkvm-audio-libs
build_audio_deps:
bash .devcontainer/install_audio_deps.sh $(ALSA_VERSION) $(OPUS_VERSION)
# Prepare everything needed for local development (toolchain + audio deps + Go tools)
dev_env: build_audio_deps
$(CLEAN_GO_CACHE)
@echo "Installing Go development tools..."
go install golang.org/x/tools/cmd/goimports@latest
@echo "Development environment ready."
JETKVM_HOME ?= $(HOME)/.jetkvm
BUILDKIT_PATH ?= /opt/jetkvm-native-buildkit
BUILDKIT_FLAVOR ?= arm-rockchip830-linux-uclibcgnueabihf
AUDIO_LIBS_DIR ?= /opt/jetkvm-audio-libs
BRANCH ?= $(shell git rev-parse --abbrev-ref HEAD)
BUILDDATE ?= $(shell date -u +%FT%T%z)
BUILDTS ?= $(shell date -u +%s)
REVISION ?= $(shell git rev-parse HEAD)
VERSION_DEV := 0.4.9-dev$(shell date +%Y%m%d%H%M)
VERSION := 0.4.8
# Audio library versions
ALSA_VERSION ?= 1.2.14
OPUS_VERSION ?= 1.5.2
# Set PKG_CONFIG_PATH globally for all targets that use CGO with audio libraries
export PKG_CONFIG_PATH := $(AUDIO_LIBS_DIR)/alsa-lib-$(ALSA_VERSION)/utils:$(AUDIO_LIBS_DIR)/opus-$(OPUS_VERSION)
# Common command to clean Go cache with verbose output for all Go builds
CLEAN_GO_CACHE := @echo "Cleaning Go cache..."; go clean -cache -v
# Optimization flags for ARM Cortex-A7 with NEON SIMD
OPTIM_CFLAGS := -O3 -mfpu=neon -mtune=cortex-a7 -mfloat-abi=hard -ftree-vectorize -ffast-math -funroll-loops -mvectorize-with-neon-quad -marm -D__ARM_NEON
# Cross-compilation environment for ARM - exported globally
export GOOS := linux
export GOARCH := arm
export GOARM := 7
export CC := $(BUILDKIT_PATH)/bin/$(BUILDKIT_FLAVOR)-gcc
export CGO_ENABLED := 1
export CGO_CFLAGS := $(OPTIM_CFLAGS) -I$(BUILDKIT_PATH)/$(BUILDKIT_FLAVOR)/include -I$(BUILDKIT_PATH)/$(BUILDKIT_FLAVOR)/sysroot/usr/include
export CGO_LDFLAGS := -L$(BUILDKIT_PATH)/$(BUILDKIT_FLAVOR)/lib -L$(BUILDKIT_PATH)/$(BUILDKIT_FLAVOR)/sysroot/usr/lib -lrockit -lrockchip_mpp -lrga -lpthread -lm -ldl
# Audio-specific flags (only used for audio C binaries, NOT for main Go app)
AUDIO_CFLAGS := $(CGO_CFLAGS) -I$(AUDIO_LIBS_DIR)/alsa-lib-$(ALSA_VERSION)/include -I$(AUDIO_LIBS_DIR)/opus-$(OPUS_VERSION)/include -I$(AUDIO_LIBS_DIR)/opus-$(OPUS_VERSION)/celt
AUDIO_LDFLAGS := $(AUDIO_LIBS_DIR)/alsa-lib-$(ALSA_VERSION)/src/.libs/libasound.a $(AUDIO_LIBS_DIR)/opus-$(OPUS_VERSION)/.libs/libopus.a -lm -ldl -lpthread
PROMETHEUS_TAG := github.com/prometheus/common/version
KVM_PKG_NAME := github.com/jetkvm/kvm
@ -12,7 +54,13 @@ BUILDKIT_FLAVOR := arm-rockchip830-linux-uclibcgnueabihf
BUILDKIT_PATH ?= /opt/jetkvm-native-buildkit
SKIP_NATIVE_IF_EXISTS ?= 0
SKIP_UI_BUILD ?= 0
ENABLE_SYNC_TRACE ?= 0
GO_BUILD_ARGS := -tags netgo,timetzdata,nomsgpack
ifeq ($(ENABLE_SYNC_TRACE), 1)
GO_BUILD_ARGS := $(GO_BUILD_ARGS),synctrace
endif
GO_RELEASE_BUILD_ARGS := -trimpath $(GO_BUILD_ARGS)
GO_LDFLAGS := \
-s -w \
@ -49,22 +97,26 @@ build_native:
./scripts/build_cgo.sh; \
fi
build_dev: build_native
build_dev: build_native build_audio_deps
$(CLEAN_GO_CACHE)
@echo "Building..."
$(GO_CMD) build \
go build \
-ldflags="$(GO_LDFLAGS) -X $(KVM_PKG_NAME).builtAppVersion=$(VERSION_DEV)" \
$(GO_RELEASE_BUILD_ARGS) \
-o $(BIN_DIR)/jetkvm_app -v cmd/main.go
build_test2json:
$(CLEAN_GO_CACHE)
$(GO_CMD) build -o $(BIN_DIR)/test2json cmd/test2json
build_gotestsum:
$(CLEAN_GO_CACHE)
@echo "Building gotestsum..."
$(GO_CMD) install gotest.tools/gotestsum@latest
cp $(shell $(GO_CMD) env GOPATH)/bin/linux_arm/gotestsum $(BIN_DIR)/gotestsum
build_dev_test: build_test2json build_gotestsum
build_dev_test: build_audio_deps build_test2json build_gotestsum
$(CLEAN_GO_CACHE)
# collect all directories that contain tests
@echo "Building tests for devices ..."
@rm -rf $(BIN_DIR)/tests && mkdir -p $(BIN_DIR)/tests
@ -74,7 +126,7 @@ build_dev_test: build_test2json build_gotestsum
test_pkg_name=$$(echo $$test | sed 's/^.\///g'); \
test_pkg_full_name=$(KVM_PKG_NAME)/$$(echo $$test | sed 's/^.\///g'); \
test_filename=$$(echo $$test_pkg_name | sed 's/\//__/g')_test; \
$(GO_CMD) test -v \
go test -v \
-ldflags="$(GO_LDFLAGS) -X $(KVM_PKG_NAME).builtAppVersion=$(VERSION_DEV)" \
$(GO_BUILD_ARGS) \
-c -o $(BIN_DIR)/tests/$$test_filename $$test; \
@ -111,9 +163,10 @@ dev_release: frontend build_dev
rclone copyto bin/jetkvm_app r2://jetkvm-update/app/$(VERSION_DEV)/jetkvm_app
rclone copyto bin/jetkvm_app.sha256 r2://jetkvm-update/app/$(VERSION_DEV)/jetkvm_app.sha256
build_release: frontend build_native
build_release: frontend build_native build_audio_deps
$(CLEAN_GO_CACHE)
@echo "Building release..."
$(GO_CMD) build \
go build \
-ldflags="$(GO_LDFLAGS) -X $(KVM_PKG_NAME).builtAppVersion=$(VERSION)" \
$(GO_RELEASE_BUILD_ARGS) \
-o bin/jetkvm_app cmd/main.go
@ -127,4 +180,39 @@ release:
@echo "Uploading release..."
@shasum -a 256 bin/jetkvm_app | cut -d ' ' -f 1 > bin/jetkvm_app.sha256
rclone copyto bin/jetkvm_app r2://jetkvm-update/app/$(VERSION)/jetkvm_app
rclone copyto bin/jetkvm_app.sha256 r2://jetkvm-update/app/$(VERSION)/jetkvm_app.sha256
rclone copyto bin/jetkvm_app.sha256 r2://jetkvm-update/app/$(VERSION)/jetkvm_app.sha256
# Run both Go and UI linting
lint: lint-go lint-ui
@echo "All linting completed successfully!"
# Run golangci-lint locally with the same configuration as CI
lint-go: build_audio_deps
@echo "Running golangci-lint..."
@mkdir -p static && touch static/.gitkeep
golangci-lint run --verbose
# Run both Go and UI linting with auto-fix
lint-fix: lint-go-fix lint-ui-fix
@echo "All linting with auto-fix completed successfully!"
# Run golangci-lint with auto-fix
lint-go-fix: build_audio_deps
@echo "Running golangci-lint with auto-fix..."
@mkdir -p static && touch static/.gitkeep
golangci-lint run --fix --verbose
# Run UI linting locally (mirrors GitHub workflow ui-lint.yml)
lint-ui:
@echo "Running UI lint..."
@cd ui && npm ci
@cd ui && npm run lint
# Run UI linting with auto-fix
lint-ui-fix:
@echo "Running UI lint with auto-fix..."
@cd ui && npm ci
@cd ui && npm run lint:fix
# Legacy alias for UI linting (for backward compatibility)
ui-lint: lint-ui

308
audio.go Normal file
View File

@ -0,0 +1,308 @@
package kvm
import (
"io"
"sync"
"sync/atomic"
"github.com/jetkvm/kvm/internal/audio"
"github.com/jetkvm/kvm/internal/logging"
"github.com/pion/webrtc/v4"
"github.com/rs/zerolog"
)
var (
audioMutex sync.Mutex
outputSource audio.AudioSource
inputSource audio.AudioSource
outputRelay *audio.OutputRelay
inputRelay *audio.InputRelay
audioInitialized bool
activeConnections atomic.Int32
audioLogger zerolog.Logger
currentAudioTrack *webrtc.TrackLocalStaticSample
inputTrackHandling atomic.Bool
useUSBForAudioOutput bool
audioOutputEnabled atomic.Bool
audioInputEnabled atomic.Bool
)
func initAudio() {
audioLogger = logging.GetDefaultLogger().With().Str("component", "audio-manager").Logger()
// Load audio output source from config
ensureConfigLoaded()
useUSBForAudioOutput = config.AudioOutputSource == "usb"
// Enable both by default
audioOutputEnabled.Store(true)
audioInputEnabled.Store(true)
audioLogger.Debug().
Str("source", config.AudioOutputSource).
Msg("Audio subsystem initialized")
audioInitialized = true
}
// startAudio starts audio sources and relays (skips already running ones)
func startAudio() error {
audioMutex.Lock()
defer audioMutex.Unlock()
if !audioInitialized {
audioLogger.Warn().Msg("Audio not initialized, skipping start")
return nil
}
// Start output audio if not running and enabled
if outputSource == nil && audioOutputEnabled.Load() {
alsaDevice := "hw:0,0" // HDMI
if useUSBForAudioOutput {
alsaDevice = "hw:1,0" // USB
}
// Create CGO audio source
outputSource = audio.NewCgoOutputSource(alsaDevice)
if currentAudioTrack != nil {
outputRelay = audio.NewOutputRelay(outputSource, currentAudioTrack)
if err := outputRelay.Start(); err != nil {
audioLogger.Error().Err(err).Msg("Failed to start audio output relay")
}
}
}
// Start input audio if not running, USB audio enabled, and input enabled
ensureConfigLoaded()
if inputSource == nil && audioInputEnabled.Load() && config.UsbDevices != nil && config.UsbDevices.Audio {
alsaPlaybackDevice := "hw:1,0" // USB speakers
// Create CGO audio source
inputSource = audio.NewCgoInputSource(alsaPlaybackDevice)
inputRelay = audio.NewInputRelay(inputSource)
if err := inputRelay.Start(); err != nil {
audioLogger.Error().Err(err).Msg("Failed to start input relay")
}
}
return nil
}
// stopOutputLocked stops output audio (assumes mutex is held)
func stopOutputLocked() {
if outputRelay != nil {
outputRelay.Stop()
outputRelay = nil
}
if outputSource != nil {
outputSource.Disconnect()
outputSource = nil
}
}
// stopInputLocked stops input audio (assumes mutex is held)
func stopInputLocked() {
if inputRelay != nil {
inputRelay.Stop()
inputRelay = nil
}
if inputSource != nil {
inputSource.Disconnect()
inputSource = nil
}
}
// stopAudioLocked stops all audio (assumes mutex is held)
func stopAudioLocked() {
stopOutputLocked()
stopInputLocked()
}
// stopAudio stops all audio
func stopAudio() {
audioMutex.Lock()
defer audioMutex.Unlock()
stopAudioLocked()
}
func onWebRTCConnect() {
count := activeConnections.Add(1)
if count == 1 {
if err := startAudio(); err != nil {
audioLogger.Error().Err(err).Msg("Failed to start audio")
}
}
}
func onWebRTCDisconnect() {
count := activeConnections.Add(-1)
if count == 0 {
// Stop audio immediately to release HDMI audio device which shares hardware with video device
stopAudio()
}
}
func setAudioTrack(audioTrack *webrtc.TrackLocalStaticSample) {
audioMutex.Lock()
defer audioMutex.Unlock()
currentAudioTrack = audioTrack
if outputRelay != nil {
outputRelay.Stop()
outputRelay = nil
}
if outputSource != nil {
outputRelay = audio.NewOutputRelay(outputSource, audioTrack)
if err := outputRelay.Start(); err != nil {
audioLogger.Error().Err(err).Msg("Failed to start output relay")
}
}
}
// SetAudioOutputSource switches between HDMI and USB audio output
func SetAudioOutputSource(useUSB bool) error {
audioMutex.Lock()
defer audioMutex.Unlock()
if useUSBForAudioOutput == useUSB {
return nil
}
audioLogger.Info().
Bool("old_usb", useUSBForAudioOutput).
Bool("new_usb", useUSB).
Msg("Switching audio output source")
useUSBForAudioOutput = useUSB
ensureConfigLoaded()
if useUSB {
config.AudioOutputSource = "usb"
} else {
config.AudioOutputSource = "hdmi"
}
if err := SaveConfig(); err != nil {
audioLogger.Error().Err(err).Msg("Failed to save config")
return err
}
stopOutputLocked()
// Restart if there are active connections
if activeConnections.Load() > 0 {
audioMutex.Unlock()
err := startAudio()
audioMutex.Lock()
if err != nil {
audioLogger.Error().Err(err).Msg("Failed to restart audio output")
return err
}
}
return nil
}
func setPendingInputTrack(track *webrtc.TrackRemote) {
audioMutex.Lock()
defer audioMutex.Unlock()
// Start input track handler only once per WebRTC session
if inputTrackHandling.CompareAndSwap(false, true) {
go handleInputTrackForSession(track)
}
}
// SetAudioOutputEnabled enables or disables audio output
func SetAudioOutputEnabled(enabled bool) error {
if audioOutputEnabled.Swap(enabled) == enabled {
return nil // Already in desired state
}
if enabled {
if activeConnections.Load() > 0 {
return startAudio()
}
} else {
audioMutex.Lock()
stopOutputLocked()
audioMutex.Unlock()
}
return nil
}
// SetAudioInputEnabled enables or disables audio input
func SetAudioInputEnabled(enabled bool) error {
if audioInputEnabled.Swap(enabled) == enabled {
return nil // Already in desired state
}
if enabled {
if activeConnections.Load() > 0 {
return startAudio()
}
} else {
audioMutex.Lock()
stopInputLocked()
audioMutex.Unlock()
}
return nil
}
// handleInputTrackForSession runs for the entire WebRTC session lifetime
// It continuously reads from the track and sends to whatever relay is currently active
func handleInputTrackForSession(track *webrtc.TrackRemote) {
defer inputTrackHandling.Store(false)
audioLogger.Debug().
Str("codec", track.Codec().MimeType).
Str("track_id", track.ID()).
Msg("starting session-lifetime track handler")
for {
// Read RTP packet (must always read to keep track alive)
rtpPacket, _, err := track.ReadRTP()
if err != nil {
if err == io.EOF {
audioLogger.Debug().Msg("audio track ended")
return
}
audioLogger.Warn().Err(err).Msg("failed to read RTP packet")
continue
}
// Extract Opus payload
opusData := rtpPacket.Payload
if len(opusData) == 0 {
continue
}
// Only send if input is enabled
if !audioInputEnabled.Load() {
continue // Drop frame but keep reading
}
// Get source in single mutex operation (hot path optimization)
audioMutex.Lock()
source := inputSource
audioMutex.Unlock()
if source == nil {
continue // No relay, drop frame but keep reading
}
if !source.IsConnected() {
if err := source.Connect(); err != nil {
continue
}
}
if err := source.WriteMessage(0, opusData); err != nil {
source.Disconnect()
}
}
}

View File

@ -494,7 +494,7 @@ func RunWebsocketClient() {
}
// If the network is not up, well, we can't connect to the cloud.
if !networkState.IsOnline() {
if !networkManager.IsOnline() {
cloudLogger.Warn().Msg("waiting for network to be online, will retry in 3 seconds")
time.Sleep(3 * time.Second)
continue

View File

@ -16,10 +16,10 @@ import (
)
const (
envChildID = "JETKVM_CHILD_ID"
errorDumpDir = "/userdata/jetkvm/"
errorDumpStateFile = ".has_error_dump"
errorDumpTemplate = "jetkvm-%s.log"
envChildID = "JETKVM_CHILD_ID"
errorDumpDir = "/userdata/jetkvm/crashdump"
errorDumpLastFile = "last-crash.log"
errorDumpTemplate = "jetkvm-%s.log"
)
func program() {
@ -117,30 +117,47 @@ func supervise() error {
return nil
}
func createErrorDump(logFile *os.File) {
logFile.Close()
// touch the error dump state file
if err := os.WriteFile(filepath.Join(errorDumpDir, errorDumpStateFile), []byte{}, 0644); err != nil {
return
}
fileName := fmt.Sprintf(errorDumpTemplate, time.Now().Format("20060102150405"))
filePath := filepath.Join(errorDumpDir, fileName)
if err := os.Rename(logFile.Name(), filePath); err == nil {
fmt.Printf("error dump created: %s\n", filePath)
return
}
fnSrc, err := os.Open(logFile.Name())
func isSymlinkTo(oldName, newName string) bool {
file, err := os.Stat(newName)
if err != nil {
return
return false
}
if file.Mode()&os.ModeSymlink != os.ModeSymlink {
return false
}
target, err := os.Readlink(newName)
if err != nil {
return false
}
return target == oldName
}
func ensureSymlink(oldName, newName string) error {
if isSymlinkTo(oldName, newName) {
return nil
}
_ = os.Remove(newName)
return os.Symlink(oldName, newName)
}
func renameFile(f *os.File, newName string) error {
_ = f.Close()
// try to rename the file first
if err := os.Rename(f.Name(), newName); err == nil {
return nil
}
// copy the log file to the error dump directory
fnSrc, err := os.Open(f.Name())
if err != nil {
return fmt.Errorf("failed to open file: %w", err)
}
defer fnSrc.Close()
fnDst, err := os.Create(filePath)
fnDst, err := os.Create(newName)
if err != nil {
return
return fmt.Errorf("failed to create file: %w", err)
}
defer fnDst.Close()
@ -148,18 +165,60 @@ func createErrorDump(logFile *os.File) {
for {
n, err := fnSrc.Read(buf)
if err != nil && err != io.EOF {
return
return fmt.Errorf("failed to read file: %w", err)
}
if n == 0 {
break
}
if _, err := fnDst.Write(buf[:n]); err != nil {
return
return fmt.Errorf("failed to write file: %w", err)
}
}
fmt.Printf("error dump created: %s\n", filePath)
return nil
}
func ensureErrorDumpDir() error {
// TODO: check if the directory is writable
f, err := os.Stat(errorDumpDir)
if err == nil && f.IsDir() {
return nil
}
if err := os.MkdirAll(errorDumpDir, 0755); err != nil {
return fmt.Errorf("failed to create error dump directory: %w", err)
}
return nil
}
func createErrorDump(logFile *os.File) {
fmt.Println()
fileName := fmt.Sprintf(
errorDumpTemplate,
time.Now().Format("20060102-150405"),
)
// check if the directory exists
if err := ensureErrorDumpDir(); err != nil {
fmt.Printf("failed to ensure error dump directory: %v\n", err)
return
}
filePath := filepath.Join(errorDumpDir, fileName)
if err := renameFile(logFile, filePath); err != nil {
fmt.Printf("failed to rename file: %v\n", err)
return
}
fmt.Printf("error dump copied: %s\n", filePath)
lastFilePath := filepath.Join(errorDumpDir, errorDumpLastFile)
if err := ensureSymlink(filePath, lastFilePath); err != nil {
fmt.Printf("failed to create symlink: %v\n", err)
return
}
}
func doSupervise() {

View File

@ -7,8 +7,9 @@ import (
"strconv"
"sync"
"github.com/jetkvm/kvm/internal/confparser"
"github.com/jetkvm/kvm/internal/logging"
"github.com/jetkvm/kvm/internal/network"
"github.com/jetkvm/kvm/internal/network/types"
"github.com/jetkvm/kvm/internal/usbgadget"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
@ -102,8 +103,9 @@ type Config struct {
TLSMode string `json:"tls_mode"` // options: "self-signed", "user-defined", ""
UsbConfig *usbgadget.Config `json:"usb_config"`
UsbDevices *usbgadget.Devices `json:"usb_devices"`
NetworkConfig *network.NetworkConfig `json:"network_config"`
NetworkConfig *types.NetworkConfig `json:"network_config"`
DefaultLogLevel string `json:"default_log_level"`
AudioOutputSource string `json:"audio_output_source"` // "hdmi" or "usb"
VideoSleepAfterSec int `json:"video_sleep_after_sec"`
}
@ -128,41 +130,57 @@ func (c *Config) SetDisplayRotation(rotation string) error {
const configPath = "/userdata/kvm_config.json"
var defaultConfig = &Config{
CloudURL: "https://api.jetkvm.com",
CloudAppURL: "https://app.jetkvm.com",
AutoUpdateEnabled: true, // Set a default value
ActiveExtension: "",
KeyboardMacros: []KeyboardMacro{},
DisplayRotation: "270",
KeyboardLayout: "en-US",
DisplayMaxBrightness: 64,
DisplayDimAfterSec: 120, // 2 minutes
DisplayOffAfterSec: 1800, // 30 minutes
JigglerEnabled: false,
// This is the "Standard" jiggler option in the UI
JigglerConfig: &JigglerConfig{
// it's a temporary solution to avoid sharing the same pointer
// we should migrate to a proper config solution in the future
var (
defaultJigglerConfig = JigglerConfig{
InactivityLimitSeconds: 60,
JitterPercentage: 25,
ScheduleCronTab: "0 * * * * *",
Timezone: "UTC",
},
TLSMode: "",
UsbConfig: &usbgadget.Config{
}
defaultUsbConfig = usbgadget.Config{
VendorId: "0x1d6b", //The Linux Foundation
ProductId: "0x0104", //Multifunction Composite Gadget
SerialNumber: "",
Manufacturer: "JetKVM",
Product: "USB Emulation Device",
},
UsbDevices: &usbgadget.Devices{
}
defaultUsbDevices = usbgadget.Devices{
AbsoluteMouse: true,
RelativeMouse: true,
Keyboard: true,
MassStorage: true,
},
NetworkConfig: &network.NetworkConfig{},
DefaultLogLevel: "INFO",
Audio: true,
}
)
func getDefaultConfig() Config {
return Config{
CloudURL: "https://api.jetkvm.com",
CloudAppURL: "https://app.jetkvm.com",
AutoUpdateEnabled: true, // Set a default value
ActiveExtension: "",
KeyboardMacros: []KeyboardMacro{},
DisplayRotation: "270",
KeyboardLayout: "en-US",
DisplayMaxBrightness: 64,
DisplayDimAfterSec: 120, // 2 minutes
DisplayOffAfterSec: 1800, // 30 minutes
JigglerEnabled: false,
// This is the "Standard" jiggler option in the UI
JigglerConfig: func() *JigglerConfig { c := defaultJigglerConfig; return &c }(),
TLSMode: "",
UsbConfig: func() *usbgadget.Config { c := defaultUsbConfig; return &c }(),
UsbDevices: func() *usbgadget.Devices { c := defaultUsbDevices; return &c }(),
NetworkConfig: func() *types.NetworkConfig {
c := &types.NetworkConfig{}
_ = confparser.SetDefaultsAndValidate(c)
return c
}(),
DefaultLogLevel: "INFO",
AudioOutputSource: "usb",
}
}
var (
@ -195,7 +213,8 @@ func LoadConfig() {
}
// load the default config
config = defaultConfig
defaultConfig := getDefaultConfig()
config = &defaultConfig
file, err := os.Open(configPath)
if err != nil {
@ -207,7 +226,7 @@ func LoadConfig() {
defer file.Close()
// load and merge the default config with the user config
loadedConfig := *defaultConfig
loadedConfig := defaultConfig
if err := json.NewDecoder(file).Decode(&loadedConfig); err != nil {
logger.Warn().Err(err).Msg("config file JSON parsing failed")
configSuccess.Set(0.0)
@ -216,19 +235,19 @@ func LoadConfig() {
// merge the user config with the default config
if loadedConfig.UsbConfig == nil {
loadedConfig.UsbConfig = defaultConfig.UsbConfig
loadedConfig.UsbConfig = getDefaultConfig().UsbConfig
}
if loadedConfig.UsbDevices == nil {
loadedConfig.UsbDevices = defaultConfig.UsbDevices
loadedConfig.UsbDevices = getDefaultConfig().UsbDevices
}
if loadedConfig.NetworkConfig == nil {
loadedConfig.NetworkConfig = defaultConfig.NetworkConfig
loadedConfig.NetworkConfig = getDefaultConfig().NetworkConfig
}
if loadedConfig.JigglerConfig == nil {
loadedConfig.JigglerConfig = defaultConfig.JigglerConfig
loadedConfig.JigglerConfig = getDefaultConfig().JigglerConfig
}
// fixup old keyboard layout value
@ -247,17 +266,25 @@ func LoadConfig() {
}
func SaveConfig() error {
return saveConfig(configPath)
}
func SaveBackupConfig() error {
return saveConfig(configPath + ".bak")
}
func saveConfig(path string) error {
configLock.Lock()
defer configLock.Unlock()
logger.Trace().Str("path", configPath).Msg("Saving config")
logger.Trace().Str("path", path).Msg("Saving config")
// fixup old keyboard layout value
if config.KeyboardLayout == "en_US" {
config.KeyboardLayout = "en-US"
}
file, err := os.Create(configPath)
file, err := os.Create(path)
if err != nil {
return fmt.Errorf("failed to create config file: %w", err)
}
@ -273,7 +300,7 @@ func SaveConfig() error {
return fmt.Errorf("failed to wite config: %w", err)
}
logger.Info().Str("path", configPath).Msg("config saved")
logger.Info().Str("path", path).Msg("config saved")
return nil
}

View File

@ -27,7 +27,12 @@ const (
)
func switchToMainScreen() {
if networkState.IsUp() {
if networkManager == nil {
nativeInstance.SwitchToScreenIfDifferent("no_network_screen")
return
}
if networkManager.IsUp() {
nativeInstance.SwitchToScreenIfDifferent("home_screen")
} else {
nativeInstance.SwitchToScreenIfDifferent("no_network_screen")
@ -35,13 +40,21 @@ func switchToMainScreen() {
}
func updateDisplay() {
nativeInstance.UpdateLabelIfChanged("home_info_ipv4_addr", networkState.IPv4String())
nativeInstance.UpdateLabelAndChangeVisibility("home_info_ipv6_addr", networkState.IPv6String())
if networkManager != nil {
nativeInstance.UpdateLabelIfChanged("home_info_ipv4_addr", networkManager.IPv4String())
nativeInstance.UpdateLabelAndChangeVisibility("home_info_ipv6_addr", networkManager.IPv6String())
nativeInstance.UpdateLabelIfChanged("home_info_mac_addr", networkManager.MACString())
}
_, _ = nativeInstance.UIObjHide("menu_btn_network")
_, _ = nativeInstance.UIObjHide("menu_btn_access")
nativeInstance.UpdateLabelIfChanged("home_info_mac_addr", networkState.MACString())
switch config.NetworkConfig.DHCPClient.String {
case "jetdhcpc":
nativeInstance.UpdateLabelIfChanged("dhcp_client_change_label", "Change to udhcpc")
case "udhcpc":
nativeInstance.UpdateLabelIfChanged("dhcp_client_change_label", "Change to JetKVM")
}
if usbState == "configured" {
nativeInstance.UpdateLabelIfChanged("usb_status_label", "Connected")
@ -59,7 +72,7 @@ func updateDisplay() {
}
nativeInstance.UpdateLabelIfChanged("cloud_status_label", fmt.Sprintf("%d active", actionSessions))
if networkState.IsUp() {
if networkManager != nil && networkManager.IsUp() {
nativeInstance.UISetVar("main_screen", "home_screen")
nativeInstance.SwitchToScreenIf("home_screen", []string{"no_network_screen", "boot_screen"})
} else {
@ -175,7 +188,7 @@ func requestDisplayUpdate(shouldWakeDisplay bool, reason string) {
wakeDisplay(false, reason)
}
displayLogger.Debug().Msg("display updating")
//TODO: only run once regardless how many pending updates
// TODO: only run once regardless how many pending updates
updateDisplay()
}()
}
@ -184,13 +197,14 @@ func waitCtrlAndRequestDisplayUpdate(shouldWakeDisplay bool, reason string) {
waitDisplayUpdate.Lock()
defer waitDisplayUpdate.Unlock()
// nativeInstance.WaitCtrlClientConnected()
requestDisplayUpdate(shouldWakeDisplay, reason)
}
func updateStaticContents() {
//contents that never change
nativeInstance.UpdateLabelIfChanged("home_info_mac_addr", networkState.MACString())
if networkManager != nil {
nativeInstance.UpdateLabelIfChanged("home_info_mac_addr", networkManager.MACString())
}
// get cpu info
if cpuInfo, err := os.ReadFile("/proc/cpuinfo"); err == nil {
@ -326,11 +340,8 @@ func startBacklightTickers() {
dimTicker = time.NewTicker(time.Duration(config.DisplayDimAfterSec) * time.Second)
go func() {
for { //nolint:staticcheck
select {
case <-dimTicker.C:
tick_displayDim()
}
for range dimTicker.C {
tick_displayDim()
}
}()
}
@ -340,11 +351,8 @@ func startBacklightTickers() {
offTicker = time.NewTicker(time.Duration(config.DisplayOffAfterSec) * time.Second)
go func() {
for { //nolint:staticcheck
select {
case <-offTicker.C:
tick_displayOff()
}
for range offTicker.C {
tick_displayOff()
}
}()
}

8
go.mod
View File

@ -16,6 +16,7 @@ require (
github.com/google/uuid v1.6.0
github.com/guregu/null/v6 v6.0.0
github.com/gwatts/rootcerts v0.0.0-20250901182336-dc5ae18bd79f
github.com/insomniacslk/dhcp v0.0.0-20250919081422-f80a1952f48e
github.com/pion/logging v0.2.4
github.com/pion/mdns/v2 v2.0.7
github.com/pion/webrtc/v4 v4.1.4
@ -54,15 +55,20 @@ require (
github.com/go-playground/validator/v10 v10.27.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/jonboulle/clockwork v0.5.0 // indirect
github.com/josharian/native v1.1.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mdlayher/ndp v1.1.0 // indirect
github.com/mdlayher/packet v1.1.2 // indirect
github.com/mdlayher/socket v0.4.1 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pierrec/lz4/v4 v4.1.14 // indirect
github.com/pilebones/go-udev v0.9.1 // indirect
github.com/pion/datachannel v1.5.10 // indirect
github.com/pion/dtls/v3 v3.0.7 // indirect
@ -82,12 +88,14 @@ require (
github.com/robfig/cron/v3 v3.0.1 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/u-root/uio v0.0.0-20230220225925-ffce2a382923 // indirect
github.com/ugorji/go/codec v1.3.0 // indirect
github.com/vishvananda/netns v0.0.5 // indirect
github.com/wlynxg/anet v0.0.5 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
golang.org/x/arch v0.20.0 // indirect
golang.org/x/oauth2 v0.30.0 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/text v0.29.0 // indirect
google.golang.org/protobuf v1.36.9 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect

22
go.sum
View File

@ -66,8 +66,15 @@ github.com/guregu/null/v6 v6.0.0 h1:N14VRS+4di81i1PXRiprbQJ9EM9gqBa0+KVMeS/QSjQ=
github.com/guregu/null/v6 v6.0.0/go.mod h1:hrMIhIfrOZeLPZhROSn149tpw2gHkidAqxoXNyeX3iQ=
github.com/gwatts/rootcerts v0.0.0-20250901182336-dc5ae18bd79f h1:08t2PbrkDgW2+mwCQ3jhKUBrCM9Bc9SeH5j2Dst3B+0=
github.com/gwatts/rootcerts v0.0.0-20250901182336-dc5ae18bd79f/go.mod h1:5Kt9XkWvkGi2OHOq0QsGxebHmhCcqJ8KCbNg/a6+n+g=
github.com/hugelgupf/socketpair v0.0.0-20190730060125-05d35a94e714 h1:/jC7qQFrv8CrSJVmaolDVOxTfS9kc36uB6H40kdbQq8=
github.com/hugelgupf/socketpair v0.0.0-20190730060125-05d35a94e714/go.mod h1:2Goc3h8EklBH5mspfHFxBnEoURQCGzQQH1ga9Myjvis=
github.com/insomniacslk/dhcp v0.0.0-20250919081422-f80a1952f48e h1:nu5z6Kg+gMNW6tdqnVjg/QEJ8Nw71IJQqOtWj00XHEU=
github.com/insomniacslk/dhcp v0.0.0-20250919081422-f80a1952f48e/go.mod h1:qfvBmyDNp+/liLEYWRvqny/PEz9hGe2Dz833eXILSmo=
github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I=
github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60=
github.com/josharian/native v1.0.1-0.20221213033349-c1e37c09b531/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w=
github.com/josharian/native v1.1.0 h1:uuaP0hAbW7Y4l0ZRQ6C9zfb7Mg1mbFKry/xzDAfmtLA=
github.com/josharian/native v1.1.0/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
@ -92,6 +99,12 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mdlayher/ndp v1.1.0 h1:QylGKGVtH60sKZUE88+IW5ila1Z/M9/OXhWdsVKuscs=
github.com/mdlayher/ndp v1.1.0/go.mod h1:FmgESgemgjl38vuOIyAHWUUL6vQKA/pQNkvXdWsdQFM=
github.com/mdlayher/packet v1.1.2 h1:3Up1NG6LZrsgDVn6X4L9Ge/iyRyxFEFD9o6Pr3Q1nQY=
github.com/mdlayher/packet v1.1.2/go.mod h1:GEu1+n9sG5VtiRE4SydOmX5GTwyyYlteZiFU+x0kew4=
github.com/mdlayher/socket v0.4.1 h1:eM9y2/jlbs1M615oshPQOHZzj6R6wMT7bX5NPiQvn2U=
github.com/mdlayher/socket v0.4.1/go.mod h1:cAqeGjoufqdxWkD7DkpyS+wcefOtmu5OQ8KuoJGIReA=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@ -101,6 +114,8 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pierrec/lz4/v4 v4.1.14 h1:+fL8AQEZtz/ijeNnpduH0bROTu0O3NZAlPjQxGn8LwE=
github.com/pierrec/lz4/v4 v4.1.14/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pilebones/go-udev v0.9.1 h1:uN72M1C1fgzhsVmBGEM8w9RD1JY4iVsPZpr+Z6rb3O8=
github.com/pilebones/go-udev v0.9.1/go.mod h1:Bgcl07crebF3JSeS4+nuaRvhWFdCeFoBhXXeAp93XNo=
github.com/pion/datachannel v1.5.10 h1:ly0Q26K1i6ZkGf42W7D4hQYR90pZwzFOjTq5AuCKk4o=
@ -161,6 +176,8 @@ github.com/sourcegraph/tf-dag v0.2.2-0.20250131204052-3e8ff1477b4f/go.mod h1:pzr
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
@ -169,6 +186,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/u-root/uio v0.0.0-20230220225925-ffce2a382923 h1:tHNk7XK9GkmKUR6Gh8gVBKXc2MVSZ4G/NnWLtzw4gNA=
github.com/u-root/uio v0.0.0-20230220225925-ffce2a382923/go.mod h1:eLL9Nub3yfAho7qB0MzZizFhTU2QkLeoVsWdHtDW264=
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/vearutop/statigz v1.5.0 h1:FuWwZiT82yBw4xbWdWIawiP2XFTyEPhIo8upRxiKLqk=
@ -193,6 +212,9 @@ golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I=
golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20220622161953-175b2fd9d664/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=

728
internal/audio/c/audio.c Normal file
View File

@ -0,0 +1,728 @@
/*
* JetKVM Audio Processing Module
*
* Bidirectional audio processing optimized for ARM NEON SIMD:
* TODO: Remove USB Gadget audio once new system image release is made available
* - OUTPUT PATH: TC358743 HDMI or USB Gadget audio Client speakers
* Pipeline: ALSA hw:0,0 or hw:1,0 capture Opus encode (128kbps, FEC enabled)
*
* - INPUT PATH: Client microphone Device speakers
* Pipeline: Opus decode (with FEC) ALSA hw:1,0 playback
*
* Key features:
* - ARM NEON SIMD optimization for all audio operations
* - Opus in-band FEC for packet loss resilience
* - S16_LE @ 48kHz stereo, 20ms frames (960 samples)
*/
#include <alsa/asoundlib.h>
#include <opus.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <errno.h>
#include <sched.h>
#include <time.h>
#include <signal.h>
// ARM NEON SIMD support (always available on JetKVM's ARM Cortex-A7)
#include <arm_neon.h>
// RV1106 (Cortex-A7) has 64-byte cache lines
#define CACHE_LINE_SIZE 64
#define SIMD_ALIGN __attribute__((aligned(16)))
#define CACHE_ALIGN __attribute__((aligned(CACHE_LINE_SIZE)))
#define SIMD_PREFETCH(addr, rw, locality) __builtin_prefetch(addr, rw, locality)
// Compile-time trace logging - disabled for production (zero overhead)
#define TRACE_LOG(...) ((void)0)
// ALSA device handles
static snd_pcm_t *pcm_capture_handle = NULL; // OUTPUT: TC358743 HDMI audio → client
static snd_pcm_t *pcm_playback_handle = NULL; // INPUT: Client microphone → device speakers
// ALSA device names
static const char *alsa_capture_device = NULL;
static const char *alsa_playback_device = NULL;
// Opus codec instances
static OpusEncoder *encoder = NULL;
static OpusDecoder *decoder = NULL;
// Audio format (S16_LE @ 48kHz stereo)
static uint32_t sample_rate = 48000;
static uint8_t channels = 2;
static uint16_t frame_size = 960; // 20ms frames at 48kHz
static uint32_t opus_bitrate = 128000;
static uint8_t opus_complexity = 5; // Higher complexity for better quality
static uint16_t max_packet_size = 1500;
// Opus encoder constants (hardcoded for production)
#define OPUS_VBR 1 // VBR enabled
#define OPUS_VBR_CONSTRAINT 1 // Constrained VBR (prevents bitrate starvation at low volumes)
#define OPUS_SIGNAL_TYPE 3002 // OPUS_SIGNAL_MUSIC (better transient handling)
#define OPUS_BANDWIDTH 1104 // OPUS_BANDWIDTH_SUPERWIDEBAND (16kHz)
#define OPUS_DTX 1 // DTX enabled (bandwidth optimization)
#define OPUS_LSB_DEPTH 16 // 16-bit depth
// ALSA retry configuration
static uint32_t sleep_microseconds = 1000;
static uint32_t sleep_milliseconds = 1;
static uint8_t max_attempts_global = 5;
static uint32_t max_backoff_us_global = 500000;
int jetkvm_audio_capture_init();
void jetkvm_audio_capture_close();
int jetkvm_audio_read_encode(void *opus_buf);
int jetkvm_audio_playback_init();
void jetkvm_audio_playback_close();
int jetkvm_audio_decode_write(void *opus_buf, int opus_size);
void update_audio_constants(uint32_t bitrate, uint8_t complexity,
uint32_t sr, uint8_t ch, uint16_t fs, uint16_t max_pkt,
uint32_t sleep_us, uint8_t max_attempts, uint32_t max_backoff);
void update_audio_decoder_constants(uint32_t sr, uint8_t ch, uint16_t fs, uint16_t max_pkt,
uint32_t sleep_us, uint8_t max_attempts, uint32_t max_backoff);
int update_opus_encoder_params(uint32_t bitrate, uint8_t complexity);
/**
* Sync encoder configuration from Go to C
*/
void update_audio_constants(uint32_t bitrate, uint8_t complexity,
uint32_t sr, uint8_t ch, uint16_t fs, uint16_t max_pkt,
uint32_t sleep_us, uint8_t max_attempts, uint32_t max_backoff) {
opus_bitrate = bitrate;
opus_complexity = complexity;
sample_rate = sr;
channels = ch;
frame_size = fs;
max_packet_size = max_pkt;
sleep_microseconds = sleep_us;
sleep_milliseconds = sleep_us / 1000; // Precompute for snd_pcm_wait
max_attempts_global = max_attempts;
max_backoff_us_global = max_backoff;
}
/**
* Sync decoder configuration from Go to C (no encoder-only params)
*/
void update_audio_decoder_constants(uint32_t sr, uint8_t ch, uint16_t fs, uint16_t max_pkt,
uint32_t sleep_us, uint8_t max_attempts, uint32_t max_backoff) {
sample_rate = sr;
channels = ch;
frame_size = fs;
max_packet_size = max_pkt;
sleep_microseconds = sleep_us;
sleep_milliseconds = sleep_us / 1000; // Precompute for snd_pcm_wait
max_attempts_global = max_attempts;
max_backoff_us_global = max_backoff;
}
/**
* Initialize ALSA device names from environment variables
* Must be called before jetkvm_audio_capture_init or jetkvm_audio_playback_init
*/
static void init_alsa_devices_from_env(void) {
// Always read from environment to support device switching
alsa_capture_device = getenv("ALSA_CAPTURE_DEVICE");
if (alsa_capture_device == NULL || alsa_capture_device[0] == '\0') {
alsa_capture_device = "hw:1,0"; // Default to USB gadget
}
alsa_playback_device = getenv("ALSA_PLAYBACK_DEVICE");
if (alsa_playback_device == NULL || alsa_playback_device[0] == '\0') {
alsa_playback_device = "hw:1,0"; // Default to USB gadget
}
}
// SIMD-OPTIMIZED BUFFER OPERATIONS (ARM NEON)
/**
* Clear audio buffer using NEON (16 samples/iteration with 2x unrolling)
*/
static inline void simd_clear_samples_s16(short * __restrict__ buffer, uint32_t samples) {
const int16x8_t zero = vdupq_n_s16(0);
uint32_t i = 0;
// Process 16 samples at a time (2x unrolled for better pipeline utilization)
uint32_t simd_samples = samples & ~15U;
for (; i < simd_samples; i += 16) {
vst1q_s16(&buffer[i], zero);
vst1q_s16(&buffer[i + 8], zero);
}
// Handle remaining 8 samples
if (i + 8 <= samples) {
vst1q_s16(&buffer[i], zero);
i += 8;
}
// Scalar: remaining samples
for (; i < samples; i++) {
buffer[i] = 0;
}
}
// INITIALIZATION STATE TRACKING
static volatile sig_atomic_t capture_initializing = 0;
static volatile sig_atomic_t capture_initialized = 0;
static volatile sig_atomic_t playback_initializing = 0;
static volatile sig_atomic_t playback_initialized = 0;
/**
* Update Opus encoder settings at runtime (does NOT modify FEC or hardcoded settings)
*
* NOTE: Currently unused but kept for potential future runtime configuration updates.
* In the current CGO implementation, encoder params are set once via update_audio_constants()
* before initialization. This function would be useful if we add runtime bitrate/complexity
* adjustment without restarting the encoder.
*
* @return 0 on success, -1 if not initialized, >0 if some settings failed
*/
int update_opus_encoder_params(uint32_t bitrate, uint8_t complexity) {
if (!encoder || !capture_initialized) {
return -1;
}
// Update runtime-configurable parameters
opus_bitrate = bitrate;
opus_complexity = complexity;
// Apply settings to encoder
int result = 0;
result |= opus_encoder_ctl(encoder, OPUS_SET_BITRATE(opus_bitrate));
result |= opus_encoder_ctl(encoder, OPUS_SET_COMPLEXITY(opus_complexity));
return result;
}
// ALSA UTILITY FUNCTIONS
/**
* Open ALSA device with exponential backoff retry
* @return 0 on success, negative error code on failure
*/
// Helper: High-precision sleep using nanosleep (better than usleep)
static inline void precise_sleep_us(uint32_t microseconds) {
struct timespec ts = {
.tv_sec = microseconds / 1000000,
.tv_nsec = (microseconds % 1000000) * 1000
};
nanosleep(&ts, NULL);
}
static int safe_alsa_open(snd_pcm_t **handle, const char *device, snd_pcm_stream_t stream) {
uint8_t attempt = 0;
int err;
uint32_t backoff_us = sleep_microseconds;
while (attempt < max_attempts_global) {
err = snd_pcm_open(handle, device, stream, SND_PCM_NONBLOCK);
if (err >= 0) {
snd_pcm_nonblock(*handle, 0);
return 0;
}
attempt++;
// Exponential backoff with bit shift (faster than multiplication)
if (err == -EBUSY || err == -EAGAIN) {
precise_sleep_us(backoff_us);
backoff_us = (backoff_us << 1 < max_backoff_us_global) ? (backoff_us << 1) : max_backoff_us_global;
} else if (err == -ENODEV || err == -ENOENT) {
precise_sleep_us(backoff_us << 1);
backoff_us = (backoff_us << 1 < max_backoff_us_global) ? (backoff_us << 1) : max_backoff_us_global;
} else if (err == -EPERM || err == -EACCES) {
precise_sleep_us(backoff_us >> 1);
} else {
precise_sleep_us(backoff_us);
backoff_us = (backoff_us << 1 < max_backoff_us_global) ? (backoff_us << 1) : max_backoff_us_global;
}
}
return err;
}
/**
* Configure ALSA device (S16_LE @ 48kHz stereo with optimized buffering)
* @param handle ALSA PCM handle
* @param device_name Unused (for debugging only)
* @return 0 on success, negative error code on failure
*/
static int configure_alsa_device(snd_pcm_t *handle, const char *device_name) {
snd_pcm_hw_params_t *params;
snd_pcm_sw_params_t *sw_params;
int err;
if (!handle) return -1;
snd_pcm_hw_params_alloca(&params);
snd_pcm_sw_params_alloca(&sw_params);
err = snd_pcm_hw_params_any(handle, params);
if (err < 0) return err;
err = snd_pcm_hw_params_set_access(handle, params, SND_PCM_ACCESS_RW_INTERLEAVED);
if (err < 0) return err;
err = snd_pcm_hw_params_set_format(handle, params, SND_PCM_FORMAT_S16_LE);
if (err < 0) return err;
err = snd_pcm_hw_params_set_channels(handle, params, channels);
if (err < 0) return err;
err = snd_pcm_hw_params_set_rate(handle, params, sample_rate, 0);
if (err < 0) {
unsigned int rate = sample_rate;
err = snd_pcm_hw_params_set_rate_near(handle, params, &rate, 0);
if (err < 0) return err;
}
snd_pcm_uframes_t period_size = frame_size; // Optimized: use full frame as period
if (period_size < 64) period_size = 64;
err = snd_pcm_hw_params_set_period_size_near(handle, params, &period_size, 0);
if (err < 0) return err;
snd_pcm_uframes_t buffer_size = period_size * 4; // 4 periods = 80ms buffer for stability
err = snd_pcm_hw_params_set_buffer_size_near(handle, params, &buffer_size);
if (err < 0) return err;
err = snd_pcm_hw_params(handle, params);
if (err < 0) return err;
err = snd_pcm_sw_params_current(handle, sw_params);
if (err < 0) return err;
err = snd_pcm_sw_params_set_start_threshold(handle, sw_params, period_size);
if (err < 0) return err;
err = snd_pcm_sw_params_set_avail_min(handle, sw_params, period_size);
if (err < 0) return err;
err = snd_pcm_sw_params(handle, sw_params);
if (err < 0) return err;
return snd_pcm_prepare(handle);
}
// AUDIO OUTPUT PATH FUNCTIONS (TC358743 HDMI Audio → Client Speakers)
/**
* Initialize OUTPUT path (TC358743 HDMI capture Opus encoder)
* Opens hw:0,0 (TC358743) and creates Opus encoder with optimized settings
* @return 0 on success, -EBUSY if initializing, -1/-2/-3 on errors
*/
int jetkvm_audio_capture_init() {
int err;
init_alsa_devices_from_env();
if (__sync_bool_compare_and_swap(&capture_initializing, 0, 1) == 0) {
return -EBUSY;
}
if (capture_initialized) {
capture_initializing = 0;
return 0;
}
if (encoder) {
opus_encoder_destroy(encoder);
encoder = NULL;
}
if (pcm_capture_handle) {
snd_pcm_close(pcm_capture_handle);
pcm_capture_handle = NULL;
}
err = safe_alsa_open(&pcm_capture_handle, alsa_capture_device, SND_PCM_STREAM_CAPTURE);
if (err < 0) {
fprintf(stderr, "Failed to open ALSA capture device %s: %s\n",
alsa_capture_device, snd_strerror(err));
fflush(stderr);
capture_initializing = 0;
return -1;
}
err = configure_alsa_device(pcm_capture_handle, "capture");
if (err < 0) {
snd_pcm_close(pcm_capture_handle);
pcm_capture_handle = NULL;
capture_initializing = 0;
return -2;
}
int opus_err = 0;
encoder = opus_encoder_create(sample_rate, channels, OPUS_APPLICATION_AUDIO, &opus_err);
if (!encoder || opus_err != OPUS_OK) {
if (pcm_capture_handle) {
snd_pcm_close(pcm_capture_handle);
pcm_capture_handle = NULL;
}
capture_initializing = 0;
return -3;
}
// Configure encoder with optimized settings
opus_encoder_ctl(encoder, OPUS_SET_BITRATE(opus_bitrate));
opus_encoder_ctl(encoder, OPUS_SET_COMPLEXITY(opus_complexity));
opus_encoder_ctl(encoder, OPUS_SET_VBR(OPUS_VBR));
opus_encoder_ctl(encoder, OPUS_SET_VBR_CONSTRAINT(OPUS_VBR_CONSTRAINT));
opus_encoder_ctl(encoder, OPUS_SET_SIGNAL(OPUS_SIGNAL_TYPE));
opus_encoder_ctl(encoder, OPUS_SET_BANDWIDTH(OPUS_BANDWIDTH));
opus_encoder_ctl(encoder, OPUS_SET_DTX(OPUS_DTX));
opus_encoder_ctl(encoder, OPUS_SET_LSB_DEPTH(OPUS_LSB_DEPTH));
opus_encoder_ctl(encoder, OPUS_SET_INBAND_FEC(1));
opus_encoder_ctl(encoder, OPUS_SET_PACKET_LOSS_PERC(20));
capture_initialized = 1;
capture_initializing = 0;
return 0;
}
/**
* Read HDMI audio, encode to Opus (OUTPUT path hot function)
* @param opus_buf Output buffer for encoded Opus packet
* @return >0 = Opus packet size in bytes, -1 = error
*/
__attribute__((hot)) int jetkvm_audio_read_encode(void * __restrict__ opus_buf) {
static short CACHE_ALIGN pcm_buffer[960 * 2]; // Cache-aligned
unsigned char * __restrict__ out = (unsigned char*)opus_buf;
int32_t pcm_rc, nb_bytes;
int32_t err = 0;
uint8_t recovery_attempts = 0;
const uint8_t max_recovery_attempts = 3;
// Prefetch for write (out) and read (pcm_buffer) - RV1106 has small L1 cache
SIMD_PREFETCH(out, 1, 0); // Write, immediate use
SIMD_PREFETCH(pcm_buffer, 0, 0); // Read, immediate use
SIMD_PREFETCH(pcm_buffer + 64, 0, 1); // Prefetch next cache line
if (__builtin_expect(!capture_initialized || !pcm_capture_handle || !encoder || !opus_buf, 0)) {
TRACE_LOG("[AUDIO_OUTPUT] jetkvm_audio_read_encode: Failed safety checks - capture_initialized=%d, pcm_capture_handle=%p, encoder=%p, opus_buf=%p\n",
capture_initialized, pcm_capture_handle, encoder, opus_buf);
return -1;
}
retry_read:
// Read 960 frames (20ms) from ALSA capture device
pcm_rc = snd_pcm_readi(pcm_capture_handle, pcm_buffer, frame_size);
if (__builtin_expect(pcm_rc < 0, 0)) {
if (pcm_rc == -EPIPE) {
recovery_attempts++;
if (recovery_attempts > max_recovery_attempts) {
return -1;
}
err = snd_pcm_prepare(pcm_capture_handle);
if (err < 0) {
snd_pcm_drop(pcm_capture_handle);
err = snd_pcm_prepare(pcm_capture_handle);
if (err < 0) return -1;
}
goto retry_read;
} else if (pcm_rc == -EAGAIN) {
// Wait for data to be available
snd_pcm_wait(pcm_capture_handle, sleep_milliseconds);
goto retry_read;
} else if (pcm_rc == -ESTRPIPE) {
recovery_attempts++;
if (recovery_attempts > max_recovery_attempts) {
return -1;
}
uint8_t resume_attempts = 0;
while ((err = snd_pcm_resume(pcm_capture_handle)) == -EAGAIN && resume_attempts < 10) {
snd_pcm_wait(pcm_capture_handle, sleep_milliseconds);
resume_attempts++;
}
if (err < 0) {
err = snd_pcm_prepare(pcm_capture_handle);
if (err < 0) return -1;
}
return 0;
} else if (pcm_rc == -ENODEV) {
return -1;
} else if (pcm_rc == -EIO) {
recovery_attempts++;
if (recovery_attempts <= max_recovery_attempts) {
snd_pcm_drop(pcm_capture_handle);
err = snd_pcm_prepare(pcm_capture_handle);
if (err >= 0) {
goto retry_read;
}
}
return -1;
} else {
recovery_attempts++;
if (recovery_attempts <= 1 && pcm_rc == -EINTR) {
goto retry_read;
} else if (recovery_attempts <= 1 && pcm_rc == -EBUSY) {
snd_pcm_wait(pcm_capture_handle, 1); // Wait 1ms for device
goto retry_read;
}
return -1;
}
}
// Zero-pad if we got a short read
if (__builtin_expect(pcm_rc < frame_size, 0)) {
uint32_t remaining_samples = (frame_size - pcm_rc) * channels;
simd_clear_samples_s16(&pcm_buffer[pcm_rc * channels], remaining_samples);
}
nb_bytes = opus_encode(encoder, pcm_buffer, frame_size, out, max_packet_size);
return nb_bytes;
}
// AUDIO INPUT PATH FUNCTIONS (Client Microphone → Device Speakers)
/**
* Initialize INPUT path (Opus decoder device speakers)
* Opens hw:1,0 (USB gadget) or "default" and creates Opus decoder
* @return 0 on success, -EBUSY if initializing, -1/-2 on errors
*/
int jetkvm_audio_playback_init() {
int err;
init_alsa_devices_from_env();
if (__sync_bool_compare_and_swap(&playback_initializing, 0, 1) == 0) {
return -EBUSY;
}
if (playback_initialized) {
playback_initializing = 0;
return 0;
}
if (decoder) {
opus_decoder_destroy(decoder);
decoder = NULL;
}
if (pcm_playback_handle) {
snd_pcm_close(pcm_playback_handle);
pcm_playback_handle = NULL;
}
err = safe_alsa_open(&pcm_playback_handle, alsa_playback_device, SND_PCM_STREAM_PLAYBACK);
if (err < 0) {
fprintf(stderr, "Failed to open ALSA playback device %s: %s\n",
alsa_playback_device, snd_strerror(err));
fflush(stderr);
err = safe_alsa_open(&pcm_playback_handle, "default", SND_PCM_STREAM_PLAYBACK);
if (err < 0) {
playback_initializing = 0;
return -1;
}
}
err = configure_alsa_device(pcm_playback_handle, "playback");
if (err < 0) {
snd_pcm_close(pcm_playback_handle);
pcm_playback_handle = NULL;
playback_initializing = 0;
return -1;
}
int opus_err = 0;
decoder = opus_decoder_create(sample_rate, channels, &opus_err);
if (!decoder || opus_err != OPUS_OK) {
snd_pcm_close(pcm_playback_handle);
pcm_playback_handle = NULL;
playback_initializing = 0;
return -2;
}
playback_initialized = 1;
playback_initializing = 0;
return 0;
}
/**
* Decode Opus, write to device speakers (INPUT path hot function)
* Processing pipeline: Opus decode (with FEC) ALSA playback with error recovery
* @param opus_buf Encoded Opus packet from client
* @param opus_size Size of Opus packet in bytes
* @return >0 = PCM frames written, 0 = frame skipped, -1/-2 = error
*/
__attribute__((hot)) int jetkvm_audio_decode_write(void * __restrict__ opus_buf, int32_t opus_size) {
static short CACHE_ALIGN pcm_buffer[960 * 2]; // Cache-aligned
unsigned char * __restrict__ in = (unsigned char*)opus_buf;
int32_t pcm_frames, pcm_rc, err = 0;
uint8_t recovery_attempts = 0;
const uint8_t max_recovery_attempts = 3;
// Prefetch input buffer - locality 0 for immediate use
SIMD_PREFETCH(in, 0, 0);
if (__builtin_expect(!playback_initialized || !pcm_playback_handle || !decoder || !opus_buf || opus_size <= 0, 0)) {
TRACE_LOG("[AUDIO_INPUT] jetkvm_audio_decode_write: Failed safety checks - playback_initialized=%d, pcm_playback_handle=%p, decoder=%p, opus_buf=%p, opus_size=%d\n",
playback_initialized, pcm_playback_handle, decoder, opus_buf, opus_size);
return -1;
}
if (opus_size > max_packet_size) {
TRACE_LOG("[AUDIO_INPUT] jetkvm_audio_decode_write: Opus packet too large - size=%d, max=%d\n", opus_size, max_packet_size);
return -1;
}
TRACE_LOG("[AUDIO_INPUT] jetkvm_audio_decode_write: Processing Opus packet - size=%d bytes\n", opus_size);
// Decode Opus packet to PCM (FEC automatically applied if embedded in packet)
// decode_fec=0 means normal decode (FEC data is used automatically when present)
pcm_frames = opus_decode(decoder, in, opus_size, pcm_buffer, frame_size, 0);
if (__builtin_expect(pcm_frames < 0, 0)) {
// Decode failed - attempt packet loss concealment using FEC from previous packet
TRACE_LOG("[AUDIO_INPUT] jetkvm_audio_decode_write: Opus decode failed with error %d, attempting packet loss concealment\n", pcm_frames);
// decode_fec=1 means use FEC data from the NEXT packet to reconstruct THIS lost packet
pcm_frames = opus_decode(decoder, NULL, 0, pcm_buffer, frame_size, 1);
if (pcm_frames < 0) {
TRACE_LOG("[AUDIO_INPUT] jetkvm_audio_decode_write: Packet loss concealment also failed with error %d\n", pcm_frames);
return -1;
}
TRACE_LOG("[AUDIO_INPUT] jetkvm_audio_decode_write: Packet loss concealment succeeded, recovered %d frames\n", pcm_frames);
} else
TRACE_LOG("[AUDIO_INPUT] jetkvm_audio_decode_write: Opus decode successful - decoded %d PCM frames\n", pcm_frames);
retry_write:
// Write decoded PCM to ALSA playback device
pcm_rc = snd_pcm_writei(pcm_playback_handle, pcm_buffer, pcm_frames);
if (__builtin_expect(pcm_rc < 0, 0)) {
TRACE_LOG("[AUDIO_INPUT] jetkvm_audio_decode_write: ALSA write failed with error %d (%s), attempt %d/%d\n",
pcm_rc, snd_strerror(pcm_rc), recovery_attempts + 1, max_recovery_attempts);
if (pcm_rc == -EPIPE) {
recovery_attempts++;
if (recovery_attempts > max_recovery_attempts) {
TRACE_LOG("[AUDIO_INPUT] jetkvm_audio_decode_write: Buffer underrun recovery failed after %d attempts\n", max_recovery_attempts);
return -2;
}
TRACE_LOG("[AUDIO_INPUT] jetkvm_audio_decode_write: Buffer underrun detected, attempting recovery (attempt %d)\n", recovery_attempts);
err = snd_pcm_prepare(pcm_playback_handle);
if (err < 0) {
TRACE_LOG("[AUDIO_INPUT] jetkvm_audio_decode_write: snd_pcm_prepare failed (%s), trying drop+prepare\n", snd_strerror(err));
snd_pcm_drop(pcm_playback_handle);
err = snd_pcm_prepare(pcm_playback_handle);
if (err < 0) {
TRACE_LOG("[AUDIO_INPUT] jetkvm_audio_decode_write: drop+prepare recovery failed (%s)\n", snd_strerror(err));
return -2;
}
}
TRACE_LOG("[AUDIO_INPUT] jetkvm_audio_decode_write: Buffer underrun recovery successful, retrying write\n");
goto retry_write;
} else if (pcm_rc == -ESTRPIPE) {
recovery_attempts++;
if (recovery_attempts > max_recovery_attempts) {
TRACE_LOG("[AUDIO_INPUT] jetkvm_audio_decode_write: Device suspend recovery failed after %d attempts\n", max_recovery_attempts);
return -2;
}
TRACE_LOG("[AUDIO_INPUT] jetkvm_audio_decode_write: Device suspended, attempting resume (attempt %d)\n", recovery_attempts);
uint8_t resume_attempts = 0;
while ((err = snd_pcm_resume(pcm_playback_handle)) == -EAGAIN && resume_attempts < 10) {
snd_pcm_wait(pcm_playback_handle, sleep_milliseconds);
resume_attempts++;
}
if (err < 0) {
TRACE_LOG("[AUDIO_INPUT] jetkvm_audio_decode_write: Device resume failed (%s), trying prepare fallback\n", snd_strerror(err));
err = snd_pcm_prepare(pcm_playback_handle);
if (err < 0) {
TRACE_LOG("[AUDIO_INPUT] jetkvm_audio_decode_write: Prepare fallback failed (%s)\n", snd_strerror(err));
return -2;
}
}
TRACE_LOG("[AUDIO_INPUT] jetkvm_audio_decode_write: Device suspend recovery successful, skipping frame\n");
return 0;
} else if (pcm_rc == -ENODEV) {
TRACE_LOG("[AUDIO_INPUT] jetkvm_audio_decode_write: Device disconnected (ENODEV) - critical error\n");
return -2;
} else if (pcm_rc == -EIO) {
recovery_attempts++;
if (recovery_attempts <= max_recovery_attempts) {
TRACE_LOG("[AUDIO_INPUT] jetkvm_audio_decode_write: I/O error detected, attempting recovery\n");
snd_pcm_drop(pcm_playback_handle);
err = snd_pcm_prepare(pcm_playback_handle);
if (err >= 0) {
TRACE_LOG("[AUDIO_INPUT] jetkvm_audio_decode_write: I/O error recovery successful, retrying write\n");
goto retry_write;
}
TRACE_LOG("[AUDIO_INPUT] jetkvm_audio_decode_write: I/O error recovery failed (%s)\n", snd_strerror(err));
}
return -2;
} else if (pcm_rc == -EAGAIN) {
recovery_attempts++;
if (recovery_attempts <= max_recovery_attempts) {
TRACE_LOG("[AUDIO_INPUT] jetkvm_audio_decode_write: Device not ready (EAGAIN), waiting and retrying\n");
snd_pcm_wait(pcm_playback_handle, 1); // Wait 1ms
goto retry_write;
}
TRACE_LOG("[AUDIO_INPUT] jetkvm_audio_decode_write: Device not ready recovery failed after %d attempts\n", max_recovery_attempts);
return -2;
} else {
recovery_attempts++;
if (recovery_attempts <= 1 && (pcm_rc == -EINTR || pcm_rc == -EBUSY)) {
TRACE_LOG("[AUDIO_INPUT] jetkvm_audio_decode_write: Transient error %d (%s), retrying once\n", pcm_rc, snd_strerror(pcm_rc));
snd_pcm_wait(pcm_playback_handle, 1); // Wait 1ms
goto retry_write;
}
TRACE_LOG("[AUDIO_INPUT] jetkvm_audio_decode_write: Unrecoverable error %d (%s)\n", pcm_rc, snd_strerror(pcm_rc));
return -2;
}
}
TRACE_LOG("[AUDIO_INPUT] jetkvm_audio_decode_write: Successfully wrote %d PCM frames to device\n", pcm_frames);
return pcm_frames;
}
// CLEANUP FUNCTIONS
/**
* Close INPUT path (thread-safe with drain)
*/
void jetkvm_audio_playback_close() {
while (playback_initializing) {
sched_yield();
}
if (__sync_bool_compare_and_swap(&playback_initialized, 1, 0) == 0) {
return;
}
if (decoder) {
opus_decoder_destroy(decoder);
decoder = NULL;
}
if (pcm_playback_handle) {
snd_pcm_drain(pcm_playback_handle);
snd_pcm_close(pcm_playback_handle);
pcm_playback_handle = NULL;
}
}
/**
* Close OUTPUT path (thread-safe with drain)
*/
void jetkvm_audio_capture_close() {
while (capture_initializing) {
sched_yield();
}
if (__sync_bool_compare_and_swap(&capture_initialized, 1, 0) == 0) {
return;
}
if (encoder) {
opus_encoder_destroy(encoder);
encoder = NULL;
}
if (pcm_capture_handle) {
snd_pcm_drain(pcm_capture_handle);
snd_pcm_close(pcm_capture_handle);
pcm_capture_handle = NULL;
}
}

View File

@ -0,0 +1,101 @@
/*
* JetKVM Audio Common Utilities
*
* Shared functions for audio processing
*/
#include "audio_common.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <signal.h>
#include <unistd.h>
#include <time.h>
// GLOBAL STATE FOR SIGNAL HANDLER
// Pointer to the running flag that will be set to 0 on shutdown
static volatile sig_atomic_t *g_running_ptr = NULL;
// SIGNAL HANDLERS
static void signal_handler(int signo) {
if (signo == SIGTERM || signo == SIGINT) {
printf("Audio server: Received signal %d, shutting down...\n", signo);
if (g_running_ptr != NULL) {
*g_running_ptr = 0;
}
}
}
void audio_common_setup_signal_handlers(volatile sig_atomic_t *running) {
g_running_ptr = running;
struct sigaction sa;
memset(&sa, 0, sizeof(sa));
sa.sa_handler = signal_handler;
sigemptyset(&sa.sa_mask);
sa.sa_flags = 0;
sigaction(SIGTERM, &sa, NULL);
sigaction(SIGINT, &sa, NULL);
// Ignore SIGPIPE (write to closed socket should return error, not crash)
signal(SIGPIPE, SIG_IGN);
}
int32_t audio_common_parse_env_int(const char *name, int32_t default_value) {
const char *str = getenv(name);
if (str == NULL || str[0] == '\0') {
return default_value;
}
return (int32_t)atoi(str);
}
const char* audio_common_parse_env_string(const char *name, const char *default_value) {
const char *str = getenv(name);
if (str == NULL || str[0] == '\0') {
return default_value;
}
return str;
}
// COMMON CONFIGURATION
void audio_common_load_config(audio_config_t *config, int is_output) {
// ALSA device configuration
if (is_output) {
config->alsa_device = audio_common_parse_env_string("ALSA_CAPTURE_DEVICE", "hw:1,0");
} else {
config->alsa_device = audio_common_parse_env_string("ALSA_PLAYBACK_DEVICE", "hw:1,0");
}
// Common Opus configuration
config->opus_bitrate = audio_common_parse_env_int("OPUS_BITRATE", 128000);
config->opus_complexity = audio_common_parse_env_int("OPUS_COMPLEXITY", 2);
// Audio format
config->sample_rate = audio_common_parse_env_int("AUDIO_SAMPLE_RATE", 48000);
config->channels = audio_common_parse_env_int("AUDIO_CHANNELS", 2);
config->frame_size = audio_common_parse_env_int("AUDIO_FRAME_SIZE", 960);
// Log configuration
printf("Audio %s Server Configuration:\n", is_output ? "Output" : "Input");
printf(" ALSA Device: %s\n", config->alsa_device);
printf(" Sample Rate: %d Hz\n", config->sample_rate);
printf(" Channels: %d\n", config->channels);
printf(" Frame Size: %d samples\n", config->frame_size);
if (is_output) {
printf(" Opus Bitrate: %d bps\n", config->opus_bitrate);
printf(" Opus Complexity: %d\n", config->opus_complexity);
}
}
void audio_common_print_startup(const char *server_name) {
printf("JetKVM %s Starting...\n", server_name);
}
void audio_common_print_shutdown(const char *server_name) {
printf("Shutting down %s...\n", server_name);
}

View File

@ -0,0 +1,135 @@
/*
* JetKVM Audio Common Utilities
*
* Shared functions used by both audio input and output servers
*/
#ifndef JETKVM_AUDIO_COMMON_H
#define JETKVM_AUDIO_COMMON_H
#include <signal.h>
#include <stdint.h>
// SHARED CONSTANTS
// Audio processing parameters
#define AUDIO_MAX_PACKET_SIZE 1500 // Maximum Opus packet size
#define AUDIO_SLEEP_MICROSECONDS 1000 // Default sleep time in microseconds
#define AUDIO_MAX_ATTEMPTS 5 // Maximum retry attempts
#define AUDIO_MAX_BACKOFF_US 500000 // Maximum backoff in microseconds
// Error handling
#define AUDIO_MAX_CONSECUTIVE_ERRORS 10 // Maximum consecutive errors before giving up
// Performance monitoring
#define AUDIO_TRACE_MASK 0x3FF // Log every 1024th frame (bit mask for efficiency)
// SIGNAL HANDLERS
/**
* Setup signal handlers for graceful shutdown.
* Handles SIGTERM and SIGINT by setting the running flag to 0.
* Ignores SIGPIPE to prevent crashes on broken pipe writes.
*
* @param running Pointer to the volatile running flag to set on shutdown
*/
void audio_common_setup_signal_handlers(volatile sig_atomic_t *running);
/**
* Parse integer from environment variable.
* Returns default_value if variable is not set or empty.
*
* @param name Environment variable name
* @param default_value Default value if not set
* @return Parsed integer value or default
*/
int32_t audio_common_parse_env_int(const char *name, int32_t default_value);
/**
* Parse string from environment variable.
* Returns default_value if variable is not set or empty.
*
* @param name Environment variable name
* @param default_value Default value if not set
* @return Environment variable value or default (not duplicated)
*/
const char* audio_common_parse_env_string(const char *name, const char *default_value);
// COMMON CONFIGURATION
/**
* Common audio configuration structure
*/
typedef struct {
const char *alsa_device; // ALSA device path
int opus_bitrate; // Opus bitrate
int opus_complexity; // Opus complexity
int sample_rate; // Sample rate
int channels; // Number of channels
int frame_size; // Frame size in samples
} audio_config_t;
/**
* Load common audio configuration from environment
* @param config Output configuration
* @param is_output true for output server, false for input
*/
void audio_common_load_config(audio_config_t *config, int is_output);
/**
* Print server startup message
* @param server_name Name of the server (e.g., "Audio Output Server")
*/
void audio_common_print_startup(const char *server_name);
/**
* Print server shutdown message
* @param server_name Name of the server
*/
void audio_common_print_shutdown(const char *server_name);
// ERROR TRACKING
/**
* Error tracking state for audio processing loops
*/
typedef struct {
uint8_t consecutive_errors; // Current consecutive error count
uint32_t frame_count; // Total frames processed
} audio_error_tracker_t;
/**
* Initialize error tracker
*/
static inline void audio_error_tracker_init(audio_error_tracker_t *tracker) {
tracker->consecutive_errors = 0;
tracker->frame_count = 0;
}
/**
* Record an error and check if we should give up
* Returns 1 if too many errors, 0 to continue
*/
static inline uint8_t audio_error_tracker_record_error(audio_error_tracker_t *tracker) {
tracker->consecutive_errors++;
return (tracker->consecutive_errors >= AUDIO_MAX_CONSECUTIVE_ERRORS) ? 1 : 0;
}
/**
* Record success and increment frame count
*/
static inline void audio_error_tracker_record_success(audio_error_tracker_t *tracker) {
tracker->consecutive_errors = 0;
tracker->frame_count++;
}
/**
* Check if we should log trace info for this frame
*/
static inline uint8_t audio_error_tracker_should_trace(audio_error_tracker_t *tracker) {
return ((tracker->frame_count & AUDIO_TRACE_MASK) == 1) ? 1 : 0;
}
#endif // JETKVM_AUDIO_COMMON_H

View File

@ -0,0 +1,213 @@
//go:build linux && (arm || arm64)
package audio
/*
#cgo CFLAGS: -O3 -ffast-math -I/opt/jetkvm-audio-libs/alsa-lib-1.2.14/include -I/opt/jetkvm-audio-libs/opus-1.5.2/include
#cgo LDFLAGS: /opt/jetkvm-audio-libs/alsa-lib-1.2.14/src/.libs/libasound.a /opt/jetkvm-audio-libs/opus-1.5.2/.libs/libopus.a -lm -ldl -lpthread
#include <stdlib.h>
#include "c/audio.c"
*/
import "C"
import (
"fmt"
"os"
"sync"
"unsafe"
"github.com/jetkvm/kvm/internal/logging"
"github.com/rs/zerolog"
)
const (
ipcMaxFrameSize = 1024 // Max Opus frame size: 128kbps @ 20ms = ~600 bytes
ipcMsgTypeOpus = 0 // Message type for Opus audio data
)
// CgoSource implements AudioSource via direct CGO calls to C audio functions (in-process)
type CgoSource struct {
direction string // "output" or "input"
alsaDevice string
initialized bool
connected bool
mu sync.Mutex
logger zerolog.Logger
opusBuf []byte // Reusable buffer for Opus packets
}
// NewCgoOutputSource creates a new CGO audio source for output (HDMI/USB → browser)
func NewCgoOutputSource(alsaDevice string) *CgoSource {
logger := logging.GetDefaultLogger().With().Str("component", "audio-output-cgo").Logger()
return &CgoSource{
direction: "output",
alsaDevice: alsaDevice,
logger: logger,
opusBuf: make([]byte, ipcMaxFrameSize),
}
}
// NewCgoInputSource creates a new CGO audio source for input (browser → USB speakers)
func NewCgoInputSource(alsaDevice string) *CgoSource {
logger := logging.GetDefaultLogger().With().Str("component", "audio-input-cgo").Logger()
return &CgoSource{
direction: "input",
alsaDevice: alsaDevice,
logger: logger,
opusBuf: make([]byte, ipcMaxFrameSize),
}
}
// Connect initializes the C audio subsystem
func (c *CgoSource) Connect() error {
c.mu.Lock()
defer c.mu.Unlock()
if c.connected {
return nil
}
// Set ALSA device via environment for C code to read via init_alsa_devices_from_env()
if c.direction == "output" {
// Set capture device for output path via environment variable
os.Setenv("ALSA_CAPTURE_DEVICE", c.alsaDevice)
// Initialize constants
C.update_audio_constants(
C.uint(128000), // bitrate
C.uchar(5), // complexity
C.uint(48000), // sample_rate
C.uchar(2), // channels
C.ushort(960), // frame_size
C.ushort(1500), // max_packet_size
C.uint(1000), // sleep_us
C.uchar(5), // max_attempts
C.uint(500000), // max_backoff_us
)
// Initialize capture (HDMI/USB → browser)
rc := C.jetkvm_audio_capture_init()
if rc != 0 {
c.logger.Error().Int("rc", int(rc)).Msg("Failed to initialize audio capture")
return fmt.Errorf("jetkvm_audio_capture_init failed: %d", rc)
}
} else {
// Set playback device for input path via environment variable
os.Setenv("ALSA_PLAYBACK_DEVICE", c.alsaDevice)
// Initialize decoder constants
C.update_audio_decoder_constants(
C.uint(48000), // sample_rate
C.uchar(2), // channels
C.ushort(960), // frame_size
C.ushort(1500), // max_packet_size
C.uint(1000), // sleep_us
C.uchar(5), // max_attempts
C.uint(500000), // max_backoff_us
)
// Initialize playback (browser → USB speakers)
rc := C.jetkvm_audio_playback_init()
if rc != 0 {
c.logger.Error().Int("rc", int(rc)).Msg("Failed to initialize audio playback")
return fmt.Errorf("jetkvm_audio_playback_init failed: %d", rc)
}
}
c.connected = true
c.initialized = true
return nil
}
// Disconnect closes the C audio subsystem
func (c *CgoSource) Disconnect() {
c.mu.Lock()
defer c.mu.Unlock()
if !c.connected {
return
}
if c.direction == "output" {
C.jetkvm_audio_capture_close()
} else {
C.jetkvm_audio_playback_close()
}
c.connected = false
}
// IsConnected returns true if currently connected
func (c *CgoSource) IsConnected() bool {
c.mu.Lock()
defer c.mu.Unlock()
return c.connected
}
// ReadMessage reads the next audio frame from C audio subsystem
// For output path: reads HDMI/USB audio and encodes to Opus
// For input path: not used (input uses WriteMessage instead)
// Returns message type (0 = Opus), payload data, and error
func (c *CgoSource) ReadMessage() (uint8, []byte, error) {
c.mu.Lock()
defer c.mu.Unlock()
if !c.connected {
return 0, nil, fmt.Errorf("not connected")
}
if c.direction != "output" {
return 0, nil, fmt.Errorf("ReadMessage only supported for output direction")
}
// Call C function to read HDMI/USB audio and encode to Opus
// Returns Opus packet size (>0) or error (<0)
opusSize := C.jetkvm_audio_read_encode(unsafe.Pointer(&c.opusBuf[0]))
if opusSize < 0 {
return 0, nil, fmt.Errorf("jetkvm_audio_read_encode failed: %d", opusSize)
}
if opusSize == 0 {
// No data available (silence/DTX)
return 0, nil, nil
}
// Return slice of opusBuf - caller must use immediately
return ipcMsgTypeOpus, c.opusBuf[:opusSize], nil
}
// WriteMessage writes an Opus packet to the C audio subsystem for playback
// Only used for input path (browser → USB speakers)
func (c *CgoSource) WriteMessage(msgType uint8, payload []byte) error {
c.mu.Lock()
defer c.mu.Unlock()
if !c.connected {
return fmt.Errorf("not connected")
}
if c.direction != "input" {
return fmt.Errorf("WriteMessage only supported for input direction")
}
if msgType != ipcMsgTypeOpus {
// Ignore non-Opus messages
return nil
}
if len(payload) == 0 {
return nil
}
// Call C function to decode Opus and write to USB speakers
rc := C.jetkvm_audio_decode_write(unsafe.Pointer(&payload[0]), C.int(len(payload)))
if rc < 0 {
return fmt.Errorf("jetkvm_audio_decode_write failed: %d", rc)
}
return nil
}

152
internal/audio/relay.go Normal file
View File

@ -0,0 +1,152 @@
package audio
import (
"context"
"fmt"
"sync/atomic"
"time"
"github.com/jetkvm/kvm/internal/logging"
"github.com/pion/webrtc/v4"
"github.com/pion/webrtc/v4/pkg/media"
"github.com/rs/zerolog"
)
// OutputRelay forwards audio from AudioSource (CGO) to WebRTC (browser)
type OutputRelay struct {
source AudioSource
audioTrack *webrtc.TrackLocalStaticSample
ctx context.Context
cancel context.CancelFunc
logger zerolog.Logger
running atomic.Bool
sample media.Sample // Reusable sample for zero-allocation hot path
// Stats (Uint32: overflows after 2.7 years @ 50fps, faster atomics on 32-bit ARM)
framesRelayed atomic.Uint32
framesDropped atomic.Uint32
}
// NewOutputRelay creates a relay for output audio (device → browser)
func NewOutputRelay(source AudioSource, audioTrack *webrtc.TrackLocalStaticSample) *OutputRelay {
ctx, cancel := context.WithCancel(context.Background())
logger := logging.GetDefaultLogger().With().Str("component", "audio-output-relay").Logger()
return &OutputRelay{
source: source,
audioTrack: audioTrack,
ctx: ctx,
cancel: cancel,
logger: logger,
sample: media.Sample{
Duration: 20 * time.Millisecond, // Constant for all Opus frames
},
}
}
// Start begins relaying audio frames
func (r *OutputRelay) Start() error {
if r.running.Swap(true) {
return fmt.Errorf("output relay already running")
}
go r.relayLoop()
r.logger.Debug().Msg("output relay started")
return nil
}
// Stop stops the relay
func (r *OutputRelay) Stop() {
if !r.running.Swap(false) {
return
}
r.cancel()
r.logger.Debug().
Uint32("frames_relayed", r.framesRelayed.Load()).
Uint32("frames_dropped", r.framesDropped.Load()).
Msg("output relay stopped")
}
// relayLoop continuously reads from audio source and writes to WebRTC
func (r *OutputRelay) relayLoop() {
const reconnectDelay = 1 * time.Second
for r.running.Load() {
// Ensure connected
if !r.source.IsConnected() {
if err := r.source.Connect(); err != nil {
r.logger.Debug().Err(err).Msg("failed to connect, will retry")
time.Sleep(reconnectDelay)
continue
}
}
// Read message from audio source
msgType, payload, err := r.source.ReadMessage()
if err != nil {
// Connection error - reconnect
if r.running.Load() {
r.logger.Warn().Err(err).Msg("read error, reconnecting")
r.source.Disconnect()
time.Sleep(reconnectDelay)
}
continue
}
// Handle message
if msgType == ipcMsgTypeOpus && len(payload) > 0 {
// Reuse sample struct (zero-allocation hot path)
r.sample.Data = payload
if err := r.audioTrack.WriteSample(r.sample); err != nil {
r.framesDropped.Add(1)
r.logger.Warn().Err(err).Msg("failed to write sample to WebRTC")
} else {
r.framesRelayed.Add(1)
}
}
}
}
// InputRelay forwards audio from WebRTC (browser microphone) to AudioSource (USB audio)
type InputRelay struct {
source AudioSource
ctx context.Context
cancel context.CancelFunc
logger zerolog.Logger
running atomic.Bool
}
// NewInputRelay creates a relay for input audio (browser → device)
func NewInputRelay(source AudioSource) *InputRelay {
ctx, cancel := context.WithCancel(context.Background())
logger := logging.GetDefaultLogger().With().Str("component", "audio-input-relay").Logger()
return &InputRelay{
source: source,
ctx: ctx,
cancel: cancel,
logger: logger,
}
}
// Start begins relaying audio frames
func (r *InputRelay) Start() error {
if r.running.Swap(true) {
return fmt.Errorf("input relay already running")
}
r.logger.Debug().Msg("input relay started")
return nil
}
// Stop stops the relay
func (r *InputRelay) Stop() {
if !r.running.Swap(false) {
return
}
r.cancel()
r.logger.Debug().Msg("input relay stopped")
}

23
internal/audio/source.go Normal file
View File

@ -0,0 +1,23 @@
package audio
// AudioSource provides audio frames via CGO (in-process) C audio functions
type AudioSource interface {
// ReadMessage reads the next audio message
// Returns message type, payload data, and error
// Blocks until data is available or error occurs
// Used for output path (device → browser)
ReadMessage() (msgType uint8, payload []byte, err error)
// WriteMessage writes an audio message
// Used for input path (browser → device)
WriteMessage(msgType uint8, payload []byte) error
// IsConnected returns true if the source is connected and ready
IsConnected() bool
// Connect initializes the C audio subsystem
Connect() error
// Disconnect closes the connection and releases resources
Disconnect()
}

View File

@ -16,22 +16,22 @@ import (
type FieldConfig struct {
Name string
Required bool
RequiredIf map[string]any
RequiredIf map[string]interface{}
OneOf []string
ValidateTypes []string
Defaults any
Defaults interface{}
IsEmpty bool
CurrentValue any
CurrentValue interface{}
TypeString string
Delegated bool
shouldUpdateValue bool
}
func SetDefaultsAndValidate(config any) error {
func SetDefaultsAndValidate(config interface{}) error {
return setDefaultsAndValidate(config, true)
}
func setDefaultsAndValidate(config any, isRoot bool) error {
func setDefaultsAndValidate(config interface{}, isRoot bool) error {
// first we need to check if the config is a pointer
if reflect.TypeOf(config).Kind() != reflect.Ptr {
return fmt.Errorf("config is not a pointer")
@ -55,7 +55,7 @@ func setDefaultsAndValidate(config any, isRoot bool) error {
Name: field.Name,
OneOf: splitString(field.Tag.Get("one_of")),
ValidateTypes: splitString(field.Tag.Get("validate_type")),
RequiredIf: make(map[string]any),
RequiredIf: make(map[string]interface{}),
CurrentValue: fieldValue.Interface(),
IsEmpty: false,
TypeString: fieldType,
@ -142,8 +142,8 @@ func setDefaultsAndValidate(config any, isRoot bool) error {
// now check if the field has required_if
requiredIf := field.Tag.Get("required_if")
if requiredIf != "" {
requiredIfParts := strings.SplitSeq(requiredIf, ",")
for part := range requiredIfParts {
requiredIfParts := strings.Split(requiredIf, ",")
for _, part := range requiredIfParts {
partVal := strings.SplitN(part, "=", 2)
if len(partVal) != 2 {
return fmt.Errorf("invalid required_if for field `%s`: %s", field.Name, requiredIf)
@ -168,7 +168,7 @@ func setDefaultsAndValidate(config any, isRoot bool) error {
return nil
}
func validateFields(config any, fields map[string]FieldConfig) error {
func validateFields(config interface{}, fields map[string]FieldConfig) error {
// now we can start to validate the fields
for _, fieldConfig := range fields {
if err := fieldConfig.validate(fields); err != nil {
@ -215,7 +215,7 @@ func (f *FieldConfig) validate(fields map[string]FieldConfig) error {
return nil
}
func (f *FieldConfig) populate(config any) {
func (f *FieldConfig) populate(config interface{}) {
// update the field if it's not empty
if !f.shouldUpdateValue {
return
@ -346,6 +346,17 @@ func (f *FieldConfig) validateField() error {
return nil
}
// Handle []string types, like dns servers, time sync ntp servers, etc.
if slice, ok := f.CurrentValue.([]string); ok {
for i, item := range slice {
if err := f.validateSingleValue(item, i); err != nil {
return err
}
}
return nil
}
// Handle single string types
val, err := toString(f.CurrentValue)
if err != nil {
return fmt.Errorf("field `%s` cannot use validate_type: %s", f.Name, err)
@ -355,30 +366,71 @@ func (f *FieldConfig) validateField() error {
return nil
}
return f.validateSingleValue(val, -1)
}
func (f *FieldConfig) validateSingleValue(val string, index int) error {
for _, validateType := range f.ValidateTypes {
var fieldRef string
if index >= 0 {
fieldRef = fmt.Sprintf("field `%s[%d]`", f.Name, index)
} else {
fieldRef = fmt.Sprintf("field `%s`", f.Name)
}
switch validateType {
case "int":
if _, err := strconv.Atoi(val); err != nil {
return fmt.Errorf("field `%s` is not a valid integer: %s", fieldRef, val)
}
case "ipv6_prefix_length":
valInt, err := strconv.Atoi(val)
if err != nil {
return fmt.Errorf("field `%s` is not a valid IPv6 prefix length: %s", fieldRef, val)
}
if valInt < 0 || valInt > 128 {
return fmt.Errorf("field `%s` is not a valid IPv6 prefix length: %s", fieldRef, val)
}
case "ipv4":
if net.ParseIP(val).To4() == nil {
return fmt.Errorf("field `%s` is not a valid IPv4 address: %s", f.Name, val)
return fmt.Errorf("field `%s` is not a valid IPv4 address: %s", fieldRef, val)
}
case "ipv6":
if net.ParseIP(val).To16() == nil {
return fmt.Errorf("field `%s` is not a valid IPv6 address: %s", f.Name, val)
return fmt.Errorf("field `%s` is not a valid IPv6 address: %s", fieldRef, val)
}
case "ipv6_prefix":
if i, _, err := net.ParseCIDR(val); err != nil {
if i.To16() == nil {
return fmt.Errorf("field `%s` is not a valid IPv6 prefix: %s", fieldRef, val)
}
}
case "ipv4_or_ipv6":
if net.ParseIP(val) == nil {
return fmt.Errorf("%s is not a valid IPv4 or IPv6 address: %s", fieldRef, val)
}
case "hwaddr":
if _, err := net.ParseMAC(val); err != nil {
return fmt.Errorf("field `%s` is not a valid MAC address: %s", f.Name, val)
return fmt.Errorf("%s is not a valid MAC address: %s", fieldRef, val)
}
case "hostname":
if _, err := idna.Lookup.ToASCII(val); err != nil {
return fmt.Errorf("field `%s` is not a valid hostname: %s", f.Name, val)
return fmt.Errorf("%s is not a valid hostname: %s", fieldRef, val)
}
case "proxy":
if url, err := url.Parse(val); err != nil || (url.Scheme != "http" && url.Scheme != "https") || url.Host == "" {
return fmt.Errorf("field `%s` is not a valid HTTP proxy URL: %s", f.Name, val)
return fmt.Errorf("%s is not a valid HTTP proxy URL: %s", fieldRef, val)
}
case "url":
if _, err := url.Parse(val); err != nil {
return fmt.Errorf("%s is not a valid URL: %s", fieldRef, val)
}
case "cidr":
if _, _, err := net.ParseCIDR(val); err != nil {
return fmt.Errorf("%s is not a valid CIDR notation: %s", fieldRef, val)
}
default:
return fmt.Errorf("field `%s` cannot use validate_type: unsupported validator: %s", f.Name, validateType)
return fmt.Errorf("field `%s` cannot use validate_type: unsupported validator: %s", fieldRef, validateType)
}
}

View File

@ -24,10 +24,10 @@ type testIPv4StaticConfig struct {
}
type testIPv6StaticConfig struct {
Address null.String `json:"address" validate_type:"ipv6" required:"true"`
Prefix null.String `json:"prefix" validate_type:"ipv6" required:"true"`
Gateway null.String `json:"gateway" validate_type:"ipv6" required:"true"`
DNS []string `json:"dns" validate_type:"ipv6" required:"true"`
Address null.String `json:"address" validate_type:"ipv6" required:"true"`
PrefixLength null.Int `json:"prefix_length" validate_type:"ipv6_prefix_length" required:"true"`
Gateway null.String `json:"gateway" validate_type:"ipv6" required:"true"`
DNS []string `json:"dns" validate_type:"ipv6" required:"true"`
}
type testNetworkConfig struct {
Hostname null.String `json:"hostname,omitempty"`
@ -39,7 +39,7 @@ type testNetworkConfig struct {
IPv6Mode null.String `json:"ipv6_mode" one_of:"slaac,dhcpv6,slaac_and_dhcpv6,static,link_local,disabled" default:"slaac"`
IPv6Static *testIPv6StaticConfig `json:"ipv6_static,omitempty" required_if:"IPv6Mode=static"`
LLDPMode null.String `json:"lldp_mode,omitempty" one_of:"disabled,rx_only,tx_only,enabled" default:"enabled"`
LLDPMode null.String `json:"lldp_mode,omitempty" one_of:"disabled,basic,all" default:"basic"`
LLDPTxTLVs []string `json:"lldp_tx_tlvs,omitempty" one_of:"chassis,port,system,vlan" default:"chassis,port,system,vlan"`
MDNSMode null.String `json:"mdns_mode,omitempty" one_of:"disabled,auto,ipv4_only,ipv6_only" default:"auto"`
TimeSyncMode null.String `json:"time_sync_mode,omitempty" one_of:"ntp_only,ntp_and_http,http_only,custom" default:"ntp_and_http"`

View File

@ -16,7 +16,7 @@ func splitString(s string) []string {
return strings.Split(s, ",")
}
func toString(v any) (string, error) {
func toString(v interface{}) (string, error) {
switch v := v.(type) {
case string:
return v, nil

View File

@ -146,14 +146,17 @@ func (m *MDNS) start(allowRestart bool) error {
return nil
}
// Start starts the mDNS server
func (m *MDNS) Start() error {
return m.start(false)
}
// Restart restarts the mDNS server
func (m *MDNS) Restart() error {
return m.start(true)
}
// Stop stops the mDNS server
func (m *MDNS) Stop() error {
m.lock.Lock()
defer m.lock.Unlock()
@ -165,26 +168,45 @@ func (m *MDNS) Stop() error {
return m.conn.Close()
}
func (m *MDNS) SetLocalNames(localNames []string, always bool) error {
if reflect.DeepEqual(m.localNames, localNames) && !always {
return nil
func (m *MDNS) setLocalNames(localNames []string) {
m.lock.Lock()
defer m.lock.Unlock()
if reflect.DeepEqual(m.localNames, localNames) {
return
}
m.localNames = localNames
_ = m.Restart()
return nil
}
func (m *MDNS) SetListenOptions(listenOptions *MDNSListenOptions) error {
func (m *MDNS) setListenOptions(listenOptions *MDNSListenOptions) {
m.lock.Lock()
defer m.lock.Unlock()
if m.listenOptions != nil &&
m.listenOptions.IPv4 == listenOptions.IPv4 &&
m.listenOptions.IPv6 == listenOptions.IPv6 {
return nil
return
}
m.listenOptions = listenOptions
_ = m.Restart()
return nil
}
// SetLocalNames sets the local names and restarts the mDNS server
func (m *MDNS) SetLocalNames(localNames []string) error {
m.setLocalNames(localNames)
return m.Restart()
}
// SetListenOptions sets the listen options and restarts the mDNS server
func (m *MDNS) SetListenOptions(listenOptions *MDNSListenOptions) error {
m.setListenOptions(listenOptions)
return m.Restart()
}
// SetOptions sets the local names and listen options and restarts the mDNS server
func (m *MDNS) SetOptions(options *MDNSOptions) error {
m.setLocalNames(options.LocalNames)
m.setListenOptions(options.ListenOptions)
return m.Restart()
}

File diff suppressed because one or more lines are too long

View File

@ -48,6 +48,10 @@ void action_switch_to_reset_config(lv_event_t *e) {
loadScreen(SCREEN_ID_RESET_CONFIG_SCREEN);
}
void action_switch_to_dhcpc(lv_event_t *e) {
loadScreen(SCREEN_ID_SWITCH_DHCP_CLIENT_SCREEN);
}
void action_switch_to_reboot(lv_event_t *e) {
loadScreen(SCREEN_ID_REBOOT_SCREEN);
}
@ -75,15 +79,19 @@ void action_about_screen_gesture(lv_event_t * e) {
// user_data doesn't seem to be working, so we use a global variable here
static uint32_t t_reset_config;
static uint32_t t_reboot;
static uint32_t t_dhcpc;
static bool b_reboot = false;
static bool b_reset_config = false;
static bool b_dhcpc = false;
static bool b_reboot_lock = false;
static bool b_reset_config_lock = false;
static bool b_dhcpc_lock = false;
const int RESET_CONFIG_HOLD_TIME = 10;
const int REBOOT_HOLD_TIME = 5;
const int DHCPC_HOLD_TIME = 5;
typedef struct {
uint32_t *start_time;
@ -153,6 +161,22 @@ void action_reset_config(lv_event_t * e) {
handle_hold_action(e, &config);
}
void action_dhcpc(lv_event_t * e) {
hold_action_config_t config = {
.start_time = &t_dhcpc,
.completed = &b_dhcpc,
.lock = &b_dhcpc_lock,
.hold_time_seconds = DHCPC_HOLD_TIME,
.rpc_method = "toggleDHCPClient",
.button_obj = NULL, // No button/spinner for reboot
.spinner_obj = NULL,
.label_obj = objects.dhcpc_label,
.default_text = "Press and hold for\n5 seconds"
};
handle_hold_action(e, &config);
}
void action_reboot(lv_event_t * e) {
hold_action_config_t config = {
.start_time = &t_reboot,

View File

@ -24,6 +24,8 @@ extern void action_handle_common_press_event(lv_event_t * e);
extern void action_reset_config(lv_event_t * e);
extern void action_reboot(lv_event_t * e);
extern void action_switch_to_reboot(lv_event_t * e);
extern void action_dhcpc(lv_event_t * e);
extern void action_switch_to_dhcpc(lv_event_t * e);
#ifdef __cplusplus

View File

@ -887,6 +887,26 @@ void create_screen_menu_advanced_screen() {
}
}
}
{
// MenuBtnDHCPClient
lv_obj_t *obj = lv_button_create(parent_obj);
objects.menu_btn_dhcp_client = obj;
lv_obj_set_pos(obj, 0, 0);
lv_obj_set_size(obj, LV_PCT(100), 50);
lv_obj_add_event_cb(obj, action_switch_to_dhcpc, LV_EVENT_PRESSED, (void *)0);
lv_obj_clear_flag(obj, LV_OBJ_FLAG_SNAPPABLE);
add_style_menu_button(obj);
{
lv_obj_t *parent_obj = obj;
{
lv_obj_t *obj = lv_label_create(parent_obj);
lv_obj_set_pos(obj, 0, 0);
lv_obj_set_size(obj, LV_SIZE_CONTENT, LV_SIZE_CONTENT);
add_style_menu_button_label(obj);
lv_label_set_text(obj, "DHCP Client");
}
}
}
{
// MenuBtnAdvancedResetConfig
lv_obj_t *obj = lv_button_create(parent_obj);
@ -2197,6 +2217,221 @@ void create_screen_rebooting_screen() {
void tick_screen_rebooting_screen() {
}
void create_screen_switch_dhcp_client_screen() {
lv_obj_t *obj = lv_obj_create(0);
objects.switch_dhcp_client_screen = obj;
lv_obj_set_pos(obj, 0, 0);
lv_obj_set_size(obj, 300, 240);
lv_obj_add_event_cb(obj, action_about_screen_gesture, LV_EVENT_GESTURE, (void *)0);
add_style_flex_screen_menu(obj);
{
lv_obj_t *parent_obj = obj;
{
lv_obj_t *obj = lv_obj_create(parent_obj);
lv_obj_set_pos(obj, 0, 0);
lv_obj_set_size(obj, LV_PCT(100), LV_PCT(100));
lv_obj_set_style_pad_left(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
lv_obj_set_style_pad_top(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
lv_obj_set_style_pad_right(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
lv_obj_set_style_pad_bottom(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
lv_obj_set_style_bg_opa(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
lv_obj_set_style_border_width(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
lv_obj_set_style_radius(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
lv_obj_clear_flag(obj, LV_OBJ_FLAG_SCROLLABLE);
add_style_flex_start(obj);
{
lv_obj_t *parent_obj = obj;
{
// DHCPClientHeader
lv_obj_t *obj = lv_obj_create(parent_obj);
objects.dhcp_client_header = obj;
lv_obj_set_pos(obj, 0, 0);
lv_obj_set_size(obj, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_pad_left(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
lv_obj_set_style_pad_top(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
lv_obj_set_style_pad_bottom(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
lv_obj_set_style_bg_opa(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
lv_obj_set_style_border_width(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
lv_obj_set_style_radius(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
add_style_flow_row_space_between(obj);
lv_obj_set_style_pad_right(obj, 4, LV_PART_MAIN | LV_STATE_DEFAULT);
{
lv_obj_t *parent_obj = obj;
{
lv_obj_t *obj = lv_button_create(parent_obj);
lv_obj_set_pos(obj, 0, 0);
lv_obj_set_size(obj, 32, 32);
lv_obj_add_event_cb(obj, action_switch_to_menu, LV_EVENT_CLICKED, (void *)0);
add_style_back_button(obj);
{
lv_obj_t *parent_obj = obj;
{
lv_obj_t *obj = lv_image_create(parent_obj);
lv_obj_set_pos(obj, -1, 2);
lv_obj_set_size(obj, LV_SIZE_CONTENT, LV_SIZE_CONTENT);
lv_image_set_src(obj, &img_back_caret);
}
}
}
{
lv_obj_t *obj = lv_label_create(parent_obj);
lv_obj_set_pos(obj, LV_PCT(0), LV_PCT(0));
lv_obj_set_size(obj, LV_SIZE_CONTENT, LV_SIZE_CONTENT);
add_style_header_link(obj);
lv_label_set_text(obj, "DHCP Client");
}
}
}
{
// DHCPClientContainer
lv_obj_t *obj = lv_obj_create(parent_obj);
objects.dhcp_client_container = obj;
lv_obj_set_pos(obj, 0, 0);
lv_obj_set_size(obj, LV_PCT(100), LV_PCT(80));
lv_obj_set_style_pad_left(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
lv_obj_set_style_pad_top(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
lv_obj_set_style_pad_bottom(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
lv_obj_set_style_bg_opa(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
lv_obj_set_style_border_width(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
lv_obj_set_style_radius(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
lv_obj_set_scrollbar_mode(obj, LV_SCROLLBAR_MODE_AUTO);
lv_obj_set_scroll_dir(obj, LV_DIR_VER);
lv_obj_set_scroll_snap_x(obj, LV_SCROLL_SNAP_START);
add_style_flex_column_start(obj);
lv_obj_set_style_pad_right(obj, 4, LV_PART_MAIN | LV_STATE_DEFAULT);
{
lv_obj_t *parent_obj = obj;
{
lv_obj_t *obj = lv_obj_create(parent_obj);
lv_obj_set_pos(obj, 0, 0);
lv_obj_set_size(obj, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_pad_left(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
lv_obj_set_style_pad_top(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
lv_obj_set_style_pad_bottom(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
lv_obj_set_style_bg_opa(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
lv_obj_set_style_border_width(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
lv_obj_set_style_radius(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
lv_obj_clear_flag(obj, LV_OBJ_FLAG_SCROLLABLE);
add_style_flex_column_start(obj);
lv_obj_set_style_pad_right(obj, 10, LV_PART_MAIN | LV_STATE_DEFAULT);
{
lv_obj_t *parent_obj = obj;
{
// DHCPClientLabelContainer
lv_obj_t *obj = lv_obj_create(parent_obj);
objects.dhcp_client_label_container = obj;
lv_obj_set_pos(obj, 0, 0);
lv_obj_set_size(obj, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_bg_opa(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
lv_obj_set_style_border_width(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
lv_obj_set_style_radius(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
lv_obj_clear_flag(obj, LV_OBJ_FLAG_SCROLLABLE);
add_style_flex_column_start(obj);
lv_obj_set_style_pad_right(obj, 10, LV_PART_MAIN | LV_STATE_DEFAULT);
lv_obj_set_style_pad_left(obj, 10, LV_PART_MAIN | LV_STATE_DEFAULT);
lv_obj_set_style_pad_top(obj, 10, LV_PART_MAIN | LV_STATE_DEFAULT);
lv_obj_set_style_pad_bottom(obj, 10, LV_PART_MAIN | LV_STATE_DEFAULT);
{
lv_obj_t *parent_obj = obj;
{
// DHCPC_Label
lv_obj_t *obj = lv_label_create(parent_obj);
objects.dhcpc_label = obj;
lv_obj_set_pos(obj, 0, 0);
lv_obj_set_size(obj, LV_PCT(100), LV_SIZE_CONTENT);
add_style_info_content_label(obj);
lv_obj_set_style_text_font(obj, &ui_font_font_book20, LV_PART_MAIN | LV_STATE_DEFAULT);
lv_label_set_text(obj, "Press and hold for\n5 seconds");
}
}
}
{
// DHCPClientSpinner
lv_obj_t *obj = lv_obj_create(parent_obj);
objects.dhcp_client_spinner = obj;
lv_obj_set_pos(obj, 0, 0);
lv_obj_set_size(obj, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_pad_left(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
lv_obj_set_style_pad_top(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
lv_obj_set_style_pad_right(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
lv_obj_set_style_pad_bottom(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
lv_obj_set_style_bg_opa(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
lv_obj_set_style_border_width(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
lv_obj_set_style_radius(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
lv_obj_add_flag(obj, LV_OBJ_FLAG_HIDDEN);
lv_obj_clear_flag(obj, LV_OBJ_FLAG_CLICKABLE|LV_OBJ_FLAG_SCROLLABLE);
add_style_flex_column_start(obj);
lv_obj_set_style_flex_main_place(obj, LV_FLEX_ALIGN_CENTER, LV_PART_MAIN | LV_STATE_DEFAULT);
lv_obj_set_style_flex_cross_place(obj, LV_FLEX_ALIGN_CENTER, LV_PART_MAIN | LV_STATE_DEFAULT);
lv_obj_set_style_flex_track_place(obj, LV_FLEX_ALIGN_CENTER, LV_PART_MAIN | LV_STATE_DEFAULT);
{
lv_obj_t *parent_obj = obj;
{
lv_obj_t *obj = lv_spinner_create(parent_obj);
lv_obj_set_pos(obj, 0, 0);
lv_obj_set_size(obj, 80, 80);
lv_spinner_set_anim_params(obj, 1000, 60);
}
}
}
{
// DHCPClientButton
lv_obj_t *obj = lv_obj_create(parent_obj);
objects.dhcp_client_button = obj;
lv_obj_set_pos(obj, 0, 0);
lv_obj_set_size(obj, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_pad_left(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
lv_obj_set_style_pad_top(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
lv_obj_set_style_pad_right(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
lv_obj_set_style_pad_bottom(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
lv_obj_set_style_bg_opa(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
lv_obj_set_style_border_width(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
lv_obj_set_style_radius(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
lv_obj_clear_flag(obj, LV_OBJ_FLAG_SCROLLABLE);
add_style_flex_column_start(obj);
{
lv_obj_t *parent_obj = obj;
{
lv_obj_t *obj = lv_button_create(parent_obj);
objects.obj2 = obj;
lv_obj_set_pos(obj, 0, 0);
lv_obj_set_size(obj, LV_PCT(100), 50);
lv_obj_add_event_cb(obj, action_dhcpc, LV_EVENT_PRESSED, (void *)0);
lv_obj_add_event_cb(obj, action_dhcpc, LV_EVENT_PRESSING, (void *)0);
lv_obj_add_event_cb(obj, action_dhcpc, LV_EVENT_RELEASED, (void *)0);
lv_obj_set_style_bg_color(obj, lv_color_hex(0xffdc2626), LV_PART_MAIN | LV_STATE_DEFAULT);
lv_obj_set_style_text_align(obj, LV_TEXT_ALIGN_LEFT, LV_PART_MAIN | LV_STATE_DEFAULT);
lv_obj_set_style_pad_right(obj, 13, LV_PART_MAIN | LV_STATE_DEFAULT);
{
lv_obj_t *parent_obj = obj;
{
// DHCPClientChangeLabel
lv_obj_t *obj = lv_label_create(parent_obj);
objects.dhcp_client_change_label = obj;
lv_obj_set_pos(obj, 0, 0);
lv_obj_set_size(obj, LV_SIZE_CONTENT, LV_SIZE_CONTENT);
lv_obj_set_style_align(obj, LV_ALIGN_CENTER, LV_PART_MAIN | LV_STATE_DEFAULT);
lv_obj_set_style_text_align(obj, LV_TEXT_ALIGN_LEFT, LV_PART_MAIN | LV_STATE_DEFAULT);
lv_label_set_text(obj, "Switch to udhcpc");
}
}
}
}
}
}
}
}
}
}
}
}
tick_screen_switch_dhcp_client_screen();
}
void tick_screen_switch_dhcp_client_screen() {
}
typedef void (*tick_screen_func_t)();
@ -2212,6 +2447,7 @@ tick_screen_func_t tick_screen_funcs[] = {
tick_screen_reset_config_screen,
tick_screen_reboot_screen,
tick_screen_rebooting_screen,
tick_screen_switch_dhcp_client_screen,
};
void tick_screen(int screen_index) {
tick_screen_funcs[screen_index]();
@ -2236,4 +2472,5 @@ void create_screens() {
create_screen_reset_config_screen();
create_screen_reboot_screen();
create_screen_rebooting_screen();
create_screen_switch_dhcp_client_screen();
}

View File

@ -19,6 +19,7 @@ typedef struct _objects_t {
lv_obj_t *reset_config_screen;
lv_obj_t *reboot_screen;
lv_obj_t *rebooting_screen;
lv_obj_t *switch_dhcp_client_screen;
lv_obj_t *boot_logo;
lv_obj_t *boot_screen_version;
lv_obj_t *no_network_header_container;
@ -54,6 +55,7 @@ typedef struct _objects_t {
lv_obj_t *menu_btn_advanced_developer_mode;
lv_obj_t *menu_btn_advanced_usb_emulation;
lv_obj_t *menu_btn_advanced_reboot;
lv_obj_t *menu_btn_dhcp_client;
lv_obj_t *menu_btn_advanced_reset_config;
lv_obj_t *menu_header_container_2;
lv_obj_t *menu_items_container_2;
@ -101,6 +103,14 @@ typedef struct _objects_t {
lv_obj_t *obj1;
lv_obj_t *reboot_in_progress_logo;
lv_obj_t *reboot_in_progress_label;
lv_obj_t *dhcp_client_header;
lv_obj_t *dhcp_client_container;
lv_obj_t *dhcp_client_label_container;
lv_obj_t *dhcpc_label;
lv_obj_t *dhcp_client_spinner;
lv_obj_t *dhcp_client_button;
lv_obj_t *obj2;
lv_obj_t *dhcp_client_change_label;
} objects_t;
extern objects_t objects;
@ -117,6 +127,7 @@ enum ScreensEnum {
SCREEN_ID_RESET_CONFIG_SCREEN = 9,
SCREEN_ID_REBOOT_SCREEN = 10,
SCREEN_ID_REBOOTING_SCREEN = 11,
SCREEN_ID_SWITCH_DHCP_CLIENT_SCREEN = 12,
};
void create_screen_boot_screen();
@ -151,6 +162,9 @@ void tick_screen_reboot_screen();
void create_screen_rebooting_screen();
void tick_screen_rebooting_screen();
void create_screen_switch_dhcp_client_screen();
void tick_screen_switch_dhcp_client_screen();
void tick_screen_by_id(enum ScreensEnum screenId);
void tick_screen(int screen_index);

View File

@ -1,11 +0,0 @@
package network
type DhcpTargetState int
const (
DhcpTargetStateDoNothing DhcpTargetState = iota
DhcpTargetStateStart
DhcpTargetStateStop
DhcpTargetStateRenew
DhcpTargetStateRelease
)

View File

@ -1,137 +0,0 @@
package network
import (
"fmt"
"io"
"os"
"os/exec"
"strings"
"sync"
"golang.org/x/net/idna"
)
const (
hostnamePath = "/etc/hostname"
hostsPath = "/etc/hosts"
)
var (
hostnameLock sync.Mutex = sync.Mutex{}
)
func updateEtcHosts(hostname string, fqdn string) error {
// update /etc/hosts
hostsFile, err := os.OpenFile(hostsPath, os.O_RDWR|os.O_SYNC, os.ModeExclusive)
if err != nil {
return fmt.Errorf("failed to open %s: %w", hostsPath, err)
}
defer hostsFile.Close()
// read all lines
if _, err := hostsFile.Seek(0, io.SeekStart); err != nil {
return fmt.Errorf("failed to seek %s: %w", hostsPath, err)
}
lines, err := io.ReadAll(hostsFile)
if err != nil {
return fmt.Errorf("failed to read %s: %w", hostsPath, err)
}
newLines := []string{}
hostLine := fmt.Sprintf("127.0.1.1\t%s %s", hostname, fqdn)
hostLineExists := false
for line := range strings.SplitSeq(string(lines), "\n") {
if strings.HasPrefix(line, "127.0.1.1") {
hostLineExists = true
line = hostLine
}
newLines = append(newLines, line)
}
if !hostLineExists {
newLines = append(newLines, hostLine)
}
if err := hostsFile.Truncate(0); err != nil {
return fmt.Errorf("failed to truncate %s: %w", hostsPath, err)
}
if _, err := hostsFile.Seek(0, io.SeekStart); err != nil {
return fmt.Errorf("failed to seek %s: %w", hostsPath, err)
}
if _, err := hostsFile.Write([]byte(strings.Join(newLines, "\n"))); err != nil {
return fmt.Errorf("failed to write %s: %w", hostsPath, err)
}
return nil
}
func ToValidHostname(hostname string) string {
ascii, err := idna.Lookup.ToASCII(hostname)
if err != nil {
return ""
}
return ascii
}
func SetHostname(hostname string, fqdn string) error {
hostnameLock.Lock()
defer hostnameLock.Unlock()
hostname = ToValidHostname(strings.TrimSpace(hostname))
fqdn = ToValidHostname(strings.TrimSpace(fqdn))
if hostname == "" {
return fmt.Errorf("invalid hostname: %s", hostname)
}
if fqdn == "" {
fqdn = hostname
}
// update /etc/hostname
if err := os.WriteFile(hostnamePath, []byte(hostname), 0644); err != nil {
return fmt.Errorf("failed to write %s: %w", hostnamePath, err)
}
// update /etc/hosts
if err := updateEtcHosts(hostname, fqdn); err != nil {
return fmt.Errorf("failed to update /etc/hosts: %w", err)
}
// run hostname
if err := exec.Command("hostname", "-F", hostnamePath).Run(); err != nil {
return fmt.Errorf("failed to run hostname: %w", err)
}
return nil
}
func (s *NetworkInterfaceState) setHostnameIfNotSame() error {
hostname := s.GetHostname()
currentHostname, _ := os.Hostname()
fqdn := fmt.Sprintf("%s.%s", hostname, s.GetDomain())
if currentHostname == hostname && s.currentFqdn == fqdn && s.currentHostname == hostname {
return nil
}
scopedLogger := s.l.With().Str("hostname", hostname).Str("fqdn", fqdn).Logger()
err := SetHostname(hostname, fqdn)
if err != nil {
scopedLogger.Error().Err(err).Msg("failed to set hostname")
return err
}
s.currentHostname = hostname
s.currentFqdn = fqdn
scopedLogger.Info().Msg("hostname set")
return nil
}

View File

@ -1,403 +0,0 @@
package network
import (
"fmt"
"net"
"sync"
"github.com/jetkvm/kvm/internal/confparser"
"github.com/jetkvm/kvm/internal/logging"
"github.com/jetkvm/kvm/internal/udhcpc"
"github.com/rs/zerolog"
"github.com/vishvananda/netlink"
)
type NetworkInterfaceState struct {
interfaceName string
interfaceUp bool
ipv4Addr *net.IP
ipv4Addresses []string
ipv6Addr *net.IP
ipv6Addresses []IPv6Address
ipv6LinkLocal *net.IP
ntpAddresses []*net.IP
macAddr *net.HardwareAddr
l *zerolog.Logger
stateLock sync.Mutex
config *NetworkConfig
dhcpClient *udhcpc.DHCPClient
defaultHostname string
currentHostname string
currentFqdn string
onStateChange func(state *NetworkInterfaceState)
onInitialCheck func(state *NetworkInterfaceState)
cbConfigChange func(config *NetworkConfig)
checked bool
}
type NetworkInterfaceOptions struct {
InterfaceName string
DhcpPidFile string
Logger *zerolog.Logger
DefaultHostname string
OnStateChange func(state *NetworkInterfaceState)
OnInitialCheck func(state *NetworkInterfaceState)
OnDhcpLeaseChange func(lease *udhcpc.Lease, state *NetworkInterfaceState)
OnConfigChange func(config *NetworkConfig)
NetworkConfig *NetworkConfig
}
func NewNetworkInterfaceState(opts *NetworkInterfaceOptions) (*NetworkInterfaceState, error) {
if opts.NetworkConfig == nil {
return nil, fmt.Errorf("NetworkConfig can not be nil")
}
if opts.DefaultHostname == "" {
opts.DefaultHostname = "jetkvm"
}
err := confparser.SetDefaultsAndValidate(opts.NetworkConfig)
if err != nil {
return nil, err
}
l := opts.Logger
s := &NetworkInterfaceState{
interfaceName: opts.InterfaceName,
defaultHostname: opts.DefaultHostname,
stateLock: sync.Mutex{},
l: l,
onStateChange: opts.OnStateChange,
onInitialCheck: opts.OnInitialCheck,
cbConfigChange: opts.OnConfigChange,
config: opts.NetworkConfig,
ntpAddresses: make([]*net.IP, 0),
}
// create the dhcp client
dhcpClient := udhcpc.NewDHCPClient(&udhcpc.DHCPClientOptions{
InterfaceName: opts.InterfaceName,
PidFile: opts.DhcpPidFile,
Logger: l,
OnLeaseChange: func(lease *udhcpc.Lease) {
_, err := s.update()
if err != nil {
opts.Logger.Error().Err(err).Msg("failed to update network state")
return
}
_ = s.updateNtpServersFromLease(lease)
_ = s.setHostnameIfNotSame()
opts.OnDhcpLeaseChange(lease, s)
},
})
s.dhcpClient = dhcpClient
return s, nil
}
func (s *NetworkInterfaceState) IsUp() bool {
return s.interfaceUp
}
func (s *NetworkInterfaceState) HasIPAssigned() bool {
return s.ipv4Addr != nil || s.ipv6Addr != nil
}
func (s *NetworkInterfaceState) IsOnline() bool {
return s.IsUp() && s.HasIPAssigned()
}
func (s *NetworkInterfaceState) IPv4() *net.IP {
return s.ipv4Addr
}
func (s *NetworkInterfaceState) IPv4String() string {
if s.ipv4Addr == nil {
return "..."
}
return s.ipv4Addr.String()
}
func (s *NetworkInterfaceState) IPv6() *net.IP {
return s.ipv6Addr
}
func (s *NetworkInterfaceState) IPv6String() string {
if s.ipv6Addr == nil {
return "..."
}
return s.ipv6Addr.String()
}
func (s *NetworkInterfaceState) NtpAddresses() []*net.IP {
return s.ntpAddresses
}
func (s *NetworkInterfaceState) NtpAddressesString() []string {
ntpServers := []string{}
if s != nil {
s.l.Debug().Any("s", s).Msg("getting NTP address strings")
if len(s.ntpAddresses) > 0 {
for _, server := range s.ntpAddresses {
s.l.Debug().IPAddr("server", *server).Msg("converting NTP address")
ntpServers = append(ntpServers, server.String())
}
}
}
return ntpServers
}
func (s *NetworkInterfaceState) MAC() *net.HardwareAddr {
return s.macAddr
}
func (s *NetworkInterfaceState) MACString() string {
if s.macAddr == nil {
return ""
}
return s.macAddr.String()
}
func (s *NetworkInterfaceState) update() (DhcpTargetState, error) {
s.stateLock.Lock()
defer s.stateLock.Unlock()
dhcpTargetState := DhcpTargetStateDoNothing
iface, err := netlink.LinkByName(s.interfaceName)
if err != nil {
s.l.Error().Err(err).Msg("failed to get interface")
return dhcpTargetState, err
}
// detect if the interface status changed
var changed bool
attrs := iface.Attrs()
state := attrs.OperState
newInterfaceUp := state == netlink.OperUp
// check if the interface is coming up
interfaceGoingUp := !s.interfaceUp && newInterfaceUp
interfaceGoingDown := s.interfaceUp && !newInterfaceUp
if s.interfaceUp != newInterfaceUp {
s.interfaceUp = newInterfaceUp
changed = true
}
if changed {
if interfaceGoingUp {
s.l.Info().Msg("interface state transitioned to up")
dhcpTargetState = DhcpTargetStateRenew
} else if interfaceGoingDown {
s.l.Info().Msg("interface state transitioned to down")
}
}
// set the mac address
s.macAddr = &attrs.HardwareAddr
// get the ip addresses
addrs, err := netlinkAddrs(iface)
if err != nil {
return dhcpTargetState, logging.ErrorfL(s.l, "failed to get ip addresses", err)
}
var (
ipv4Addresses = make([]net.IP, 0)
ipv4AddressesString = make([]string, 0)
ipv6Addresses = make([]IPv6Address, 0)
// ipv6AddressesString = make([]string, 0)
ipv6LinkLocal *net.IP
)
for _, addr := range addrs {
if addr.IP.To4() != nil {
scopedLogger := s.l.With().Str("ipv4", addr.IP.String()).Logger()
if interfaceGoingDown {
// remove all IPv4 addresses from the interface.
scopedLogger.Info().Msg("state transitioned to down, removing IPv4 address")
err := netlink.AddrDel(iface, &addr)
if err != nil {
scopedLogger.Warn().Err(err).Msg("failed to delete address")
}
// notify the DHCP client to release the lease
dhcpTargetState = DhcpTargetStateRelease
continue
}
ipv4Addresses = append(ipv4Addresses, addr.IP)
ipv4AddressesString = append(ipv4AddressesString, addr.IPNet.String())
} else if addr.IP.To16() != nil {
if s.config.IPv6Mode.String == "disabled" {
continue
}
scopedLogger := s.l.With().Str("ipv6", addr.IP.String()).Logger()
// check if it's a link local address
if addr.IP.IsLinkLocalUnicast() {
ipv6LinkLocal = &addr.IP
continue
}
if !addr.IP.IsGlobalUnicast() {
scopedLogger.Trace().Msg("not a global unicast address, skipping")
continue
}
if interfaceGoingDown {
scopedLogger.Info().Msg("state transitioned to down, removing IPv6 address")
err := netlink.AddrDel(iface, &addr)
if err != nil {
scopedLogger.Warn().Err(err).Msg("failed to delete address")
}
continue
}
ipv6Addresses = append(ipv6Addresses, IPv6Address{
Address: addr.IP,
Prefix: *addr.IPNet,
ValidLifetime: lifetimeToTime(addr.ValidLft),
PreferredLifetime: lifetimeToTime(addr.PreferedLft),
Scope: addr.Scope,
})
// ipv6AddressesString = append(ipv6AddressesString, addr.IPNet.String())
}
}
if len(ipv4Addresses) > 0 {
// compare the addresses to see if there's a change
if s.ipv4Addr == nil || s.ipv4Addr.String() != ipv4Addresses[0].String() {
scopedLogger := s.l.With().Str("ipv4", ipv4Addresses[0].String()).Logger()
if s.ipv4Addr != nil {
scopedLogger.Info().
Str("old_ipv4", s.ipv4Addr.String()).
Msg("IPv4 address changed")
} else {
scopedLogger.Info().Msg("IPv4 address found")
}
s.ipv4Addr = &ipv4Addresses[0]
changed = true
}
}
s.ipv4Addresses = ipv4AddressesString
if s.config.IPv6Mode.String != "disabled" {
if ipv6LinkLocal != nil {
if s.ipv6LinkLocal == nil || s.ipv6LinkLocal.String() != ipv6LinkLocal.String() {
scopedLogger := s.l.With().Str("ipv6", ipv6LinkLocal.String()).Logger()
if s.ipv6LinkLocal != nil {
scopedLogger.Info().
Str("old_ipv6", s.ipv6LinkLocal.String()).
Msg("IPv6 link local address changed")
} else {
scopedLogger.Info().Msg("IPv6 link local address found")
}
s.ipv6LinkLocal = ipv6LinkLocal
changed = true
}
}
s.ipv6Addresses = ipv6Addresses
if len(ipv6Addresses) > 0 {
// compare the addresses to see if there's a change
if s.ipv6Addr == nil || s.ipv6Addr.String() != ipv6Addresses[0].Address.String() {
scopedLogger := s.l.With().Str("ipv6", ipv6Addresses[0].Address.String()).Logger()
if s.ipv6Addr != nil {
scopedLogger.Info().
Str("old_ipv6", s.ipv6Addr.String()).
Msg("IPv6 address changed")
} else {
scopedLogger.Info().Msg("IPv6 address found")
}
s.ipv6Addr = &ipv6Addresses[0].Address
changed = true
}
}
}
// if it's the initial check, we'll set changed to false
initialCheck := !s.checked
if initialCheck {
s.checked = true
changed = false
if dhcpTargetState == DhcpTargetStateRenew {
// it's the initial check, we'll start the DHCP client
// dhcpTargetState = DhcpTargetStateStart
// TODO: manage DHCP client start/stop
dhcpTargetState = DhcpTargetStateDoNothing
}
}
if initialCheck {
s.handleInitialCheck()
} else if changed {
s.handleStateChange()
}
return dhcpTargetState, nil
}
func (s *NetworkInterfaceState) updateNtpServersFromLease(lease *udhcpc.Lease) error {
if lease != nil && len(lease.NTPServers) > 0 {
s.l.Info().Msg("lease found, updating DHCP NTP addresses")
s.ntpAddresses = make([]*net.IP, 0, len(lease.NTPServers))
for _, ntpServer := range lease.NTPServers {
if ntpServer != nil {
s.l.Info().IPAddr("ntp_server", ntpServer).Msg("NTP server found in lease")
s.ntpAddresses = append(s.ntpAddresses, &ntpServer)
}
}
} else {
s.l.Info().Msg("no NTP servers found in lease")
s.ntpAddresses = make([]*net.IP, 0, len(s.config.TimeSyncNTPServers))
}
return nil
}
func (s *NetworkInterfaceState) handleInitialCheck() {
// if s.IsUp() {}
s.onInitialCheck(s)
}
func (s *NetworkInterfaceState) handleStateChange() {
// if s.IsUp() {} else {}
s.onStateChange(s)
}
func (s *NetworkInterfaceState) CheckAndUpdateDhcp() error {
dhcpTargetState, err := s.update()
if err != nil {
return logging.ErrorfL(s.l, "failed to update network state", err)
}
switch dhcpTargetState {
case DhcpTargetStateRenew:
s.l.Info().Msg("renewing DHCP lease")
_ = s.dhcpClient.Renew()
case DhcpTargetStateRelease:
s.l.Info().Msg("releasing DHCP lease")
_ = s.dhcpClient.Release()
case DhcpTargetStateStart:
s.l.Warn().Msg("dhcpTargetStateStart not implemented")
case DhcpTargetStateStop:
s.l.Warn().Msg("dhcpTargetStateStop not implemented")
}
return nil
}
func (s *NetworkInterfaceState) onConfigChange(config *NetworkConfig) {
_ = s.setHostnameIfNotSame()
s.cbConfigChange(config)
}

View File

@ -1,58 +0,0 @@
//go:build linux
package network
import (
"time"
"github.com/vishvananda/netlink"
"github.com/vishvananda/netlink/nl"
)
func (s *NetworkInterfaceState) HandleLinkUpdate(update netlink.LinkUpdate) {
if update.Link.Attrs().Name == s.interfaceName {
s.l.Info().Interface("update", update).Msg("interface link update received")
_ = s.CheckAndUpdateDhcp()
}
}
func (s *NetworkInterfaceState) Run() error {
updates := make(chan netlink.LinkUpdate)
done := make(chan struct{})
if err := netlink.LinkSubscribe(updates, done); err != nil {
s.l.Warn().Err(err).Msg("failed to subscribe to link updates")
return err
}
_ = s.setHostnameIfNotSame()
// run the dhcp client
go s.dhcpClient.Run() // nolint:errcheck
if err := s.CheckAndUpdateDhcp(); err != nil {
return err
}
go func() {
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
for {
select {
case update := <-updates:
s.HandleLinkUpdate(update)
case <-ticker.C:
_ = s.CheckAndUpdateDhcp()
case <-done:
return
}
}
}()
return nil
}
func netlinkAddrs(iface netlink.Link) ([]netlink.Addr, error) {
return netlink.AddrList(iface, nl.FAMILY_ALL)
}

View File

@ -1,21 +0,0 @@
//go:build !linux
package network
import (
"fmt"
"github.com/vishvananda/netlink"
)
func (s *NetworkInterfaceState) HandleLinkUpdate() error {
return fmt.Errorf("not implemented")
}
func (s *NetworkInterfaceState) Run() error {
return fmt.Errorf("not implemented")
}
func netlinkAddrs(iface netlink.Link) ([]netlink.Addr, error) {
return nil, fmt.Errorf("not implemented")
}

View File

@ -1,126 +0,0 @@
package network
import (
"fmt"
"time"
"github.com/jetkvm/kvm/internal/confparser"
"github.com/jetkvm/kvm/internal/udhcpc"
)
type RpcIPv6Address struct {
Address string `json:"address"`
ValidLifetime *time.Time `json:"valid_lifetime,omitempty"`
PreferredLifetime *time.Time `json:"preferred_lifetime,omitempty"`
Scope int `json:"scope"`
}
type RpcNetworkState struct {
InterfaceName string `json:"interface_name"`
MacAddress string `json:"mac_address"`
IPv4 string `json:"ipv4,omitempty"`
IPv6 string `json:"ipv6,omitempty"`
IPv6LinkLocal string `json:"ipv6_link_local,omitempty"`
IPv4Addresses []string `json:"ipv4_addresses,omitempty"`
IPv6Addresses []RpcIPv6Address `json:"ipv6_addresses,omitempty"`
DHCPLease *udhcpc.Lease `json:"dhcp_lease,omitempty"`
}
type RpcNetworkSettings struct {
NetworkConfig
}
func (s *NetworkInterfaceState) MacAddress() string {
if s.macAddr == nil {
return ""
}
return s.macAddr.String()
}
func (s *NetworkInterfaceState) IPv4Address() string {
if s.ipv4Addr == nil {
return ""
}
return s.ipv4Addr.String()
}
func (s *NetworkInterfaceState) IPv6Address() string {
if s.ipv6Addr == nil {
return ""
}
return s.ipv6Addr.String()
}
func (s *NetworkInterfaceState) IPv6LinkLocalAddress() string {
if s.ipv6LinkLocal == nil {
return ""
}
return s.ipv6LinkLocal.String()
}
func (s *NetworkInterfaceState) RpcGetNetworkState() RpcNetworkState {
ipv6Addresses := make([]RpcIPv6Address, 0)
if s.ipv6Addresses != nil && s.config.IPv6Mode.String != "disabled" {
for _, addr := range s.ipv6Addresses {
ipv6Addresses = append(ipv6Addresses, RpcIPv6Address{
Address: addr.Prefix.String(),
ValidLifetime: addr.ValidLifetime,
PreferredLifetime: addr.PreferredLifetime,
Scope: addr.Scope,
})
}
}
return RpcNetworkState{
InterfaceName: s.interfaceName,
MacAddress: s.MacAddress(),
IPv4: s.IPv4Address(),
IPv6: s.IPv6Address(),
IPv6LinkLocal: s.IPv6LinkLocalAddress(),
IPv4Addresses: s.ipv4Addresses,
IPv6Addresses: ipv6Addresses,
DHCPLease: s.dhcpClient.GetLease(),
}
}
func (s *NetworkInterfaceState) RpcGetNetworkSettings() RpcNetworkSettings {
if s.config == nil {
return RpcNetworkSettings{}
}
return RpcNetworkSettings{
NetworkConfig: *s.config,
}
}
func (s *NetworkInterfaceState) RpcSetNetworkSettings(settings RpcNetworkSettings) error {
currentSettings := s.config
err := confparser.SetDefaultsAndValidate(&settings.NetworkConfig)
if err != nil {
return err
}
if IsSame(currentSettings, settings.NetworkConfig) {
// no changes, do nothing
return nil
}
s.config = &settings.NetworkConfig
s.onConfigChange(s.config)
return nil
}
func (s *NetworkInterfaceState) RpcRenewDHCPLease() error {
if s.dhcpClient == nil {
return fmt.Errorf("dhcp client not initialized")
}
return s.dhcpClient.Renew()
}

View File

@ -1,25 +1,13 @@
package network
package types
import (
"fmt"
"net"
"net/http"
"net/url"
"time"
"github.com/guregu/null/v6"
"github.com/jetkvm/kvm/internal/mdns"
"golang.org/x/net/idna"
)
type IPv6Address struct {
Address net.IP `json:"address"`
Prefix net.IPNet `json:"prefix"`
ValidLifetime *time.Time `json:"valid_lifetime"`
PreferredLifetime *time.Time `json:"preferred_lifetime"`
Scope int `json:"scope"`
}
// IPv4StaticConfig represents static IPv4 configuration
type IPv4StaticConfig struct {
Address null.String `json:"address,omitempty" validate_type:"ipv4" required:"true"`
Netmask null.String `json:"netmask,omitempty" validate_type:"ipv4" required:"true"`
@ -27,13 +15,23 @@ type IPv4StaticConfig struct {
DNS []string `json:"dns,omitempty" validate_type:"ipv4" required:"true"`
}
// IPv6StaticConfig represents static IPv6 configuration
type IPv6StaticConfig struct {
Address null.String `json:"address,omitempty" validate_type:"ipv6" required:"true"`
Prefix null.String `json:"prefix,omitempty" validate_type:"ipv6" required:"true"`
Prefix null.String `json:"prefix,omitempty" validate_type:"ipv6_prefix" required:"true"`
Gateway null.String `json:"gateway,omitempty" validate_type:"ipv6" required:"true"`
DNS []string `json:"dns,omitempty" validate_type:"ipv6" required:"true"`
}
// MDNSListenOptions represents MDNS listening options
type MDNSListenOptions struct {
IPv4 bool
IPv6 bool
}
// NetworkConfig represents the complete network configuration for an interface
type NetworkConfig struct {
DHCPClient null.String `json:"dhcp_client,omitempty" one_of:"jetdhcpc,udhcpc" default:"jetdhcpc"`
Hostname null.String `json:"hostname,omitempty" validate_type:"hostname"`
HTTPProxy null.String `json:"http_proxy,omitempty" validate_type:"proxy"`
Domain null.String `json:"domain,omitempty" validate_type:"hostname"`
@ -44,7 +42,7 @@ type NetworkConfig struct {
IPv6Mode null.String `json:"ipv6_mode,omitempty" one_of:"slaac,dhcpv6,slaac_and_dhcpv6,static,link_local,disabled" default:"slaac"`
IPv6Static *IPv6StaticConfig `json:"ipv6_static,omitempty" required_if:"IPv6Mode=static"`
LLDPMode null.String `json:"lldp_mode,omitempty" one_of:"disabled,rx_only,tx_only,basic,all,enabled" default:"enabled"`
LLDPMode null.String `json:"lldp_mode,omitempty" one_of:"disabled,basic,all" default:"basic"`
LLDPTxTLVs []string `json:"lldp_tx_tlvs,omitempty" one_of:"chassis,port,system,vlan" default:"chassis,port,system,vlan"`
MDNSMode null.String `json:"mdns_mode,omitempty" one_of:"disabled,auto,ipv4_only,ipv6_only" default:"auto"`
TimeSyncMode null.String `json:"time_sync_mode,omitempty" one_of:"ntp_only,ntp_and_http,http_only,custom" default:"ntp_and_http"`
@ -55,13 +53,15 @@ type NetworkConfig struct {
TimeSyncHTTPUrls []string `json:"time_sync_http_urls,omitempty" validate_type:"url" required_if:"TimeSyncOrdering=http_user_provided"`
}
func (c *NetworkConfig) GetMDNSMode() *mdns.MDNSListenOptions {
listenOptions := &mdns.MDNSListenOptions{
IPv4: c.IPv4Mode.String != "disabled",
IPv6: c.IPv6Mode.String != "disabled",
// GetMDNSMode returns the MDNS mode configuration
func (c *NetworkConfig) GetMDNSMode() *MDNSListenOptions {
mode := c.MDNSMode.String
listenOptions := &MDNSListenOptions{
IPv4: true,
IPv6: true,
}
switch c.MDNSMode.String {
switch mode {
case "ipv4_only":
listenOptions.IPv6 = false
case "ipv6_only":
@ -74,53 +74,21 @@ func (c *NetworkConfig) GetMDNSMode() *mdns.MDNSListenOptions {
return listenOptions
}
func (s *NetworkConfig) GetTransportProxyFunc() func(*http.Request) (*url.URL, error) {
// GetTransportProxyFunc returns a function for HTTP proxy configuration
func (c *NetworkConfig) GetTransportProxyFunc() func(*http.Request) (*url.URL, error) {
return func(*http.Request) (*url.URL, error) {
if s.HTTPProxy.String == "" {
if c.HTTPProxy.String == "" {
return nil, nil
} else {
proxyUrl, _ := url.Parse(s.HTTPProxy.String)
return proxyUrl, nil
proxyURL, _ := url.Parse(c.HTTPProxy.String)
return proxyURL, nil
}
}
}
func (s *NetworkInterfaceState) GetHostname() string {
hostname := ToValidHostname(s.config.Hostname.String)
if hostname == "" {
return s.defaultHostname
}
return hostname
}
func ToValidDomain(domain string) string {
ascii, err := idna.Lookup.ToASCII(domain)
if err != nil {
return ""
}
return ascii
}
func (s *NetworkInterfaceState) GetDomain() string {
domain := ToValidDomain(s.config.Domain.String)
if domain == "" {
lease := s.dhcpClient.GetLease()
if lease != nil && lease.Domain != "" {
domain = ToValidDomain(lease.Domain)
}
}
if domain == "" {
return "local"
}
return domain
}
func (s *NetworkInterfaceState) GetFQDN() string {
return fmt.Sprintf("%s.%s", s.GetHostname(), s.GetDomain())
// NetworkConfig interface for backward compatibility
type NetworkConfigInterface interface {
InterfaceName() string
IPv4Addresses() []IPAddress
IPv6Addresses() []IPAddress
}

View File

@ -1,18 +1,26 @@
package udhcpc
package types
import (
"bufio"
"encoding/json"
"fmt"
"net"
"os"
"reflect"
"strconv"
"strings"
"time"
)
type Lease struct {
// DHCPClient is the interface for a DHCP client.
type DHCPClient interface {
Domain() string
Lease4() *DHCPLease
Lease6() *DHCPLease
Renew() error
Release() error
SetIPv4(enabled bool)
SetIPv6(enabled bool)
SetOnLeaseChange(callback func(lease *DHCPLease))
Start() error
Stop() error
}
// DHCPLease is a network configuration obtained by DHCP.
type DHCPLease struct {
// from https://udhcp.busybox.net/README.udhcpc
IPAddress net.IP `env:"ip" json:"ip"` // The obtained IP
Netmask net.IP `env:"subnet" json:"netmask"` // The assigned subnet mask
@ -21,6 +29,7 @@ type Lease struct {
MTU int `env:"mtu" json:"mtu,omitempty"` // The MTU to use for this network
HostName string `env:"hostname" json:"hostname,omitempty"` // The assigned hostname
Domain string `env:"domain" json:"domain,omitempty"` // The domain name of the network
SearchList []string `env:"search" json:"search_list,omitempty"` // The search list for the network
BootPNextServer net.IP `env:"siaddr" json:"bootp_next_server,omitempty"` // The bootp next server option
BootPServerName string `env:"sname" json:"bootp_server_name,omitempty"` // The bootp server name option
BootPFile string `env:"boot_file" json:"bootp_file,omitempty"` // The bootp boot file option
@ -38,149 +47,46 @@ type Lease struct {
BootSize int `env:"bootsize" json:"bootsize,omitempty"` // The length in 512 octect blocks of the bootfile
RootPath string `env:"rootpath" json:"root_path,omitempty"` // The path name of the client's root disk
LeaseTime time.Duration `env:"lease" json:"lease,omitempty"` // The lease time, in seconds
RenewalTime time.Duration `env:"renewal" json:"renewal,omitempty"` // The renewal time, in seconds
RebindingTime time.Duration `env:"rebinding" json:"rebinding,omitempty"` // The rebinding time, in seconds
DHCPType string `env:"dhcptype" json:"dhcp_type,omitempty"` // DHCP message type (safely ignored)
ServerID string `env:"serverid" json:"server_id,omitempty"` // The IP of the server
Message string `env:"message" json:"reason,omitempty"` // Reason for a DHCPNAK
TFTPServerName string `env:"tftp" json:"tftp,omitempty"` // The TFTP server name
BootFileName string `env:"bootfile" json:"bootfile,omitempty"` // The boot file name
Uptime time.Duration `env:"uptime" json:"uptime,omitempty"` // The uptime of the device when the lease was obtained, in seconds
ClassIdentifier string `env:"classid" json:"class_identifier,omitempty"` // The class identifier
LeaseExpiry *time.Time `json:"lease_expiry,omitempty"` // The expiry time of the lease
isEmpty map[string]bool
InterfaceName string `json:"interface_name,omitempty"` // The name of the interface
DHCPClient string `json:"dhcp_client,omitempty"` // The DHCP client that obtained the lease
}
func (l *Lease) setIsEmpty(m map[string]bool) {
l.isEmpty = m
// IsIPv6 returns true if the DHCP lease is for an IPv6 address
func (d *DHCPLease) IsIPv6() bool {
return d.IPAddress.To4() == nil
}
func (l *Lease) IsEmpty(key string) bool {
return l.isEmpty[key]
// IPMask returns the IP mask for the DHCP lease
func (d *DHCPLease) IPMask() net.IPMask {
if d.IsIPv6() {
// TODO: not implemented
return nil
}
mask := net.ParseIP(d.Netmask.String())
return net.IPv4Mask(mask[12], mask[13], mask[14], mask[15])
}
func (l *Lease) ToJSON() string {
json, err := json.Marshal(l)
if err != nil {
return ""
// IPNet returns the IP net for the DHCP lease
func (d *DHCPLease) IPNet() *net.IPNet {
if d.IsIPv6() {
// TODO: not implemented
return nil
}
return &net.IPNet{
IP: d.IPAddress,
Mask: d.IPMask(),
}
return string(json)
}
func (l *Lease) SetLeaseExpiry() (time.Time, error) {
if l.Uptime == 0 || l.LeaseTime == 0 {
return time.Time{}, fmt.Errorf("uptime or lease time isn't set")
}
// get the uptime of the device
file, err := os.Open("/proc/uptime")
if err != nil {
return time.Time{}, fmt.Errorf("failed to open uptime file: %w", err)
}
defer file.Close()
var uptime time.Duration
scanner := bufio.NewScanner(file)
for scanner.Scan() {
text := scanner.Text()
parts := strings.Split(text, " ")
uptime, err = time.ParseDuration(parts[0] + "s")
if err != nil {
return time.Time{}, fmt.Errorf("failed to parse uptime: %w", err)
}
}
relativeLeaseRemaining := (l.Uptime + l.LeaseTime) - uptime
leaseExpiry := time.Now().Add(relativeLeaseRemaining)
l.LeaseExpiry = &leaseExpiry
return leaseExpiry, nil
}
func UnmarshalDHCPCLease(lease *Lease, str string) error {
// parse the lease file as a map
data := make(map[string]string)
for line := range strings.SplitSeq(str, "\n") {
line = strings.TrimSpace(line)
// skip empty lines and comments
if line == "" || strings.HasPrefix(line, "#") {
continue
}
parts := strings.SplitN(line, "=", 2)
if len(parts) != 2 {
continue
}
key := strings.TrimSpace(parts[0])
value := strings.TrimSpace(parts[1])
data[key] = value
}
// now iterate over the lease struct and set the values
leaseType := reflect.TypeOf(lease).Elem()
leaseValue := reflect.ValueOf(lease).Elem()
valuesParsed := make(map[string]bool)
for i := 0; i < leaseType.NumField(); i++ {
field := leaseValue.Field(i)
// get the env tag
key := leaseType.Field(i).Tag.Get("env")
if key == "" {
continue
}
valuesParsed[key] = false
// get the value from the data map
value, ok := data[key]
if !ok || value == "" {
continue
}
switch field.Interface().(type) {
case string:
field.SetString(value)
case int:
val, err := strconv.Atoi(value)
if err != nil {
continue
}
field.SetInt(int64(val))
case time.Duration:
val, err := time.ParseDuration(value + "s")
if err != nil {
continue
}
field.Set(reflect.ValueOf(val))
case net.IP:
ip := net.ParseIP(value)
if ip == nil {
continue
}
field.Set(reflect.ValueOf(ip))
case []net.IP:
val := make([]net.IP, 0)
for ipStr := range strings.FieldsSeq(value) {
ip := net.ParseIP(ipStr)
if ip == nil {
continue
}
val = append(val, ip)
}
field.Set(reflect.ValueOf(val))
default:
return fmt.Errorf("unsupported field `%s` type: %s", key, field.Type().String())
}
valuesParsed[key] = true
}
lease.setIsEmpty(valuesParsed)
return nil
}

View File

@ -0,0 +1,62 @@
package types
import (
"net"
"time"
"golang.org/x/sys/unix"
)
// InterfaceState represents the current state of a network interface
type InterfaceState struct {
InterfaceName string `json:"interface_name"`
Hostname string `json:"hostname"`
MACAddress string `json:"mac_address"`
Up bool `json:"up"`
Online bool `json:"online"`
IPv4Ready bool `json:"ipv4_ready"`
IPv6Ready bool `json:"ipv6_ready"`
IPv4Address string `json:"ipv4_address,omitempty"`
IPv6Address string `json:"ipv6_address,omitempty"`
IPv6LinkLocal string `json:"ipv6_link_local,omitempty"`
IPv6Gateway string `json:"ipv6_gateway,omitempty"`
IPv4Addresses []string `json:"ipv4_addresses,omitempty"`
IPv6Addresses []IPv6Address `json:"ipv6_addresses,omitempty"`
NTPServers []net.IP `json:"ntp_servers,omitempty"`
DHCPLease4 *DHCPLease `json:"dhcp_lease,omitempty"`
DHCPLease6 *DHCPLease `json:"dhcp_lease6,omitempty"`
LastUpdated time.Time `json:"last_updated"`
}
// RpcInterfaceState is the RPC representation of an interface state
type RpcInterfaceState struct {
InterfaceState
IPv6Addresses []RpcIPv6Address `json:"ipv6_addresses"`
}
// ToRpcInterfaceState converts an InterfaceState to a RpcInterfaceState
func (s *InterfaceState) ToRpcInterfaceState() *RpcInterfaceState {
addrs := make([]RpcIPv6Address, len(s.IPv6Addresses))
for i, addr := range s.IPv6Addresses {
addrs[i] = RpcIPv6Address{
Address: addr.Address.String(),
Prefix: addr.Prefix.String(),
ValidLifetime: addr.ValidLifetime,
PreferredLifetime: addr.PreferredLifetime,
Scope: addr.Scope,
Flags: addr.Flags,
FlagSecondary: addr.Flags&unix.IFA_F_SECONDARY != 0,
FlagPermanent: addr.Flags&unix.IFA_F_PERMANENT != 0,
FlagTemporary: addr.Flags&unix.IFA_F_TEMPORARY != 0,
FlagStablePrivacy: addr.Flags&unix.IFA_F_STABLE_PRIVACY != 0,
FlagDeprecated: addr.Flags&unix.IFA_F_DEPRECATED != 0,
FlagOptimistic: addr.Flags&unix.IFA_F_OPTIMISTIC != 0,
FlagDADFailed: addr.Flags&unix.IFA_F_DADFAILED != 0,
FlagTentative: addr.Flags&unix.IFA_F_TENTATIVE != 0,
}
}
return &RpcInterfaceState{
InterfaceState: *s,
IPv6Addresses: addrs,
}
}

View File

@ -0,0 +1,85 @@
package types
import (
"net"
"slices"
"time"
"github.com/vishvananda/netlink"
)
// IPAddress represents a network interface address
type IPAddress struct {
Family int
Address net.IPNet
Gateway net.IP
MTU int
Secondary bool
Permanent bool
}
func (a *IPAddress) String() string {
return a.Address.String()
}
func (a *IPAddress) Compare(n netlink.Addr) bool {
if !a.Address.IP.Equal(n.IP) {
return false
}
if slices.Compare(a.Address.Mask, n.Mask) != 0 {
return false
}
return true
}
func (a *IPAddress) NetlinkAddr() netlink.Addr {
return netlink.Addr{
IPNet: &a.Address,
}
}
func (a *IPAddress) DefaultRoute(linkIndex int) netlink.Route {
return netlink.Route{
Dst: nil,
Gw: a.Gateway,
LinkIndex: linkIndex,
}
}
// ParsedIPConfig represents the parsed IP configuration
type ParsedIPConfig struct {
Addresses []IPAddress
Nameservers []net.IP
SearchList []string
Domain string
MTU int
Interface string
}
// IPv6Address represents an IPv6 address with lifetime information
type IPv6Address struct {
Address net.IP `json:"address"`
Prefix net.IPNet `json:"prefix"`
ValidLifetime *time.Time `json:"valid_lifetime"`
PreferredLifetime *time.Time `json:"preferred_lifetime"`
Flags int `json:"flags"`
Scope int `json:"scope"`
}
// RpcIPv6Address is the RPC representation of an IPv6 address
type RpcIPv6Address struct {
Address string `json:"address"`
Prefix string `json:"prefix"`
ValidLifetime *time.Time `json:"valid_lifetime"`
PreferredLifetime *time.Time `json:"preferred_lifetime"`
Scope int `json:"scope"`
Flags int `json:"flags"`
FlagSecondary bool `json:"flag_secondary"`
FlagPermanent bool `json:"flag_permanent"`
FlagTemporary bool `json:"flag_temporary"`
FlagStablePrivacy bool `json:"flag_stable_privacy"`
FlagDeprecated bool `json:"flag_deprecated"`
FlagOptimistic bool `json:"flag_optimistic"`
FlagDADFailed bool `json:"flag_dad_failed"`
FlagTentative bool `json:"flag_tentative"`
}

View File

@ -0,0 +1,22 @@
package types
import "net"
// InterfaceResolvConf represents the DNS configuration for a network interface
type InterfaceResolvConf struct {
NameServers []net.IP `json:"nameservers"`
SearchList []string `json:"search_list"`
Domain string `json:"domain,omitempty"` // TODO: remove this once we have a better way to handle the domain
Source string `json:"source,omitempty"`
}
// InterfaceResolvConfMap ..
type InterfaceResolvConfMap map[string]InterfaceResolvConf
// ResolvConf represents the DNS configuration for the system
type ResolvConf struct {
ConfigIPv4 InterfaceResolvConfMap `json:"config_ipv4"`
ConfigIPv6 InterfaceResolvConfMap `json:"config_ipv6"`
Domain string `json:"domain"`
HostName string `json:"host_name"`
}

View File

@ -1,26 +0,0 @@
package network
import (
"encoding/json"
"time"
)
func lifetimeToTime(lifetime int) *time.Time {
if lifetime == 0 {
return nil
}
t := time.Now().Add(time.Duration(lifetime) * time.Second)
return &t
}
func IsSame(a, b any) bool {
aJSON, err := json.Marshal(a)
if err != nil {
return false
}
bJSON, err := json.Marshal(b)
if err != nil {
return false
}
return string(aJSON) == string(bJSON)
}

149
internal/sync/log.go Normal file
View File

@ -0,0 +1,149 @@
//go:build synctrace
package sync
import (
"fmt"
"reflect"
"runtime"
"sync"
"time"
"github.com/jetkvm/kvm/internal/logging"
"github.com/rs/zerolog"
)
var defaultLogger = logging.GetSubsystemLogger("synctrace")
func logTrace(msg string) {
if defaultLogger.GetLevel() > zerolog.TraceLevel {
return
}
logTrack(3).Trace().Msg(msg)
}
func logTrack(callerSkip int) *zerolog.Logger {
l := *defaultLogger
if l.GetLevel() > zerolog.TraceLevel {
return &l
}
pc, file, no, ok := runtime.Caller(callerSkip)
if ok {
l = l.With().
Str("file", file).
Int("line", no).
Logger()
details := runtime.FuncForPC(pc)
if details != nil {
l = l.With().
Str("func", details.Name()).
Logger()
}
}
return &l
}
func logLockTrack(i string) *zerolog.Logger {
l := logTrack(4).
With().
Str("index", i).
Logger()
return &l
}
var (
indexMu sync.Mutex
lockCount map[string]int = make(map[string]int)
unlockCount map[string]int = make(map[string]int)
lastLock map[string]time.Time = make(map[string]time.Time)
)
type trackable interface {
sync.Locker
}
func getIndex(t trackable) string {
ptr := reflect.ValueOf(t).Pointer()
return fmt.Sprintf("%x", ptr)
}
func increaseLockCount(i string) {
indexMu.Lock()
defer indexMu.Unlock()
if _, ok := lockCount[i]; !ok {
lockCount[i] = 0
}
lockCount[i]++
if _, ok := lastLock[i]; !ok {
lastLock[i] = time.Now()
}
}
func increaseUnlockCount(i string) {
indexMu.Lock()
defer indexMu.Unlock()
if _, ok := unlockCount[i]; !ok {
unlockCount[i] = 0
}
unlockCount[i]++
}
func logLock(t trackable) {
i := getIndex(t)
increaseLockCount(i)
logLockTrack(i).Trace().Msg("locking mutex")
}
func logUnlock(t trackable) {
i := getIndex(t)
increaseUnlockCount(i)
logLockTrack(i).Trace().Msg("unlocking mutex")
}
func logTryLock(t trackable) {
i := getIndex(t)
logLockTrack(i).Trace().Msg("trying to lock mutex")
}
func logTryLockResult(t trackable, l bool) {
if !l {
return
}
i := getIndex(t)
increaseLockCount(i)
logLockTrack(i).Trace().Msg("locked mutex")
}
func logRLock(t trackable) {
i := getIndex(t)
increaseLockCount(i)
logLockTrack(i).Trace().Msg("locking mutex for reading")
}
func logRUnlock(t trackable) {
i := getIndex(t)
increaseUnlockCount(i)
logLockTrack(i).Trace().Msg("unlocking mutex for reading")
}
func logTryRLock(t trackable) {
i := getIndex(t)
logLockTrack(i).Trace().Msg("trying to lock mutex for reading")
}
func logTryRLockResult(t trackable, l bool) {
if !l {
return
}
i := getIndex(t)
increaseLockCount(i)
logLockTrack(i).Trace().Msg("locked mutex for reading")
}

69
internal/sync/mutex.go Normal file
View File

@ -0,0 +1,69 @@
//go:build synctrace
package sync
import (
gosync "sync"
)
// Mutex is a wrapper around the sync.Mutex
type Mutex struct {
mu gosync.Mutex
}
// Lock locks the mutex
func (m *Mutex) Lock() {
logLock(m)
m.mu.Lock()
}
// Unlock unlocks the mutex
func (m *Mutex) Unlock() {
logUnlock(m)
m.mu.Unlock()
}
// TryLock tries to lock the mutex
func (m *Mutex) TryLock() bool {
logTryLock(m)
l := m.mu.TryLock()
logTryLockResult(m, l)
return l
}
// RWMutex is a wrapper around the sync.RWMutex
type RWMutex struct {
mu gosync.RWMutex
}
// Lock locks the mutex
func (m *RWMutex) Lock() {
logLock(m)
m.mu.Lock()
}
// Unlock unlocks the mutex
func (m *RWMutex) Unlock() {
logUnlock(m)
m.mu.Unlock()
}
// RLock locks the mutex for reading
func (m *RWMutex) RLock() {
logRLock(m)
m.mu.RLock()
}
// RUnlock unlocks the mutex for reading
func (m *RWMutex) RUnlock() {
logRUnlock(m)
m.mu.RUnlock()
}
// TryRLock tries to lock the mutex for reading
func (m *RWMutex) TryRLock() bool {
logTryRLock(m)
l := m.mu.TryRLock()
logTryRLockResult(m, l)
return l
}

18
internal/sync/once.go Normal file
View File

@ -0,0 +1,18 @@
//go:build synctrace
package sync
import (
gosync "sync"
)
// Once is a wrapper around the sync.Once
type Once struct {
mu gosync.Once
}
// Do calls the function f if and only if Do has not been called before for this instance of Once.
func (o *Once) Do(f func()) {
logTrace("Doing once")
o.mu.Do(f)
}

92
internal/sync/release.go Normal file
View File

@ -0,0 +1,92 @@
//go:build !synctrace
package sync
import (
gosync "sync"
)
// Mutex is a wrapper around the sync.Mutex
type Mutex struct {
mu gosync.Mutex
}
// Lock locks the mutex
func (m *Mutex) Lock() {
m.mu.Lock()
}
// Unlock unlocks the mutex
func (m *Mutex) Unlock() {
m.mu.Unlock()
}
// TryLock tries to lock the mutex
func (m *Mutex) TryLock() bool {
return m.mu.TryLock()
}
// RWMutex is a wrapper around the sync.RWMutex
type RWMutex struct {
mu gosync.RWMutex
}
// Lock locks the mutex
func (m *RWMutex) Lock() {
m.mu.Lock()
}
// Unlock unlocks the mutex
func (m *RWMutex) Unlock() {
m.mu.Unlock()
}
// RLock locks the mutex for reading
func (m *RWMutex) RLock() {
m.mu.RLock()
}
// RUnlock unlocks the mutex for reading
func (m *RWMutex) RUnlock() {
m.mu.RUnlock()
}
// TryRLock tries to lock the mutex for reading
func (m *RWMutex) TryRLock() bool {
return m.mu.TryRLock()
}
// TryLock tries to lock the mutex
func (m *RWMutex) TryLock() bool {
return m.mu.TryLock()
}
// WaitGroup is a wrapper around the sync.WaitGroup
type WaitGroup struct {
wg gosync.WaitGroup
}
// Add adds a function to the wait group
func (w *WaitGroup) Add(delta int) {
w.wg.Add(delta)
}
// Done decrements the wait group counter
func (w *WaitGroup) Done() {
w.wg.Done()
}
// Wait waits for the wait group to finish
func (w *WaitGroup) Wait() {
w.wg.Wait()
}
// Once is a wrapper around the sync.Once
type Once struct {
mu gosync.Once
}
// Do calls the function f if and only if Do has not been called before for this instance of Once.
func (o *Once) Do(f func()) {
o.mu.Do(f)
}

View File

@ -0,0 +1,30 @@
//go:build synctrace
package sync
import (
gosync "sync"
)
// WaitGroup is a wrapper around the sync.WaitGroup
type WaitGroup struct {
wg gosync.WaitGroup
}
// Add adds a function to the wait group
func (w *WaitGroup) Add(delta int) {
logTrace("Adding to wait group")
w.wg.Add(delta)
}
// Done decrements the wait group counter
func (w *WaitGroup) Done() {
logTrace("Done with wait group")
w.wg.Done()
}
// Wait waits for the wait group to finish
func (w *WaitGroup) Wait() {
logTrace("Waiting for wait group")
w.wg.Wait()
}

View File

@ -3,13 +3,14 @@ package timesync
import (
"context"
"math/rand/v2"
"net"
"strconv"
"time"
"github.com/beevik/ntp"
)
var defaultNTPServerIPs = []string{
var DefaultNTPServerIPs = []string{
// These servers are known by static IP and as such don't need DNS lookups
// These are from Google and Cloudflare since if they're down, the internet
// is broken anyway
@ -27,7 +28,7 @@ var defaultNTPServerIPs = []string{
"2001:4860:4806:c::", // time.google.com IPv6
}
var defaultNTPServerHostnames = []string{
var DefaultNTPServerHostnames = []string{
// should use something from https://github.com/jauderho/public-ntp-servers
"time.apple.com",
"time.aws.com",
@ -37,7 +38,48 @@ var defaultNTPServerHostnames = []string{
"pool.ntp.org",
}
func (t *TimeSync) filterNTPServers(ntpServers []string) ([]string, error) {
if len(ntpServers) == 0 {
return nil, nil
}
hasIPv4, err := t.preCheckIPv4()
if err != nil {
t.l.Error().Err(err).Msg("failed to check IPv4")
return nil, err
}
hasIPv6, err := t.preCheckIPv6()
if err != nil {
t.l.Error().Err(err).Msg("failed to check IPv6")
return nil, err
}
filteredServers := []string{}
for _, server := range ntpServers {
ip := net.ParseIP(server)
t.l.Trace().Str("server", server).Interface("ip", ip).Msg("checking NTP server")
if ip == nil {
continue
}
if hasIPv4 && ip.To4() != nil {
filteredServers = append(filteredServers, server)
}
if hasIPv6 && ip.To16() != nil {
filteredServers = append(filteredServers, server)
}
}
return filteredServers, nil
}
func (t *TimeSync) queryNetworkTime(ntpServers []string) (now *time.Time, offset *time.Duration) {
ntpServers, err := t.filterNTPServers(ntpServers)
if err != nil {
t.l.Error().Err(err).Msg("failed to filter NTP servers")
return nil, nil
}
chunkSize := int(t.networkConfig.TimeSyncParallel.ValueOr(4))
t.l.Info().Strs("servers", ntpServers).Int("chunkSize", chunkSize).Msg("querying NTP servers")

View File

@ -7,7 +7,7 @@ import (
"sync"
"time"
"github.com/jetkvm/kvm/internal/network"
"github.com/jetkvm/kvm/internal/network/types"
"github.com/rs/zerolog"
)
@ -24,11 +24,13 @@ var (
timeSyncRetryInterval = 0 * time.Second
)
type PreCheckFunc func() (bool, error)
type TimeSync struct {
syncLock *sync.Mutex
l *zerolog.Logger
networkConfig *network.NetworkConfig
networkConfig *types.NetworkConfig
dhcpNtpAddresses []string
rtcDevicePath string
@ -36,14 +38,19 @@ type TimeSync struct {
rtcLock *sync.Mutex
syncSuccess bool
timer *time.Timer
preCheckFunc func() (bool, error)
preCheckFunc PreCheckFunc
preCheckIPv4 PreCheckFunc
preCheckIPv6 PreCheckFunc
}
type TimeSyncOptions struct {
PreCheckFunc func() (bool, error)
PreCheckFunc PreCheckFunc
PreCheckIPv4 PreCheckFunc
PreCheckIPv6 PreCheckFunc
Logger *zerolog.Logger
NetworkConfig *network.NetworkConfig
NetworkConfig *types.NetworkConfig
}
type SyncMode struct {
@ -69,7 +76,10 @@ func NewTimeSync(opts *TimeSyncOptions) *TimeSync {
rtcDevicePath: rtcDevice,
rtcLock: &sync.Mutex{},
preCheckFunc: opts.PreCheckFunc,
preCheckIPv4: opts.PreCheckIPv4,
preCheckIPv6: opts.PreCheckIPv6,
networkConfig: opts.NetworkConfig,
timer: time.NewTimer(timeSyncWaitNetUpInt),
}
if t.rtcDevicePath != "" {
@ -112,49 +122,64 @@ func (t *TimeSync) getSyncMode() SyncMode {
}
}
t.l.Debug().Strs("Ordering", syncMode.Ordering).Bool("Ntp", syncMode.Ntp).Bool("Http", syncMode.Http).Bool("NtpUseFallback", syncMode.NtpUseFallback).Bool("HttpUseFallback", syncMode.HttpUseFallback).Msg("sync mode")
t.l.Debug().
Strs("Ordering", syncMode.Ordering).
Bool("Ntp", syncMode.Ntp).
Bool("Http", syncMode.Http).
Bool("NtpUseFallback", syncMode.NtpUseFallback).
Bool("HttpUseFallback", syncMode.HttpUseFallback).
Msg("sync mode")
return syncMode
}
func (t *TimeSync) doTimeSync() {
func (t *TimeSync) timeSyncLoop() {
metricTimeSyncStatus.Set(0)
for {
// use a timer here instead of sleep
for range t.timer.C {
if ok, err := t.preCheckFunc(); !ok {
if err != nil {
t.l.Error().Err(err).Msg("pre-check failed")
}
time.Sleep(timeSyncWaitNetChkInt)
t.timer.Reset(timeSyncWaitNetChkInt)
continue
}
t.l.Info().Msg("syncing system time")
start := time.Now()
err := t.Sync()
err := t.sync()
if err != nil {
t.l.Error().Str("error", err.Error()).Msg("failed to sync system time")
// retry after a delay
timeSyncRetryInterval += timeSyncRetryStep
time.Sleep(timeSyncRetryInterval)
t.timer.Reset(timeSyncRetryInterval)
// reset the retry interval if it exceeds the max interval
if timeSyncRetryInterval > timeSyncRetryMaxInt {
timeSyncRetryInterval = 0
}
continue
}
isInitialSync := !t.syncSuccess
t.syncSuccess = true
t.l.Info().Str("now", time.Now().Format(time.RFC3339)).
Str("time_taken", time.Since(start).String()).
Bool("is_initial_sync", isInitialSync).
Msg("time sync successful")
metricTimeSyncStatus.Set(1)
time.Sleep(timeSyncInterval) // after the first sync is done
t.timer.Reset(timeSyncInterval) // after the first sync is done
}
}
func (t *TimeSync) Sync() error {
func (t *TimeSync) sync() error {
t.syncLock.Lock()
defer t.syncLock.Unlock()
var (
now *time.Time
offset *time.Duration
@ -188,10 +213,10 @@ Orders:
case "ntp":
if syncMode.Ntp && syncMode.NtpUseFallback {
log.Info().Msg("using NTP fallback IPs")
now, offset = t.queryNetworkTime(defaultNTPServerIPs)
now, offset = t.queryNetworkTime(DefaultNTPServerIPs)
if now == nil {
log.Info().Msg("using NTP fallback hostnames")
now, offset = t.queryNetworkTime(defaultNTPServerHostnames)
now, offset = t.queryNetworkTime(DefaultNTPServerHostnames)
}
if now != nil {
break Orders
@ -239,12 +264,25 @@ Orders:
return nil
}
// Sync triggers a manual time sync
func (t *TimeSync) Sync() error {
if !t.syncLock.TryLock() {
t.l.Warn().Msg("sync already in progress, skipping")
return nil
}
t.syncLock.Unlock()
return t.sync()
}
// IsSyncSuccess returns true if the system time is synchronized
func (t *TimeSync) IsSyncSuccess() bool {
return t.syncSuccess
}
// Start starts the time sync
func (t *TimeSync) Start() {
go t.doTimeSync()
go t.timeSyncLoop()
}
func (t *TimeSync) setSystemTime(now time.Time) error {

View File

@ -1,7 +1,9 @@
package usbgadget
import (
"context"
"fmt"
"time"
"github.com/rs/zerolog"
"github.com/sourcegraph/tf-dag/dag"
@ -114,7 +116,20 @@ func (c *ChangeSetResolver) resolveChanges(initial bool) error {
}
func (c *ChangeSetResolver) applyChanges() error {
return c.applyChangesWithTimeout(45 * time.Second)
}
func (c *ChangeSetResolver) applyChangesWithTimeout(timeout time.Duration) error {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
for _, change := range c.resolvedChanges {
select {
case <-ctx.Done():
return fmt.Errorf("USB gadget reconfiguration timed out after %v: %w", timeout, ctx.Err())
default:
}
change.ResetActionResolution()
action := change.Action()
actionStr := FileChangeResolvedActionString[action]
@ -126,7 +141,7 @@ func (c *ChangeSetResolver) applyChanges() error {
l.Str("action", actionStr).Str("change", change.String()).Msg("applying change")
err := c.changeset.applyChange(change)
err := c.applyChangeWithTimeout(ctx, change)
if err != nil {
if change.IgnoreErrors {
c.l.Warn().Str("change", change.String()).Err(err).Msg("ignoring error")
@ -139,6 +154,20 @@ func (c *ChangeSetResolver) applyChanges() error {
return nil
}
func (c *ChangeSetResolver) applyChangeWithTimeout(ctx context.Context, change *FileChange) error {
done := make(chan error, 1)
go func() {
done <- c.changeset.applyChange(change)
}()
select {
case err := <-done:
return err
case <-ctx.Done():
return fmt.Errorf("change application timed out for %s: %w", change.String(), ctx.Err())
}
}
func (c *ChangeSetResolver) GetChanges() ([]*FileChange, error) {
localChanges := c.changeset.Changes
changesMap := make(map[string]*FileChange)

View File

@ -59,6 +59,23 @@ var defaultGadgetConfig = map[string]gadgetConfigItem{
// mass storage
"mass_storage_base": massStorageBaseConfig,
"mass_storage_lun0": massStorageLun0Config,
// audio (UAC1 - USB Audio Class 1)
"audio": {
order: 4000,
device: "uac1.usb0",
path: []string{"functions", "uac1.usb0"},
configPath: []string{"uac1.usb0"},
attrs: gadgetAttributes{
"p_chmask": "3", // Playback: stereo (2 channels)
"p_srate": "48000", // Playback: 48kHz sample rate
"p_ssize": "2", // Playback: 16-bit (2 bytes)
"p_volume_present": "0", // Playback: no volume control
"c_chmask": "3", // Capture: stereo (2 channels)
"c_srate": "48000", // Capture: 48kHz sample rate
"c_ssize": "2", // Capture: 16-bit (2 bytes)
"c_volume_present": "0", // Capture: no volume control
},
},
}
func (u *UsbGadget) isGadgetConfigItemEnabled(itemKey string) bool {
@ -73,6 +90,8 @@ func (u *UsbGadget) isGadgetConfigItemEnabled(itemKey string) bool {
return u.enabledDevices.MassStorage
case "mass_storage_lun0":
return u.enabledDevices.MassStorage
case "audio":
return u.enabledDevices.Audio
default:
return true
}
@ -182,6 +201,9 @@ func (u *UsbGadget) Init() error {
return u.logError("unable to initialize USB stack", err)
}
// Pre-open HID files to reduce input latency
u.PreOpenHidFiles()
return nil
}
@ -191,11 +213,17 @@ func (u *UsbGadget) UpdateGadgetConfig() error {
u.loadGadgetConfig()
// Close HID files before reconfiguration to prevent "file already closed" errors
u.CloseHidFiles()
err := u.configureUsbGadget(true)
if err != nil {
return u.logError("unable to update gadget config", err)
}
// Reopen HID files after reconfiguration
u.PreOpenHidFiles()
return nil
}

View File

@ -1,10 +1,12 @@
package usbgadget
import (
"context"
"fmt"
"path"
"path/filepath"
"sort"
"time"
"github.com/rs/zerolog"
)
@ -52,22 +54,49 @@ func (u *UsbGadget) newUsbGadgetTransaction(lock bool) error {
}
func (u *UsbGadget) WithTransaction(fn func() error) error {
u.txLock.Lock()
defer u.txLock.Unlock()
return u.WithTransactionTimeout(fn, 60*time.Second)
}
err := u.newUsbGadgetTransaction(false)
if err != nil {
u.log.Error().Err(err).Msg("failed to create transaction")
return err
}
if err := fn(); err != nil {
u.log.Error().Err(err).Msg("transaction failed")
return err
}
result := u.tx.Commit()
u.tx = nil
// WithTransactionTimeout executes a USB gadget transaction with a specified timeout
// to prevent indefinite blocking during USB reconfiguration operations
func (u *UsbGadget) WithTransactionTimeout(fn func() error, timeout time.Duration) error {
// Create a context with timeout for the entire transaction
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
return result
// Channel to signal when the transaction is complete
done := make(chan error, 1)
// Execute the transaction in a goroutine
go func() {
u.txLock.Lock()
defer u.txLock.Unlock()
err := u.newUsbGadgetTransaction(false)
if err != nil {
u.log.Error().Err(err).Msg("failed to create transaction")
done <- err
return
}
if err := fn(); err != nil {
u.log.Error().Err(err).Msg("transaction failed")
done <- err
return
}
result := u.tx.Commit()
u.tx = nil
done <- result
}()
// Wait for either completion or timeout
select {
case err := <-done:
return err
case <-ctx.Done():
return fmt.Errorf("USB gadget transaction timed out after %v: %w", timeout, ctx.Err())
}
}
func (tx *UsbGadgetTransaction) addFileChange(component string, change RequestedFileChange) string {

View File

@ -19,6 +19,7 @@ type Devices struct {
RelativeMouse bool `json:"relative_mouse"`
Keyboard bool `json:"keyboard"`
MassStorage bool `json:"mass_storage"`
Audio bool `json:"audio"`
}
// Config is a struct that represents the customizations for a USB gadget.
@ -39,6 +40,7 @@ var defaultUsbGadgetDevices = Devices{
RelativeMouse: true,
Keyboard: true,
MassStorage: true,
Audio: true,
}
type KeysDownState struct {
@ -188,3 +190,63 @@ func (u *UsbGadget) Close() error {
return nil
}
// CloseHidFiles closes all open HID files
func (u *UsbGadget) CloseHidFiles() {
u.log.Debug().Msg("closing HID files")
// Close keyboard HID file
if u.keyboardHidFile != nil {
if err := u.keyboardHidFile.Close(); err != nil {
u.log.Debug().Err(err).Msg("failed to close keyboard HID file")
}
u.keyboardHidFile = nil
}
// Close absolute mouse HID file
if u.absMouseHidFile != nil {
if err := u.absMouseHidFile.Close(); err != nil {
u.log.Debug().Err(err).Msg("failed to close absolute mouse HID file")
}
u.absMouseHidFile = nil
}
// Close relative mouse HID file
if u.relMouseHidFile != nil {
if err := u.relMouseHidFile.Close(); err != nil {
u.log.Debug().Err(err).Msg("failed to close relative mouse HID file")
}
u.relMouseHidFile = nil
}
}
// PreOpenHidFiles opens all HID files to reduce input latency
func (u *UsbGadget) PreOpenHidFiles() {
// Add a small delay to allow USB gadget reconfiguration to complete
// This prevents "no such device or address" errors when trying to open HID files
time.Sleep(100 * time.Millisecond)
if u.enabledDevices.Keyboard {
if err := u.openKeyboardHidFile(); err != nil {
u.log.Debug().Err(err).Msg("failed to pre-open keyboard HID file")
}
}
if u.enabledDevices.AbsoluteMouse {
if u.absMouseHidFile == nil {
var err error
u.absMouseHidFile, err = os.OpenFile("/dev/hidg1", os.O_RDWR, 0666)
if err != nil {
u.log.Debug().Err(err).Msg("failed to pre-open absolute mouse HID file")
}
}
}
if u.enabledDevices.RelativeMouse {
if u.relMouseHidFile == nil {
var err error
u.relMouseHidFile, err = os.OpenFile("/dev/hidg2", os.O_RDWR, 0666)
if err != nil {
u.log.Debug().Err(err).Msg("failed to pre-open relative mouse HID file")
}
}
}
}

View File

@ -175,6 +175,10 @@ func rpcGetDeviceID() (string, error) {
func rpcReboot(force bool) error {
logger.Info().Msg("Got reboot request from JSONRPC, rebooting...")
writeJSONRPCEvent("willReboot", nil, currentSession)
// Wait for the JSONRPCEvent to be sent
time.Sleep(1 * time.Second)
nativeInstance.SwitchToScreenIfDifferent("rebooting_screen")
args := []string{}
@ -700,7 +704,8 @@ func rpcSetUsbConfig(usbConfig usbgadget.Config) error {
LoadConfig()
config.UsbConfig = &usbConfig
gadget.SetGadgetConfig(config.UsbConfig)
return updateUsbRelatedConfig()
wasAudioEnabled := config.UsbDevices != nil && config.UsbDevices.Audio
return updateUsbRelatedConfig(wasAudioEnabled)
}
func rpcGetWakeOnLanDevices() ([]WakeOnLanDevice, error) {
@ -720,7 +725,8 @@ func rpcSetWakeOnLanDevices(params SetWakeOnLanDevicesParams) error {
}
func rpcResetConfig() error {
config = defaultConfig
defaultConfig := getDefaultConfig()
config = &defaultConfig
if err := SaveConfig(); err != nil {
return fmt.Errorf("failed to reset config: %w", err)
}
@ -911,23 +917,67 @@ func rpcGetUsbDevices() (usbgadget.Devices, error) {
return *config.UsbDevices, nil
}
func updateUsbRelatedConfig() error {
func updateUsbRelatedConfig(wasAudioEnabled bool) error {
ensureConfigLoaded()
audioSourceChanged := false
// If USB audio is being disabled and audio output source is USB, switch to HDMI
if config.UsbDevices != nil && !config.UsbDevices.Audio && config.AudioOutputSource == "usb" {
audioMutex.Lock()
config.AudioOutputSource = "hdmi"
useUSBForAudioOutput = false
audioSourceChanged = true
audioMutex.Unlock()
}
// If USB audio is being enabled (was disabled, now enabled), switch to USB
if config.UsbDevices != nil && config.UsbDevices.Audio && !wasAudioEnabled {
audioMutex.Lock()
config.AudioOutputSource = "usb"
useUSBForAudioOutput = true
audioSourceChanged = true
audioMutex.Unlock()
}
// Stop audio before USB reconfiguration
// Input always uses USB, output depends on audioSourceChanged
audioMutex.Lock()
stopInputLocked()
if audioSourceChanged {
stopOutputLocked()
}
audioMutex.Unlock()
if err := gadget.UpdateGadgetConfig(); err != nil {
return fmt.Errorf("failed to write gadget config: %w", err)
}
if err := SaveConfig(); err != nil {
return fmt.Errorf("failed to save config: %w", err)
}
// Restart audio if source changed or USB audio is enabled with active connections
// The relay handles device readiness via retry logic
if activeConnections.Load() > 0 && (audioSourceChanged || (config.UsbDevices != nil && config.UsbDevices.Audio)) {
if err := startAudio(); err != nil {
logger.Warn().Err(err).Msg("Failed to restart audio after USB reconfiguration")
}
}
return nil
}
func rpcSetUsbDevices(usbDevices usbgadget.Devices) error {
wasAudioEnabled := config.UsbDevices != nil && config.UsbDevices.Audio
config.UsbDevices = &usbDevices
gadget.SetGadgetDevices(config.UsbDevices)
return updateUsbRelatedConfig()
return updateUsbRelatedConfig(wasAudioEnabled)
}
func rpcSetUsbDeviceState(device string, enabled bool) error {
wasAudioEnabled := config.UsbDevices != nil && config.UsbDevices.Audio
switch device {
case "absoluteMouse":
config.UsbDevices.AbsoluteMouse = enabled
@ -937,11 +987,43 @@ func rpcSetUsbDeviceState(device string, enabled bool) error {
config.UsbDevices.Keyboard = enabled
case "massStorage":
config.UsbDevices.MassStorage = enabled
case "audio":
config.UsbDevices.Audio = enabled
default:
return fmt.Errorf("invalid device: %s", device)
}
gadget.SetGadgetDevices(config.UsbDevices)
return updateUsbRelatedConfig()
return updateUsbRelatedConfig(wasAudioEnabled)
}
func rpcGetAudioOutputSource() (string, error) {
ensureConfigLoaded()
return config.AudioOutputSource, nil
}
func rpcSetAudioOutputSource(source string) error {
if source != "hdmi" && source != "usb" {
return fmt.Errorf("invalid audio output source: %s (must be 'hdmi' or 'usb')", source)
}
useUSB := source == "usb"
return SetAudioOutputSource(useUSB)
}
func rpcGetAudioOutputEnabled() (bool, error) {
return audioOutputEnabled.Load(), nil
}
func rpcSetAudioOutputEnabled(enabled bool) error {
return SetAudioOutputEnabled(enabled)
}
func rpcGetAudioInputEnabled() (bool, error) {
return audioInputEnabled.Load(), nil
}
func rpcSetAudioInputEnabled(enabled bool) error {
return SetAudioInputEnabled(enabled)
}
func rpcSetCloudUrl(apiUrl string, appUrl string) error {
@ -1262,6 +1344,12 @@ var rpcHandlers = map[string]RPCHandler{
"getUsbDevices": {Func: rpcGetUsbDevices},
"setUsbDevices": {Func: rpcSetUsbDevices, Params: []string{"devices"}},
"setUsbDeviceState": {Func: rpcSetUsbDeviceState, Params: []string{"device", "enabled"}},
"getAudioOutputSource": {Func: rpcGetAudioOutputSource},
"setAudioOutputSource": {Func: rpcSetAudioOutputSource, Params: []string{"source"}},
"getAudioOutputEnabled": {Func: rpcGetAudioOutputEnabled},
"setAudioOutputEnabled": {Func: rpcSetAudioOutputEnabled, Params: []string{"enabled"}},
"getAudioInputEnabled": {Func: rpcGetAudioInputEnabled},
"setAudioInputEnabled": {Func: rpcSetAudioInputEnabled, Params: []string{"enabled"}},
"setCloudUrl": {Func: rpcSetCloudUrl, Params: []string{"apiUrl", "appUrl"}},
"getKeyboardLayout": {Func: rpcGetKeyboardLayout},
"setKeyboardLayout": {Func: rpcSetKeyboardLayout, Params: []string{"layout"}},

View File

@ -33,7 +33,9 @@ func Main() {
go runWatchdog()
go confirmCurrentSystem()
initDisplay()
initNative(systemVersionLocal, appVersionLocal)
initAudio()
http.DefaultClient.Timeout = 1 * time.Minute
@ -74,9 +76,6 @@ func Main() {
}
initJiggler()
// initialize display
initDisplay()
// start video sleep mode timer
startVideoSleepModeTicker()
@ -126,6 +125,9 @@ func Main() {
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
<-sigs
logger.Info().Msg("JetKVM Shutting Down")
stopAudio()
//if fuseServer != nil {
// err := setMassStorageImage(" ")
// if err != nil {

16
mdns.go
View File

@ -1,19 +1,23 @@
package kvm
import (
"fmt"
"github.com/jetkvm/kvm/internal/mdns"
)
var mDNS *mdns.MDNS
func initMdns() error {
options := getMdnsOptions()
if options == nil {
return fmt.Errorf("failed to get mDNS options")
}
m, err := mdns.NewMDNS(&mdns.MDNSOptions{
Logger: logger,
LocalNames: []string{
networkState.GetHostname(),
networkState.GetFQDN(),
},
ListenOptions: config.NetworkConfig.GetMDNSMode(),
Logger: logger,
LocalNames: options.LocalNames,
ListenOptions: options.ListenOptions,
})
if err != nil {
return err

View File

@ -43,6 +43,8 @@ func initNative(systemVersion *semver.Version, appVersion *semver.Version) {
_ = rpcReboot(true)
case "reboot":
_ = rpcReboot(true)
case "toggleDHCPClient":
_ = rpcToggleDHCPClient()
default:
nativeLogger.Warn().Str("event", event).Msg("unknown rpc event received")
}

View File

@ -1,10 +1,14 @@
package kvm
import (
"context"
"fmt"
"reflect"
"github.com/jetkvm/kvm/internal/network"
"github.com/jetkvm/kvm/internal/udhcpc"
"github.com/jetkvm/kvm/internal/confparser"
"github.com/jetkvm/kvm/internal/mdns"
"github.com/jetkvm/kvm/internal/network/types"
"github.com/jetkvm/kvm/pkg/nmlite"
)
const (
@ -12,114 +16,297 @@ const (
)
var (
networkState *network.NetworkInterfaceState
networkManager *nmlite.NetworkManager
)
func networkStateChanged(isOnline bool) {
// do not block the main thread
go waitCtrlAndRequestDisplayUpdate(true, "network_state_changed")
type RpcNetworkSettings struct {
types.NetworkConfig
}
if timeSync != nil {
if networkState != nil {
timeSync.SetDhcpNtpAddresses(networkState.NtpAddressesString())
func (s *RpcNetworkSettings) ToNetworkConfig() *types.NetworkConfig {
return &s.NetworkConfig
}
type PostRebootAction struct {
HealthCheck string `json:"healthCheck"`
RedirectUrl string `json:"redirectUrl"`
}
func toRpcNetworkSettings(config *types.NetworkConfig) *RpcNetworkSettings {
return &RpcNetworkSettings{
NetworkConfig: *config,
}
}
func getMdnsOptions() *mdns.MDNSOptions {
if networkManager == nil {
return nil
}
var ipv4, ipv6 bool
switch config.NetworkConfig.MDNSMode.String {
case "auto":
ipv4 = true
ipv6 = true
case "ipv4_only":
ipv4 = true
case "ipv6_only":
ipv6 = true
}
return &mdns.MDNSOptions{
LocalNames: []string{
networkManager.Hostname(),
networkManager.FQDN(),
},
ListenOptions: &mdns.MDNSListenOptions{
IPv4: ipv4,
IPv6: ipv6,
},
}
}
func restartMdns() {
if mDNS == nil {
return
}
options := getMdnsOptions()
if options == nil {
return
}
if err := mDNS.SetOptions(options); err != nil {
networkLogger.Error().Err(err).Msg("failed to restart mDNS")
}
}
func triggerTimeSyncOnNetworkStateChange() {
if timeSync == nil {
return
}
// set the NTP servers from the network manager
if networkManager != nil {
ntpServers := make([]string, len(networkManager.NTPServers()))
for i, server := range networkManager.NTPServers() {
ntpServers[i] = server.String()
}
networkLogger.Info().Strs("ntpServers", ntpServers).Msg("setting NTP servers from network manager")
timeSync.SetDhcpNtpAddresses(ntpServers)
}
// sync time
go func() {
if err := timeSync.Sync(); err != nil {
networkLogger.Error().Err(err).Msg("failed to sync time after network state change")
}
}()
}
func networkStateChanged(_ string, state types.InterfaceState) {
// do not block the main thread
go waitCtrlAndRequestDisplayUpdate(true, "network_state_changed")
if currentSession != nil {
writeJSONRPCEvent("networkState", state.ToRpcInterfaceState(), currentSession)
}
if state.Online {
networkLogger.Info().Msg("network state changed to online, triggering time sync")
triggerTimeSyncOnNetworkStateChange()
}
// always restart mDNS when the network state changes
if mDNS != nil {
_ = mDNS.SetListenOptions(config.NetworkConfig.GetMDNSMode())
_ = mDNS.SetLocalNames([]string{
networkState.GetHostname(),
networkState.GetFQDN(),
}, true)
restartMdns()
}
}
func validateNetworkConfig() {
err := confparser.SetDefaultsAndValidate(config.NetworkConfig)
if err == nil {
return
}
// if the network is now online, trigger an NTP sync if still needed
if isOnline && timeSync != nil && (isTimeSyncNeeded() || !timeSync.IsSyncSuccess()) {
if err := timeSync.Sync(); err != nil {
logger.Warn().Str("error", err.Error()).Msg("unable to sync time on network state change")
}
networkLogger.Error().Err(err).Msg("failed to validate config, reverting to default config")
if err := SaveBackupConfig(); err != nil {
networkLogger.Error().Err(err).Msg("failed to save backup config")
}
// do not use a pointer to the default config
// it has been already changed during LoadConfig
config.NetworkConfig = &(types.NetworkConfig{})
if err := SaveConfig(); err != nil {
networkLogger.Error().Err(err).Msg("failed to save config")
}
}
func initNetwork() error {
ensureConfigLoaded()
state, err := network.NewNetworkInterfaceState(&network.NetworkInterfaceOptions{
DefaultHostname: GetDefaultHostname(),
InterfaceName: NetIfName,
NetworkConfig: config.NetworkConfig,
Logger: networkLogger,
OnStateChange: func(state *network.NetworkInterfaceState) {
networkStateChanged(state.IsOnline())
},
OnInitialCheck: func(state *network.NetworkInterfaceState) {
networkStateChanged(state.IsOnline())
},
OnDhcpLeaseChange: func(lease *udhcpc.Lease, state *network.NetworkInterfaceState) {
networkStateChanged(state.IsOnline())
// validate the config, if it's invalid, revert to the default config and save the backup
validateNetworkConfig()
if currentSession == nil {
return
}
nc := config.NetworkConfig
writeJSONRPCEvent("networkState", networkState.RpcGetNetworkState(), currentSession)
},
OnConfigChange: func(networkConfig *network.NetworkConfig) {
config.NetworkConfig = networkConfig
networkStateChanged(false)
if mDNS != nil {
_ = mDNS.SetListenOptions(networkConfig.GetMDNSMode())
_ = mDNS.SetLocalNames([]string{
networkState.GetHostname(),
networkState.GetFQDN(),
}, true)
}
},
})
if state == nil {
if err == nil {
return fmt.Errorf("failed to create NetworkInterfaceState")
}
return err
nm := nmlite.NewNetworkManager(context.Background(), networkLogger)
networkLogger.Info().Interface("networkConfig", nc).Str("hostname", nc.Hostname.String).Str("domain", nc.Domain.String).Msg("initializing network manager")
_ = setHostname(nm, nc.Hostname.String, nc.Domain.String)
nm.SetOnInterfaceStateChange(networkStateChanged)
if err := nm.AddInterface(NetIfName, nc); err != nil {
return fmt.Errorf("failed to add interface: %w", err)
}
_ = nm.CleanUpLegacyDHCPClients()
if err := state.Run(); err != nil {
return err
}
networkState = state
networkManager = nm
return nil
}
func rpcGetNetworkState() network.RpcNetworkState {
return networkState.RpcGetNetworkState()
func setHostname(nm *nmlite.NetworkManager, hostname, domain string) error {
if nm == nil {
return nil
}
if hostname == "" {
hostname = GetDefaultHostname()
}
return nm.SetHostname(hostname, domain)
}
func rpcGetNetworkSettings() network.RpcNetworkSettings {
return networkState.RpcGetNetworkSettings()
func shouldRebootForNetworkChange(oldConfig, newConfig *types.NetworkConfig) (rebootRequired bool, postRebootAction *PostRebootAction) {
oldDhcpClient := oldConfig.DHCPClient.String
l := networkLogger.With().
Interface("old", oldConfig).
Interface("new", newConfig).
Logger()
// DHCP client change always requires reboot
if newConfig.DHCPClient.String != oldDhcpClient {
rebootRequired = true
l.Info().Msg("DHCP client changed, reboot required")
return rebootRequired, postRebootAction
}
oldIPv4Mode := oldConfig.IPv4Mode.String
newIPv4Mode := newConfig.IPv4Mode.String
// IPv4 mode change requires reboot
if newIPv4Mode != oldIPv4Mode {
rebootRequired = true
l.Info().Msg("IPv4 mode changed with udhcpc, reboot required")
if newIPv4Mode == "static" && oldIPv4Mode != "static" {
postRebootAction = &PostRebootAction{
HealthCheck: fmt.Sprintf("//%s/device/status", newConfig.IPv4Static.Address.String),
RedirectUrl: fmt.Sprintf("//%s", newConfig.IPv4Static.Address.String),
}
l.Info().Interface("postRebootAction", postRebootAction).Msg("IPv4 mode changed to static, reboot required")
}
return rebootRequired, postRebootAction
}
// IPv4 static config changes require reboot
if !reflect.DeepEqual(oldConfig.IPv4Static, newConfig.IPv4Static) {
rebootRequired = true
// Handle IP change for redirect (only if both are not nil and IP changed)
if newConfig.IPv4Static != nil && oldConfig.IPv4Static != nil &&
newConfig.IPv4Static.Address.String != oldConfig.IPv4Static.Address.String {
postRebootAction = &PostRebootAction{
HealthCheck: fmt.Sprintf("//%s/device/status", newConfig.IPv4Static.Address.String),
RedirectUrl: fmt.Sprintf("//%s", newConfig.IPv4Static.Address.String),
}
l.Info().Interface("postRebootAction", postRebootAction).Msg("IPv4 static config changed, reboot required")
}
return rebootRequired, postRebootAction
}
// IPv6 mode change requires reboot when using udhcpc
if newConfig.IPv6Mode.String != oldConfig.IPv6Mode.String && oldDhcpClient == "udhcpc" {
rebootRequired = true
l.Info().Msg("IPv6 mode changed with udhcpc, reboot required")
}
return rebootRequired, postRebootAction
}
func rpcSetNetworkSettings(settings network.RpcNetworkSettings) (*network.RpcNetworkSettings, error) {
s := networkState.RpcSetNetworkSettings(settings)
func rpcGetNetworkState() *types.RpcInterfaceState {
state, _ := networkManager.GetInterfaceState(NetIfName)
return state.ToRpcInterfaceState()
}
func rpcGetNetworkSettings() *RpcNetworkSettings {
return toRpcNetworkSettings(config.NetworkConfig)
}
func rpcSetNetworkSettings(settings RpcNetworkSettings) (*RpcNetworkSettings, error) {
netConfig := settings.ToNetworkConfig()
l := networkLogger.With().
Str("interface", NetIfName).
Interface("newConfig", netConfig).
Logger()
l.Debug().Msg("setting new config")
// Check if reboot is needed
rebootRequired, postRebootAction := shouldRebootForNetworkChange(config.NetworkConfig, netConfig)
// If reboot required, send willReboot event before applying network config
if rebootRequired {
l.Info().Msg("Sending willReboot event before applying network config")
writeJSONRPCEvent("willReboot", postRebootAction, currentSession)
}
_ = setHostname(networkManager, netConfig.Hostname.String, netConfig.Domain.String)
s := networkManager.SetInterfaceConfig(NetIfName, netConfig)
if s != nil {
return nil, s
}
l.Debug().Msg("new config applied")
newConfig, err := networkManager.GetInterfaceConfig(NetIfName)
if err != nil {
return nil, err
}
config.NetworkConfig = newConfig
l.Debug().Msg("saving new config")
if err := SaveConfig(); err != nil {
return nil, err
}
return &network.RpcNetworkSettings{NetworkConfig: *config.NetworkConfig}, nil
if rebootRequired {
if err := rpcReboot(false); err != nil {
return nil, err
}
}
return toRpcNetworkSettings(newConfig), nil
}
func rpcRenewDHCPLease() error {
return networkState.RpcRenewDHCPLease()
return networkManager.RenewDHCPLease(NetIfName)
}
func rpcToggleDHCPClient() error {
switch config.NetworkConfig.DHCPClient.String {
case "jetdhcpc":
config.NetworkConfig.DHCPClient.String = "udhcpc"
case "udhcpc":
config.NetworkConfig.DHCPClient.String = "jetdhcpc"
}
if err := SaveConfig(); err != nil {
return err
}
return rpcReboot(true)
}

9
ota.go
View File

@ -488,6 +488,15 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
if rebootNeeded {
scopedLogger.Info().Msg("System Rebooting in 10s")
// TODO: Future enhancement - send postRebootAction to redirect to release notes
// Example:
// postRebootAction := &PostRebootAction{
// HealthCheck: "[..]/device/status",
// RedirectUrl: "[..]/settings/general/update?version=X.Y.Z",
// }
// writeJSONRPCEvent("willReboot", postRebootAction, currentSession)
time.Sleep(10 * time.Second)
cmd := exec.Command("reboot")
err := cmd.Start()

219
pkg/nmlite/dhcp.go Normal file
View File

@ -0,0 +1,219 @@
// Package nmlite provides DHCP client functionality for the network manager.
package nmlite
import (
"context"
"fmt"
"github.com/jetkvm/kvm/internal/network/types"
"github.com/jetkvm/kvm/pkg/nmlite/jetdhcpc"
"github.com/jetkvm/kvm/pkg/nmlite/udhcpc"
"github.com/rs/zerolog"
)
// DHCPClient wraps the dhclient package for use in the network manager
type DHCPClient struct {
ctx context.Context
ifaceName string
logger *zerolog.Logger
client types.DHCPClient
clientType string
// Configuration
ipv4Enabled bool
ipv6Enabled bool
// Callbacks
onLeaseChange func(lease *types.DHCPLease)
}
// NewDHCPClient creates a new DHCP client
func NewDHCPClient(ctx context.Context, ifaceName string, logger *zerolog.Logger, clientType string) (*DHCPClient, error) {
if ifaceName == "" {
return nil, fmt.Errorf("interface name cannot be empty")
}
if logger == nil {
return nil, fmt.Errorf("logger cannot be nil")
}
return &DHCPClient{
ctx: ctx,
ifaceName: ifaceName,
logger: logger,
clientType: clientType,
}, nil
}
// SetIPv4 enables or disables IPv4 DHCP
func (dc *DHCPClient) SetIPv4(enabled bool) {
dc.ipv4Enabled = enabled
if dc.client != nil {
dc.client.SetIPv4(enabled)
}
}
// SetIPv6 enables or disables IPv6 DHCP
func (dc *DHCPClient) SetIPv6(enabled bool) {
dc.ipv6Enabled = enabled
if dc.client != nil {
dc.client.SetIPv6(enabled)
}
}
// SetOnLeaseChange sets the callback for lease changes
func (dc *DHCPClient) SetOnLeaseChange(callback func(lease *types.DHCPLease)) {
dc.onLeaseChange = callback
}
func (dc *DHCPClient) initClient() (types.DHCPClient, error) {
switch dc.clientType {
case "jetdhcpc":
return dc.initJetDHCPC()
case "udhcpc":
return dc.initUDHCPC()
default:
return nil, fmt.Errorf("invalid client type: %s", dc.clientType)
}
}
func (dc *DHCPClient) initJetDHCPC() (types.DHCPClient, error) {
return jetdhcpc.NewClient(dc.ctx, []string{dc.ifaceName}, &jetdhcpc.Config{
IPv4: dc.ipv4Enabled,
IPv6: dc.ipv6Enabled,
V4ClientIdentifier: true,
OnLease4Change: func(lease *types.DHCPLease) {
dc.handleLeaseChange(lease, false)
},
OnLease6Change: func(lease *types.DHCPLease) {
dc.handleLeaseChange(lease, true)
},
UpdateResolvConf: func(nameservers []string) error {
// This will be handled by the resolv.conf manager
dc.logger.Debug().
Interface("nameservers", nameservers).
Msg("DHCP client requested resolv.conf update")
return nil
},
}, dc.logger)
}
func (dc *DHCPClient) initUDHCPC() (types.DHCPClient, error) {
c := udhcpc.NewDHCPClient(&udhcpc.DHCPClientOptions{
InterfaceName: dc.ifaceName,
PidFile: "",
Logger: dc.logger,
OnLeaseChange: func(lease *types.DHCPLease) {
dc.handleLeaseChange(lease, false)
},
})
return c, nil
}
// Start starts the DHCP client
func (dc *DHCPClient) Start() error {
if dc.client != nil {
dc.logger.Warn().Msg("DHCP client already started")
return nil
}
dc.logger.Info().Msg("starting DHCP client")
// Create the underlying DHCP client
client, err := dc.initClient()
if err != nil {
return fmt.Errorf("failed to create DHCP client: %w", err)
}
dc.client = client
// Start the client
if err := dc.client.Start(); err != nil {
dc.client = nil
return fmt.Errorf("failed to start DHCP client: %w", err)
}
dc.logger.Info().Msg("DHCP client started")
return nil
}
func (dc *DHCPClient) Domain() string {
if dc.client == nil {
return ""
}
return dc.client.Domain()
}
func (dc *DHCPClient) Lease4() *types.DHCPLease {
if dc.client == nil {
return nil
}
return dc.client.Lease4()
}
func (dc *DHCPClient) Lease6() *types.DHCPLease {
if dc.client == nil {
return nil
}
return dc.client.Lease6()
}
// Stop stops the DHCP client
func (dc *DHCPClient) Stop() error {
if dc.client == nil {
return nil
}
dc.logger.Info().Msg("stopping DHCP client")
dc.client = nil
dc.logger.Info().Msg("DHCP client stopped")
return nil
}
// Renew renews the DHCP lease
func (dc *DHCPClient) Renew() error {
if dc.client == nil {
return fmt.Errorf("DHCP client not started")
}
dc.logger.Info().Msg("renewing DHCP lease")
if err := dc.client.Renew(); err != nil {
return fmt.Errorf("failed to renew DHCP lease: %w", err)
}
return nil
}
// Release releases the DHCP lease
func (dc *DHCPClient) Release() error {
if dc.client == nil {
return fmt.Errorf("DHCP client not started")
}
dc.logger.Info().Msg("releasing DHCP lease")
if err := dc.client.Release(); err != nil {
return fmt.Errorf("failed to release DHCP lease: %w", err)
}
return nil
}
// handleLeaseChange handles lease changes from the underlying DHCP client
func (dc *DHCPClient) handleLeaseChange(lease *types.DHCPLease, isIPv6 bool) {
if lease == nil {
return
}
dc.logger.Info().
Bool("ipv6", isIPv6).
Str("ip", lease.IPAddress.String()).
Msg("DHCP lease changed")
// copy the lease to avoid race conditions
leaseCopy := *lease
// Notify callback
if dc.onLeaseChange != nil {
dc.onLeaseChange(&leaseCopy)
}
}

261
pkg/nmlite/hostname.go Normal file
View File

@ -0,0 +1,261 @@
package nmlite
import (
"fmt"
"io"
"os"
"os/exec"
"strings"
"golang.org/x/net/idna"
)
const (
hostnamePath = "/etc/hostname"
hostsPath = "/etc/hosts"
)
// SetHostname sets the system hostname and updates /etc/hosts
func (hm *ResolvConfManager) SetHostname(hostname, domain string) error {
hostname = ToValidHostname(strings.TrimSpace(hostname))
domain = ToValidHostname(strings.TrimSpace(domain))
if hostname == "" {
return fmt.Errorf("invalid hostname: %s", hostname)
}
hm.hostname = hostname
hm.domain = domain
return hm.reconcileHostname()
}
func (hm *ResolvConfManager) Domain() string {
hm.mu.Lock()
defer hm.mu.Unlock()
return hm.getDomain()
}
func (hm *ResolvConfManager) Hostname() string {
hm.mu.Lock()
defer hm.mu.Unlock()
return hm.getHostname()
}
func (hm *ResolvConfManager) FQDN() string {
hm.mu.Lock()
defer hm.mu.Unlock()
return hm.getFQDN()
}
func (hm *ResolvConfManager) getFQDN() string {
hostname := hm.getHostname()
domain := hm.getDomain()
if domain == "" {
return hostname
}
return fmt.Sprintf("%s.%s", hostname, domain)
}
func (hm *ResolvConfManager) getHostname() string {
if hm.hostname != "" {
return hm.hostname
}
return "jetkvm"
}
func (hm *ResolvConfManager) getDomain() string {
if hm.domain != "" {
return hm.domain
}
for _, iface := range hm.conf.ConfigIPv4 {
if iface.Domain != "" {
return iface.Domain
}
}
for _, iface := range hm.conf.ConfigIPv6 {
if iface.Domain != "" {
return iface.Domain
}
}
return "local"
}
func (hm *ResolvConfManager) reconcileHostname() error {
hm.mu.Lock()
domain := hm.getDomain()
hostname := hm.hostname
if hostname == "" {
hostname = "jetkvm"
}
hm.mu.Unlock()
fqdn := hostname
if fqdn != "" {
fqdn = fmt.Sprintf("%s.%s", hostname, domain)
}
hm.logger.Info().
Str("hostname", hostname).
Str("fqdn", fqdn).
Msg("setting hostname")
// Update /etc/hostname
if err := hm.updateEtcHostname(hostname); err != nil {
return fmt.Errorf("failed to update /etc/hostname: %w", err)
}
// Update /etc/hosts
if err := hm.updateEtcHosts(hostname, fqdn); err != nil {
return fmt.Errorf("failed to update /etc/hosts: %w", err)
}
// Set the hostname using hostname command
if err := hm.setSystemHostname(hostname); err != nil {
return fmt.Errorf("failed to set system hostname: %w", err)
}
hm.logger.Info().
Str("hostname", hostname).
Str("fqdn", fqdn).
Msg("hostname set successfully")
return nil
}
// GetCurrentHostname returns the current system hostname
func (hm *ResolvConfManager) GetCurrentHostname() (string, error) {
return os.Hostname()
}
// GetCurrentFQDN returns the current FQDN
func (hm *ResolvConfManager) GetCurrentFQDN() (string, error) {
hostname, err := hm.GetCurrentHostname()
if err != nil {
return "", err
}
// Try to get the FQDN from /etc/hosts
return hm.getFQDNFromHosts(hostname)
}
// updateEtcHostname updates the /etc/hostname file
func (hm *ResolvConfManager) updateEtcHostname(hostname string) error {
if err := os.WriteFile(hostnamePath, []byte(hostname), 0644); err != nil {
return fmt.Errorf("failed to write %s: %w", hostnamePath, err)
}
hm.logger.Debug().Str("file", hostnamePath).Str("hostname", hostname).Msg("updated /etc/hostname")
return nil
}
// updateEtcHosts updates the /etc/hosts file
func (hm *ResolvConfManager) updateEtcHosts(hostname, fqdn string) error {
// Open /etc/hosts for reading and writing
hostsFile, err := os.OpenFile(hostsPath, os.O_RDWR|os.O_SYNC, os.ModeExclusive)
if err != nil {
return fmt.Errorf("failed to open %s: %w", hostsPath, err)
}
defer hostsFile.Close()
// Read all lines
if _, err := hostsFile.Seek(0, io.SeekStart); err != nil {
return fmt.Errorf("failed to seek %s: %w", hostsPath, err)
}
lines, err := io.ReadAll(hostsFile)
if err != nil {
return fmt.Errorf("failed to read %s: %w", hostsPath, err)
}
// Process lines
newLines := []string{}
hostLine := fmt.Sprintf("127.0.1.1\t%s %s", hostname, fqdn)
hostLineExists := false
for _, line := range strings.Split(string(lines), "\n") {
if strings.HasPrefix(line, "127.0.1.1") {
hostLineExists = true
line = hostLine
}
newLines = append(newLines, line)
}
// Add host line if it doesn't exist
if !hostLineExists {
newLines = append(newLines, hostLine)
}
// Write back to file
if err := hostsFile.Truncate(0); err != nil {
return fmt.Errorf("failed to truncate %s: %w", hostsPath, err)
}
if _, err := hostsFile.Seek(0, io.SeekStart); err != nil {
return fmt.Errorf("failed to seek %s: %w", hostsPath, err)
}
if _, err := hostsFile.Write([]byte(strings.Join(newLines, "\n"))); err != nil {
return fmt.Errorf("failed to write %s: %w", hostsPath, err)
}
hm.logger.Debug().
Str("file", hostsPath).
Str("hostname", hostname).
Str("fqdn", fqdn).
Msg("updated /etc/hosts")
return nil
}
// setSystemHostname sets the system hostname using the hostname command
func (hm *ResolvConfManager) setSystemHostname(hostname string) error {
cmd := exec.Command("hostname", "-F", hostnamePath)
if err := cmd.Run(); err != nil {
return fmt.Errorf("failed to run hostname command: %w", err)
}
hm.logger.Debug().Str("hostname", hostname).Msg("set system hostname")
return nil
}
// getFQDNFromHosts tries to get the FQDN from /etc/hosts
func (hm *ResolvConfManager) getFQDNFromHosts(hostname string) (string, error) {
content, err := os.ReadFile(hostsPath)
if err != nil {
return hostname, nil // Return hostname as fallback
}
lines := strings.Split(string(content), "\n")
for _, line := range lines {
if strings.HasPrefix(line, "127.0.1.1") {
parts := strings.Fields(line)
if len(parts) >= 2 {
// The second part should be the FQDN
return parts[1], nil
}
}
}
return hostname, nil // Return hostname as fallback
}
// ToValidHostname converts a hostname to a valid format
func ToValidHostname(hostname string) string {
ascii, err := idna.Lookup.ToASCII(hostname)
if err != nil {
return ""
}
return ascii
}
// ValidateHostname validates a hostname
func ValidateHostname(hostname string) error {
_, err := idna.Lookup.ToASCII(hostname)
return err
}

853
pkg/nmlite/interface.go Normal file
View File

@ -0,0 +1,853 @@
package nmlite
import (
"context"
"fmt"
"net"
"net/netip"
"time"
"github.com/jetkvm/kvm/internal/sync"
"github.com/jetkvm/kvm/internal/confparser"
"github.com/jetkvm/kvm/internal/logging"
"github.com/jetkvm/kvm/internal/network/types"
"github.com/jetkvm/kvm/pkg/nmlite/link"
"github.com/mdlayher/ndp"
"github.com/rs/zerolog"
"github.com/vishvananda/netlink"
)
type ResolvConfChangeCallback func(family int, resolvConf *types.InterfaceResolvConf) error
// InterfaceManager manages a single network interface
type InterfaceManager struct {
ctx context.Context
ifaceName string
config *types.NetworkConfig
logger *zerolog.Logger
state *types.InterfaceState
linkState *link.Link
stateMu sync.RWMutex
// Network components
staticConfig *StaticConfigManager
dhcpClient *DHCPClient
// Callbacks
onStateChange func(state types.InterfaceState)
onConfigChange func(config *types.NetworkConfig)
onDHCPLeaseChange func(lease *types.DHCPLease)
onResolvConfChange ResolvConfChangeCallback
// Control
stopCh chan struct{}
wg sync.WaitGroup
}
// NewInterfaceManager creates a new interface manager
func NewInterfaceManager(ctx context.Context, ifaceName string, config *types.NetworkConfig, logger *zerolog.Logger) (*InterfaceManager, error) {
if config == nil {
return nil, fmt.Errorf("config cannot be nil")
}
if logger == nil {
logger = logging.GetSubsystemLogger("interface")
}
scopedLogger := logger.With().Str("interface", ifaceName).Logger()
// Validate and set defaults
if err := confparser.SetDefaultsAndValidate(config); err != nil {
return nil, fmt.Errorf("invalid config: %w", err)
}
im := &InterfaceManager{
ctx: ctx,
ifaceName: ifaceName,
config: config,
logger: &scopedLogger,
state: &types.InterfaceState{
InterfaceName: ifaceName,
// LastUpdated: time.Now(),
},
stopCh: make(chan struct{}),
}
// Initialize components
var err error
im.staticConfig, err = NewStaticConfigManager(ifaceName, &scopedLogger)
if err != nil {
return nil, fmt.Errorf("failed to create static config manager: %w", err)
}
// create the dhcp client
im.dhcpClient, err = NewDHCPClient(ctx, ifaceName, &scopedLogger, config.DHCPClient.String)
if err != nil {
return nil, fmt.Errorf("failed to create DHCP client: %w", err)
}
// Set up DHCP client callbacks
im.dhcpClient.SetOnLeaseChange(func(lease *types.DHCPLease) {
if im.config.IPv4Mode.String != "dhcp" {
im.logger.Warn().Str("mode", im.config.IPv4Mode.String).Msg("ignoring DHCP lease, current mode is not DHCP")
return
}
if err := im.applyDHCPLease(lease); err != nil {
im.logger.Error().Err(err).Msg("failed to apply DHCP lease")
}
im.updateStateFromDHCPLease(lease)
if im.onDHCPLeaseChange != nil {
im.onDHCPLeaseChange(lease)
}
})
return im, nil
}
// Start starts managing the interface
func (im *InterfaceManager) Start() error {
im.stateMu.Lock()
defer im.stateMu.Unlock()
im.logger.Info().Msg("starting interface manager")
// Start monitoring interface state
im.wg.Add(1)
go im.monitorInterfaceState()
nl := getNetlinkManager()
// Set the link state
linkState, err := nl.GetLinkByName(im.ifaceName)
if err != nil {
return fmt.Errorf("failed to get interface: %w", err)
}
im.linkState = linkState
// Bring the interface up
_, linkUpErr := nl.EnsureInterfaceUpWithTimeout(
im.ctx,
im.linkState,
30*time.Second,
)
// Set callback after the interface is up
nl.AddStateChangeCallback(im.ifaceName, link.StateChangeCallback{
Async: true,
Func: func(link *link.Link) {
im.handleLinkStateChange(link)
},
})
if linkUpErr != nil {
im.logger.Error().Err(linkUpErr).Msg("failed to bring interface up, continuing anyway")
} else {
// Apply initial configuration
if err := im.applyConfiguration(); err != nil {
im.logger.Error().Err(err).Msg("failed to apply initial configuration")
return err
}
}
im.logger.Info().Msg("interface manager started")
return nil
}
// Stop stops managing the interface
func (im *InterfaceManager) Stop() error {
im.logger.Info().Msg("stopping interface manager")
close(im.stopCh)
im.wg.Wait()
// Stop DHCP client
if im.dhcpClient != nil {
if err := im.dhcpClient.Stop(); err != nil {
return fmt.Errorf("failed to stop DHCP client: %w", err)
}
}
im.logger.Info().Msg("interface manager stopped")
return nil
}
func (im *InterfaceManager) link() (*link.Link, error) {
nl := getNetlinkManager()
if nl == nil {
return nil, fmt.Errorf("netlink manager not initialized")
}
return nl.GetLinkByName(im.ifaceName)
}
// IsUp returns true if the interface is up
func (im *InterfaceManager) IsUp() bool {
im.stateMu.RLock()
defer im.stateMu.RUnlock()
if im.state == nil {
return false
}
return im.state.Up
}
// IsOnline returns true if the interface is online
func (im *InterfaceManager) IsOnline() bool {
im.stateMu.RLock()
defer im.stateMu.RUnlock()
if im.state == nil {
return false
}
return im.state.Online
}
// IPv4Ready returns true if the interface has an IPv4 address
func (im *InterfaceManager) IPv4Ready() bool {
im.stateMu.RLock()
defer im.stateMu.RUnlock()
if im.state == nil {
return false
}
return im.state.IPv4Ready
}
// IPv6Ready returns true if the interface has an IPv6 address
func (im *InterfaceManager) IPv6Ready() bool {
im.stateMu.RLock()
defer im.stateMu.RUnlock()
if im.state == nil {
return false
}
return im.state.IPv6Ready
}
// GetIPv4Addresses returns the IPv4 addresses of the interface
func (im *InterfaceManager) GetIPv4Addresses() []string {
im.stateMu.RLock()
defer im.stateMu.RUnlock()
if im.state == nil {
return []string{}
}
return im.state.IPv4Addresses
}
// GetIPv4Address returns the IPv4 address of the interface
func (im *InterfaceManager) GetIPv4Address() string {
im.stateMu.RLock()
defer im.stateMu.RUnlock()
if im.state == nil {
return ""
}
return im.state.IPv4Address
}
// GetIPv6Address returns the IPv6 address of the interface
func (im *InterfaceManager) GetIPv6Address() string {
im.stateMu.RLock()
defer im.stateMu.RUnlock()
if im.state == nil {
return ""
}
return im.state.IPv6Address
}
// GetIPv6Addresses returns the IPv6 addresses of the interface
func (im *InterfaceManager) GetIPv6Addresses() []string {
im.stateMu.RLock()
defer im.stateMu.RUnlock()
addresses := []string{}
if im.state == nil {
return addresses
}
for _, addr := range im.state.IPv6Addresses {
addresses = append(addresses, addr.Address.String())
}
return addresses
}
// GetMACAddress returns the MAC address of the interface
func (im *InterfaceManager) GetMACAddress() string {
im.stateMu.RLock()
defer im.stateMu.RUnlock()
if im.state == nil {
return ""
}
return im.state.MACAddress
}
// GetState returns the current interface state
func (im *InterfaceManager) GetState() *types.InterfaceState {
im.stateMu.RLock()
defer im.stateMu.RUnlock()
// Return a copy to avoid race conditions
im.logger.Debug().Interface("state", im.state).Msg("getting interface state")
state := *im.state
return &state
}
// NTPServers returns the NTP servers of the interface
func (im *InterfaceManager) NTPServers() []net.IP {
im.stateMu.RLock()
defer im.stateMu.RUnlock()
if im.state == nil {
return []net.IP{}
}
return im.state.NTPServers
}
func (im *InterfaceManager) Domain() string {
im.stateMu.RLock()
defer im.stateMu.RUnlock()
if im.state == nil {
return ""
}
if im.state.DHCPLease4 != nil {
return im.state.DHCPLease4.Domain
}
if im.state.DHCPLease6 != nil {
return im.state.DHCPLease6.Domain
}
return ""
}
// GetConfig returns the current interface configuration
func (im *InterfaceManager) GetConfig() *types.NetworkConfig {
// Return a copy to avoid race conditions
config := *im.config
return &config
}
// ApplyConfiguration applies the current configuration to the interface
func (im *InterfaceManager) ApplyConfiguration() error {
return im.applyConfiguration()
}
// SetConfig updates the interface configuration
func (im *InterfaceManager) SetConfig(config *types.NetworkConfig) error {
if config == nil {
return fmt.Errorf("config cannot be nil")
}
// Validate and set defaults
if err := confparser.SetDefaultsAndValidate(config); err != nil {
return fmt.Errorf("invalid config: %w", err)
}
im.config = config
// Apply the new configuration
if err := im.applyConfiguration(); err != nil {
im.logger.Error().Err(err).Msg("failed to apply new configuration")
return err
}
// Notify callback
if im.onConfigChange != nil {
im.onConfigChange(config)
}
im.logger.Info().Msg("configuration updated")
return nil
}
// RenewDHCPLease renews the DHCP lease
func (im *InterfaceManager) RenewDHCPLease() error {
if im.dhcpClient == nil {
return fmt.Errorf("DHCP client not available")
}
return im.dhcpClient.Renew()
}
// SetOnStateChange sets the callback for state changes
func (im *InterfaceManager) SetOnStateChange(callback func(state types.InterfaceState)) {
im.onStateChange = callback
}
// SetOnConfigChange sets the callback for configuration changes
func (im *InterfaceManager) SetOnConfigChange(callback func(config *types.NetworkConfig)) {
im.onConfigChange = callback
}
// SetOnDHCPLeaseChange sets the callback for DHCP lease changes
func (im *InterfaceManager) SetOnDHCPLeaseChange(callback func(lease *types.DHCPLease)) {
im.onDHCPLeaseChange = callback
}
// SetOnResolvConfChange sets the callback for resolv.conf changes
func (im *InterfaceManager) SetOnResolvConfChange(callback ResolvConfChangeCallback) {
im.onResolvConfChange = callback
}
// applyConfiguration applies the current configuration to the interface
func (im *InterfaceManager) applyConfiguration() error {
im.logger.Info().Msg("applying configuration")
// Apply IPv4 configuration
if err := im.applyIPv4Config(); err != nil {
return fmt.Errorf("failed to apply IPv4 config: %w", err)
}
// Apply IPv6 configuration
if err := im.applyIPv6Config(); err != nil {
return fmt.Errorf("failed to apply IPv6 config: %w", err)
}
return nil
}
// applyIPv4Config applies IPv4 configuration
func (im *InterfaceManager) applyIPv4Config() error {
mode := im.config.IPv4Mode.String
im.logger.Info().Str("mode", mode).Msg("applying IPv4 configuration")
switch mode {
case "static":
return im.applyIPv4Static()
case "dhcp":
return im.applyIPv4DHCP()
case "disabled":
return im.disableIPv4()
default:
return fmt.Errorf("invalid IPv4 mode: %s", mode)
}
}
// applyIPv6Config applies IPv6 configuration
func (im *InterfaceManager) applyIPv6Config() error {
mode := im.config.IPv6Mode.String
im.logger.Info().Str("mode", mode).Msg("applying IPv6 configuration")
switch mode {
case "static":
return im.applyIPv6Static()
case "dhcpv6":
return im.applyIPv6DHCP()
case "slaac":
return im.applyIPv6SLAAC()
case "slaac_and_dhcpv6":
return im.applyIPv6SLAACAndDHCP()
case "link_local":
return im.applyIPv6LinkLocal()
case "disabled":
return im.disableIPv6()
default:
return fmt.Errorf("invalid IPv6 mode: %s", mode)
}
}
// applyIPv4Static applies static IPv4 configuration
func (im *InterfaceManager) applyIPv4Static() error {
if im.config.IPv4Static == nil {
return fmt.Errorf("IPv4 static configuration is nil")
}
im.logger.Info().Msg("stopping DHCP")
// Disable DHCP
if im.dhcpClient != nil {
im.dhcpClient.SetIPv4(false)
}
im.logger.Info().Interface("config", im.config.IPv4Static).Msg("applying IPv4 static configuration")
config, err := im.staticConfig.ToIPv4Static(im.config.IPv4Static)
if err != nil {
return fmt.Errorf("failed to convert IPv4 static configuration: %w", err)
}
im.logger.Info().Interface("config", config).Msg("converted IPv4 static configuration")
if err := im.onResolvConfChange(link.AfInet, &types.InterfaceResolvConf{
NameServers: config.Nameservers,
Source: "static",
}); err != nil {
im.logger.Warn().Err(err).Msg("failed to update resolv.conf")
}
return im.ReconcileLinkAddrs(config.Addresses, link.AfInet)
}
// applyIPv4DHCP applies DHCP IPv4 configuration
func (im *InterfaceManager) applyIPv4DHCP() error {
if im.dhcpClient == nil {
return fmt.Errorf("DHCP client not available")
}
// Enable DHCP
im.dhcpClient.SetIPv4(true)
return im.dhcpClient.Start()
}
// disableIPv4 disables IPv4
func (im *InterfaceManager) disableIPv4() error {
// Disable DHCP
if im.dhcpClient != nil {
im.dhcpClient.SetIPv4(false)
}
// Remove all IPv4 addresses
return im.staticConfig.DisableIPv4()
}
// applyIPv6Static applies static IPv6 configuration
func (im *InterfaceManager) applyIPv6Static() error {
if im.config.IPv6Static == nil {
return fmt.Errorf("IPv6 static configuration is nil")
}
im.logger.Info().Msg("stopping DHCPv6")
// Disable DHCPv6
if im.dhcpClient != nil {
im.dhcpClient.SetIPv6(false)
}
// Apply static configuration
config, err := im.staticConfig.ToIPv6Static(im.config.IPv6Static)
if err != nil {
return fmt.Errorf("failed to convert IPv6 static configuration: %w", err)
}
im.logger.Info().Interface("config", config).Msg("converted IPv6 static configuration")
if err := im.onResolvConfChange(link.AfInet6, &types.InterfaceResolvConf{
NameServers: config.Nameservers,
Source: "static",
}); err != nil {
im.logger.Warn().Err(err).Msg("failed to update resolv.conf")
}
return im.ReconcileLinkAddrs(config.Addresses, link.AfInet6)
}
// applyIPv6DHCP applies DHCPv6 configuration
func (im *InterfaceManager) applyIPv6DHCP() error {
if im.dhcpClient == nil {
return fmt.Errorf("DHCP client not available")
}
// Enable DHCPv6
im.dhcpClient.SetIPv6(true)
return im.dhcpClient.Start()
}
// applyIPv6SLAAC applies SLAAC configuration
func (im *InterfaceManager) applyIPv6SLAAC() error {
// Disable DHCPv6
if im.dhcpClient != nil {
im.dhcpClient.SetIPv6(false)
}
// Remove static IPv6 configuration
l, err := im.link()
if err != nil {
return fmt.Errorf("failed to get interface: %w", err)
}
netlinkMgr := getNetlinkManager()
// Ensure interface is up
if err := netlinkMgr.EnsureInterfaceUp(l); err != nil {
return fmt.Errorf("failed to bring interface up: %w", err)
}
if err := netlinkMgr.RemoveNonLinkLocalIPv6Addresses(l); err != nil {
return fmt.Errorf("failed to remove non-link-local IPv6 addresses: %w", err)
}
if err := im.SendRouterSolicitation(); err != nil {
im.logger.Error().Err(err).Msg("failed to send router solicitation, continuing anyway")
}
// Enable SLAAC
return im.staticConfig.EnableIPv6SLAAC()
}
// applyIPv6SLAACAndDHCP applies SLAAC + DHCPv6 configuration
func (im *InterfaceManager) applyIPv6SLAACAndDHCP() error {
// Enable both SLAAC and DHCPv6
if im.dhcpClient != nil {
im.dhcpClient.SetIPv6(true)
if err := im.dhcpClient.Start(); err != nil {
return fmt.Errorf("failed to start DHCP client: %w", err)
}
}
return im.staticConfig.EnableIPv6SLAAC()
}
// applyIPv6LinkLocal applies link-local only IPv6 configuration
func (im *InterfaceManager) applyIPv6LinkLocal() error {
// Disable DHCPv6
if im.dhcpClient != nil {
im.dhcpClient.SetIPv6(false)
}
// Enable link-local only
return im.staticConfig.EnableIPv6LinkLocal()
}
// disableIPv6 disables IPv6
func (im *InterfaceManager) disableIPv6() error {
// Disable DHCPv6
if im.dhcpClient != nil {
im.dhcpClient.SetIPv6(false)
}
// Disable IPv6
return im.staticConfig.DisableIPv6()
}
func (im *InterfaceManager) handleLinkStateChange(link *link.Link) {
{
im.stateMu.Lock()
defer im.stateMu.Unlock()
if link.IsSame(im.linkState) {
return
}
im.linkState = link
}
im.logger.Info().Interface("link", link).Msg("link state changed")
operState := link.Attrs().OperState
if operState == netlink.OperUp {
im.handleLinkUp()
} else {
im.handleLinkDown()
}
}
// SendRouterSolicitation sends a router solicitation
func (im *InterfaceManager) SendRouterSolicitation() error {
im.logger.Info().Msg("sending router solicitation")
m := &ndp.RouterSolicitation{}
l, err := im.link()
if err != nil {
return fmt.Errorf("failed to get interface: %w", err)
}
if l.Attrs().OperState != netlink.OperUp {
return fmt.Errorf("interface %s is not up", im.ifaceName)
}
iface := l.Interface()
if iface == nil {
return fmt.Errorf("failed to get net.Interface for %s", im.ifaceName)
}
hwAddr := l.HardwareAddr()
if hwAddr == nil {
return fmt.Errorf("failed to get hardware address for %s", im.ifaceName)
}
c, _, err := ndp.Listen(iface, ndp.LinkLocal)
if err != nil {
return fmt.Errorf("failed to create NDP listener on %s: %w", im.ifaceName, err)
}
m.Options = append(m.Options, &ndp.LinkLayerAddress{
Addr: hwAddr,
Direction: ndp.Source,
})
targetAddr := netip.MustParseAddr("ff02::2")
if err := c.WriteTo(m, nil, targetAddr); err != nil {
c.Close()
return fmt.Errorf("failed to write to %s: %w", targetAddr.String(), err)
}
im.logger.Info().Msg("router solicitation sent")
c.Close()
return nil
}
func (im *InterfaceManager) handleLinkUp() {
im.logger.Info().Msg("link up")
if err := im.applyConfiguration(); err != nil {
im.logger.Error().Err(err).Msg("failed to apply configuration")
}
if im.config.IPv4Mode.String == "dhcp" {
if err := im.dhcpClient.Renew(); err != nil {
im.logger.Error().Err(err).Msg("failed to renew DHCP lease")
}
}
if im.config.IPv6Mode.String == "slaac" {
if err := im.staticConfig.EnableIPv6SLAAC(); err != nil {
im.logger.Error().Err(err).Msg("failed to enable IPv6 SLAAC")
}
if err := im.SendRouterSolicitation(); err != nil {
im.logger.Error().Err(err).Msg("failed to send router solicitation")
}
}
}
func (im *InterfaceManager) handleLinkDown() {
im.logger.Info().Msg("link down")
if im.config.IPv4Mode.String == "dhcp" {
if err := im.dhcpClient.Stop(); err != nil {
im.logger.Error().Err(err).Msg("failed to stop DHCP client")
}
}
netlinkMgr := getNetlinkManager()
if err := netlinkMgr.RemoveAllAddresses(im.linkState, link.AfInet); err != nil {
im.logger.Error().Err(err).Msg("failed to remove all IPv4 addresses")
}
if err := netlinkMgr.RemoveNonLinkLocalIPv6Addresses(im.linkState); err != nil {
im.logger.Error().Err(err).Msg("failed to remove non-link-local IPv6 addresses")
}
}
// monitorInterfaceState monitors the interface state and updates accordingly
func (im *InterfaceManager) monitorInterfaceState() {
defer im.wg.Done()
im.logger.Debug().Msg("monitoring interface state")
// TODO: use netlink subscription instead of polling
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for {
select {
case <-im.ctx.Done():
return
case <-im.stopCh:
return
case <-ticker.C:
if err := im.updateInterfaceState(); err != nil {
im.logger.Error().Err(err).Msg("failed to update interface state")
}
}
}
}
// updateStateFromDHCPLease updates the state from a DHCP lease
func (im *InterfaceManager) updateStateFromDHCPLease(lease *types.DHCPLease) {
family := link.AfInet
im.stateMu.Lock()
if lease.IsIPv6() {
im.state.DHCPLease6 = lease
family = link.AfInet6
} else {
im.state.DHCPLease4 = lease
}
im.stateMu.Unlock()
// Update resolv.conf with DNS information
if im.onResolvConfChange == nil {
return
}
if im.ifaceName == "" {
im.logger.Warn().Msg("interface name is empty, skipping resolv.conf update")
return
}
if err := im.onResolvConfChange(family, &types.InterfaceResolvConf{
NameServers: lease.DNS,
SearchList: lease.SearchList,
Source: "dhcp",
}); err != nil {
im.logger.Warn().Err(err).Msg("failed to update resolv.conf")
}
}
// ReconcileLinkAddrs reconciles the link addresses
func (im *InterfaceManager) ReconcileLinkAddrs(addrs []types.IPAddress, family int) error {
nl := getNetlinkManager()
link, err := im.link()
if err != nil {
return fmt.Errorf("failed to get interface: %w", err)
}
if link == nil {
return fmt.Errorf("failed to get interface: %w", err)
}
return nl.ReconcileLink(link, addrs, family)
}
// applyDHCPLease applies DHCP lease configuration using ReconcileLinkAddrs
func (im *InterfaceManager) applyDHCPLease(lease *types.DHCPLease) error {
if lease == nil {
return fmt.Errorf("DHCP lease is nil")
}
if lease.DHCPClient != "jetdhcpc" {
im.logger.Warn().Str("dhcp_client", lease.DHCPClient).Msg("ignoring DHCP lease, not implemented yet")
return nil
}
if lease.IsIPv6() {
im.logger.Warn().Msg("ignoring IPv6 DHCP lease, not implemented yet")
return nil
}
// Convert DHCP lease to IPv4Config
ipv4Config := im.convertDHCPLeaseToIPv4Config(lease)
// Apply the configuration using ReconcileLinkAddrs
return im.ReconcileLinkAddrs([]types.IPAddress{*ipv4Config}, link.AfInet)
}
// convertDHCPLeaseToIPv4Config converts a DHCP lease to IPv4Config
func (im *InterfaceManager) convertDHCPLeaseToIPv4Config(lease *types.DHCPLease) *types.IPAddress {
ipNet := lease.IPNet()
if ipNet == nil {
return nil
}
// Create IPv4Address
ipv4Addr := types.IPAddress{
Address: *ipNet,
Gateway: lease.Routers[0],
Secondary: false,
Permanent: false,
}
im.logger.Trace().
Interface("ipv4Addr", ipv4Addr).
Interface("lease", lease).
Msg("converted DHCP lease to IPv4Config")
// Create IPv4Config
return &ipv4Addr
}

View File

@ -0,0 +1,163 @@
package nmlite
import (
"fmt"
"time"
"github.com/jetkvm/kvm/internal/network/types"
"github.com/jetkvm/kvm/pkg/nmlite/link"
"github.com/vishvananda/netlink"
)
// updateInterfaceState updates the current interface state
func (im *InterfaceManager) updateInterfaceState() error {
nl, err := im.link()
if err != nil {
return fmt.Errorf("failed to get interface: %w", err)
}
var stateChanged bool
attrs := nl.Attrs()
// We should release the lock before calling the callbacks
// to avoid deadlocks
im.stateMu.Lock()
// Check if the interface is up
isUp := attrs.OperState == netlink.OperUp
if im.state.Up != isUp {
im.state.Up = isUp
stateChanged = true
}
// Check if the interface is online
isOnline := isUp && nl.HasGlobalUnicastAddress()
if im.state.Online != isOnline {
im.state.Online = isOnline
stateChanged = true
}
// Check if the MAC address has changed
if im.state.MACAddress != attrs.HardwareAddr.String() {
im.state.MACAddress = attrs.HardwareAddr.String()
stateChanged = true
}
// Update IP addresses
if ipChanged, err := im.updateInterfaceStateAddresses(nl); err != nil {
im.logger.Error().Err(err).Msg("failed to update IP addresses")
} else if ipChanged {
stateChanged = true
}
im.state.LastUpdated = time.Now()
im.stateMu.Unlock()
// Notify callback if state changed
if stateChanged && im.onStateChange != nil {
im.logger.Debug().Interface("state", im.state).Msg("notifying state change")
im.onStateChange(*im.state)
}
return nil
}
// updateIPAddresses updates the IP addresses in the state
func (im *InterfaceManager) updateInterfaceStateAddresses(nl *link.Link) (bool, error) {
mgr := getNetlinkManager()
addrs, err := nl.AddrList(link.AfUnspec)
if err != nil {
return false, fmt.Errorf("failed to get addresses: %w", err)
}
var (
ipv4Addresses []string
ipv6Addresses []types.IPv6Address
ipv4Addr, ipv6Addr string
ipv6LinkLocal string
ipv6Gateway string
ipv4Ready, ipv6Ready = false, false
stateChanged = false
)
routes, _ := mgr.ListDefaultRoutes(link.AfInet6)
if len(routes) > 0 {
ipv6Gateway = routes[0].Gw.String()
}
for _, addr := range addrs {
if addr.IP.To4() != nil {
// IPv4 address
ipv4Addresses = append(ipv4Addresses, addr.IPNet.String())
if ipv4Addr == "" {
ipv4Addr = addr.IP.String()
ipv4Ready = true
}
continue
}
// IPv6 address (if it's not an IPv4 address, it must be an IPv6 address)
if addr.IP.IsLinkLocalUnicast() {
ipv6LinkLocal = addr.IP.String()
continue
} else if !addr.IP.IsGlobalUnicast() {
continue
}
ipv6Addresses = append(ipv6Addresses, types.IPv6Address{
Address: addr.IP,
Prefix: *addr.IPNet,
Scope: addr.Scope,
Flags: addr.Flags,
ValidLifetime: lifetimeToTime(addr.ValidLft),
PreferredLifetime: lifetimeToTime(addr.PreferedLft),
})
if ipv6Addr == "" {
ipv6Addr = addr.IP.String()
ipv6Ready = true
}
}
if !sortAndCompareStringSlices(im.state.IPv4Addresses, ipv4Addresses) {
im.state.IPv4Addresses = ipv4Addresses
stateChanged = true
}
if !sortAndCompareIPv6AddressSlices(im.state.IPv6Addresses, ipv6Addresses) {
im.state.IPv6Addresses = ipv6Addresses
stateChanged = true
}
if im.state.IPv4Address != ipv4Addr {
im.state.IPv4Address = ipv4Addr
stateChanged = true
}
if im.state.IPv6Address != ipv6Addr {
im.state.IPv6Address = ipv6Addr
stateChanged = true
}
if im.state.IPv6LinkLocal != ipv6LinkLocal {
im.state.IPv6LinkLocal = ipv6LinkLocal
stateChanged = true
}
if im.state.IPv6Gateway != ipv6Gateway {
im.state.IPv6Gateway = ipv6Gateway
stateChanged = true
}
if im.state.IPv4Ready != ipv4Ready {
im.state.IPv4Ready = ipv4Ready
stateChanged = true
}
if im.state.IPv6Ready != ipv6Ready {
im.state.IPv6Ready = ipv6Ready
stateChanged = true
}
return stateChanged, nil
}

View File

@ -0,0 +1,407 @@
package jetdhcpc
import (
"context"
"errors"
"net"
"slices"
"time"
"github.com/jetkvm/kvm/internal/sync"
"github.com/jetkvm/kvm/pkg/nmlite/link"
"github.com/insomniacslk/dhcp/dhcpv4"
"github.com/insomniacslk/dhcp/dhcpv6"
"github.com/jetkvm/kvm/internal/network/types"
"github.com/rs/zerolog"
)
const (
VendorIdentifier = "jetkvm"
)
var (
ErrIPv6LinkTimeout = errors.New("timeout after waiting for a non-tentative IPv6 address")
ErrIPv6RouteTimeout = errors.New("timeout after waiting for an IPv6 route")
ErrInterfaceUpTimeout = errors.New("timeout after waiting for an interface to come up")
ErrInterfaceUpCanceled = errors.New("context canceled while waiting for an interface to come up")
)
type LeaseChangeHandler func(lease *types.DHCPLease)
// Config is a DHCP client configuration.
type Config struct {
LinkUpTimeout time.Duration
// Timeout is the timeout for one DHCP request attempt.
Timeout time.Duration
// Retries is how many times to retry DHCP attempts.
Retries int
// IPv4 is whether to request an IPv4 lease.
IPv4 bool
// IPv6 is whether to request an IPv6 lease.
IPv6 bool
// Modifiers4 allows modifications to the IPv4 DHCP request.
Modifiers4 []dhcpv4.Modifier
// Modifiers6 allows modifications to the IPv6 DHCP request.
Modifiers6 []dhcpv6.Modifier
// V6ServerAddr can be a unicast or broadcast destination for DHCPv6
// messages.
//
// If not set, it will default to nclient6's default (all servers &
// relay agents).
V6ServerAddr *net.UDPAddr
// V6ClientPort is the port that is used to send and receive DHCPv6
// messages.
//
// If not set, it will default to dhcpv6's default (546).
V6ClientPort *int
// V4ServerAddr can be a unicast or broadcast destination for IPv4 DHCP
// messages.
//
// If not set, it will default to nclient4's default (DHCP broadcast
// address).
V4ServerAddr *net.UDPAddr
// If true, add Client Identifier (61) option to the IPv4 request.
V4ClientIdentifier bool
Hostname string
OnLease4Change LeaseChangeHandler
OnLease6Change LeaseChangeHandler
UpdateResolvConf func([]string) error
}
// Client is a DHCP client.
type Client struct {
types.DHCPClient
ifaces []string
cfg Config
l *zerolog.Logger
ctx context.Context
// TODO: support multiple interfaces
currentLease4 *Lease
currentLease6 *Lease
mu sync.Mutex
cfgMu sync.Mutex
lease4Mu sync.Mutex
lease6Mu sync.Mutex
timer4 *time.Timer
timer6 *time.Timer
stateDir string
}
var (
defaultTimerDuration = 1 * time.Second
defaultLinkUpTimeout = 30 * time.Second
maxRenewalAttemptDuration = 2 * time.Hour
)
// NewClient creates a new DHCP client for the given interface.
func NewClient(ctx context.Context, ifaces []string, c *Config, l *zerolog.Logger) (*Client, error) {
timer4 := time.NewTimer(defaultTimerDuration)
timer6 := time.NewTimer(defaultTimerDuration)
cfg := *c
if cfg.LinkUpTimeout == 0 {
cfg.LinkUpTimeout = defaultLinkUpTimeout
}
if cfg.Timeout == 0 {
cfg.Timeout = defaultLinkUpTimeout
}
if cfg.Retries == 0 {
cfg.Retries = 3
}
return &Client{
ctx: ctx,
ifaces: ifaces,
cfg: cfg,
l: l,
stateDir: "/run/jetkvm-dhcp",
currentLease4: nil,
currentLease6: nil,
lease4Mu: sync.Mutex{},
lease6Mu: sync.Mutex{},
mu: sync.Mutex{},
cfgMu: sync.Mutex{},
timer4: timer4,
timer6: timer6,
}, nil
}
func resetTimer(t *time.Timer, l *zerolog.Logger) {
l.Debug().Dur("delay", defaultTimerDuration).Msg("will retry later")
t.Reset(defaultTimerDuration)
}
func getRenewalTime(lease *Lease) time.Duration {
if lease.RenewalTime <= 0 || lease.LeaseTime > maxRenewalAttemptDuration/2 {
return maxRenewalAttemptDuration
}
return lease.RenewalTime
}
func (c *Client) requestLoop(t *time.Timer, family int, ifname string) {
l := c.l.With().Str("interface", ifname).Int("family", family).Logger()
for range t.C {
l.Info().Msg("requesting lease")
if _, err := c.ensureInterfaceUp(ifname); err != nil {
l.Error().Err(err).Msg("failed to ensure interface up")
resetTimer(t, c.l)
continue
}
var (
lease *Lease
err error
)
switch family {
case link.AfInet:
lease, err = c.requestLease4(ifname)
case link.AfInet6:
lease, err = c.requestLease6(ifname)
}
if err != nil {
l.Error().Err(err).Msg("failed to request lease")
resetTimer(t, c.l)
continue
}
c.handleLeaseChange(lease)
nextRenewal := getRenewalTime(lease)
l.Info().
Dur("nextRenewal", nextRenewal).
Dur("leaseTime", lease.LeaseTime).
Dur("rebindingTime", lease.RebindingTime).
Msg("sleeping until next renewal")
t.Reset(nextRenewal)
}
}
func (c *Client) ensureInterfaceUp(ifname string) (*link.Link, error) {
nlm := link.GetNetlinkManager()
iface, err := nlm.GetLinkByName(ifname)
if err != nil {
return nil, err
}
return nlm.EnsureInterfaceUpWithTimeout(c.ctx, iface, c.cfg.LinkUpTimeout)
}
// Lease4 returns the current IPv4 lease
func (c *Client) Lease4() *types.DHCPLease {
c.lease4Mu.Lock()
defer c.lease4Mu.Unlock()
if c.currentLease4 == nil {
return nil
}
return c.currentLease4.ToDHCPLease()
}
// Lease6 returns the current IPv6 lease
func (c *Client) Lease6() *types.DHCPLease {
c.lease6Mu.Lock()
defer c.lease6Mu.Unlock()
if c.currentLease6 == nil {
return nil
}
return c.currentLease6.ToDHCPLease()
}
// Domain returns the current domain
func (c *Client) Domain() string {
c.lease4Mu.Lock()
defer c.lease4Mu.Unlock()
if c.currentLease4 != nil {
return c.currentLease4.Domain
}
c.lease6Mu.Lock()
defer c.lease6Mu.Unlock()
if c.currentLease6 != nil {
return c.currentLease6.Domain
}
return ""
}
// handleLeaseChange handles lease changes
func (c *Client) handleLeaseChange(lease *Lease) {
// do not use defer here, because we need to unlock the mutex before returning
ipv4 := lease.p4 != nil
if ipv4 {
c.lease4Mu.Lock()
c.currentLease4 = lease
c.lease4Mu.Unlock()
} else {
c.lease6Mu.Lock()
c.currentLease6 = lease
c.lease6Mu.Unlock()
}
c.apply()
// TODO: handle lease expiration
if c.cfg.OnLease4Change != nil && ipv4 {
c.cfg.OnLease4Change(lease.ToDHCPLease())
}
if c.cfg.OnLease6Change != nil && !ipv4 {
c.cfg.OnLease6Change(lease.ToDHCPLease())
}
}
func (c *Client) Renew() error {
c.timer4.Reset(defaultTimerDuration)
c.timer6.Reset(defaultTimerDuration)
return nil
}
func (c *Client) Release() error {
// TODO: implement
return nil
}
func (c *Client) SetIPv4(ipv4 bool) {
c.cfgMu.Lock()
defer c.cfgMu.Unlock()
currentIPv4 := c.cfg.IPv4
c.cfg.IPv4 = ipv4
if currentIPv4 == ipv4 {
return
}
if !ipv4 {
c.lease4Mu.Lock()
c.currentLease4 = nil
c.lease4Mu.Unlock()
c.timer4.Stop()
}
c.timer4.Reset(defaultTimerDuration)
}
func (c *Client) SetIPv6(ipv6 bool) {
c.cfgMu.Lock()
defer c.cfgMu.Unlock()
currentIPv6 := c.cfg.IPv6
c.cfg.IPv6 = ipv6
if currentIPv6 == ipv6 {
return
}
if !ipv6 {
c.lease6Mu.Lock()
c.currentLease6 = nil
c.lease6Mu.Unlock()
c.timer6.Stop()
}
c.timer6.Reset(defaultTimerDuration)
}
func (c *Client) Start() error {
if err := c.killUdhcpc(); err != nil {
c.l.Warn().Err(err).Msg("failed to kill udhcpc processes, continuing anyway")
}
for _, iface := range c.ifaces {
if c.cfg.IPv4 {
go c.requestLoop(c.timer4, link.AfInet, iface)
}
if c.cfg.IPv6 {
go c.requestLoop(c.timer6, link.AfInet6, iface)
}
}
return nil
}
func (c *Client) apply() {
var (
iface string
nameservers []net.IP
searchList []string
domain string
)
if c.currentLease4 != nil {
iface = c.currentLease4.InterfaceName
nameservers = c.currentLease4.DNS
searchList = c.currentLease4.SearchList
domain = c.currentLease4.Domain
}
if c.currentLease6 != nil {
iface = c.currentLease6.InterfaceName
nameservers = append(nameservers, c.currentLease6.DNS...)
searchList = append(searchList, c.currentLease6.SearchList...)
domain = c.currentLease6.Domain
}
// deduplicate searchList
searchList = slices.Compact(searchList)
if c.cfg.UpdateResolvConf == nil {
c.l.Warn().Msg("no UpdateResolvConf function set, skipping resolv.conf update")
return
}
c.l.Info().
Str("interface", iface).
Interface("nameservers", nameservers).
Interface("searchList", searchList).
Str("domain", domain).
Msg("updating resolv.conf")
// Convert net.IP to string slice
var nameserverStrings []string
for _, ns := range nameservers {
nameserverStrings = append(nameserverStrings, ns.String())
}
if err := c.cfg.UpdateResolvConf(nameserverStrings); err != nil {
c.l.Error().Err(err).Msg("failed to update resolv.conf")
}
}

View File

@ -0,0 +1,85 @@
package jetdhcpc
import (
"fmt"
"github.com/insomniacslk/dhcp/dhcpv4"
"github.com/insomniacslk/dhcp/dhcpv4/nclient4"
"github.com/vishvananda/netlink"
)
func (c *Client) requestLease4(ifname string) (*Lease, error) {
iface, err := netlink.LinkByName(ifname)
if err != nil {
return nil, err
}
l := c.l.With().Str("interface", ifname).Logger()
mods := []nclient4.ClientOpt{
nclient4.WithTimeout(c.cfg.Timeout),
nclient4.WithRetry(c.cfg.Retries),
}
mods = append(mods, c.getDHCP4Logger(ifname))
if c.cfg.V4ServerAddr != nil {
mods = append(mods, nclient4.WithServerAddr(c.cfg.V4ServerAddr))
}
client, err := nclient4.New(ifname, mods...)
if err != nil {
return nil, err
}
defer client.Close()
// Prepend modifiers with default options, so they can be overridden.
reqmods := append(
[]dhcpv4.Modifier{
dhcpv4.WithOption(dhcpv4.OptClassIdentifier(VendorIdentifier)),
dhcpv4.WithRequestedOptions(
dhcpv4.OptionSubnetMask,
dhcpv4.OptionInterfaceMTU,
dhcpv4.OptionNTPServers,
dhcpv4.OptionDomainName,
dhcpv4.OptionDomainNameServer,
dhcpv4.OptionDNSDomainSearchList,
),
},
c.cfg.Modifiers4...)
if c.cfg.V4ClientIdentifier {
// Client Id is hardware type + mac per RFC 2132 9.14.
ident := []byte{0x01} // Type ethernet
ident = append(ident, iface.Attrs().HardwareAddr...)
reqmods = append(reqmods, dhcpv4.WithOption(dhcpv4.OptClientIdentifier(ident)))
}
if c.cfg.Hostname != "" {
reqmods = append(reqmods, dhcpv4.WithOption(dhcpv4.OptHostName(c.cfg.Hostname)))
}
l.Info().Msg("attempting to get DHCPv4 lease")
var (
lease *nclient4.Lease
reqErr error
)
if c.currentLease4 != nil {
l.Info().Msg("current lease is not nil, renewing")
lease, reqErr = client.Renew(c.ctx, c.currentLease4.p4, reqmods...)
} else {
l.Info().Msg("current lease is nil, requesting new lease")
lease, reqErr = client.Request(c.ctx, reqmods...)
}
if reqErr != nil {
return nil, reqErr
}
if lease == nil || lease.ACK == nil {
return nil, fmt.Errorf("failed to acquire DHCPv4 lease")
}
summaryStructured(lease.ACK, &l).Info().Msgf("DHCPv4 lease acquired: %s", lease.ACK.String())
l.Trace().Interface("options", lease.ACK.Options.String()).Msg("DHCPv4 lease options")
return fromNclient4Lease(lease, ifname), nil
}

View File

@ -0,0 +1,135 @@
package jetdhcpc
import (
"net"
"time"
"github.com/insomniacslk/dhcp/dhcpv6"
"github.com/insomniacslk/dhcp/dhcpv6/nclient6"
"github.com/rs/zerolog"
"github.com/vishvananda/netlink"
)
// isIPv6LinkReady returns true if the interface has a link-local address
// which is not tentative.
func isIPv6LinkReady(l netlink.Link, logger *zerolog.Logger) (bool, error) {
addrs, err := netlink.AddrList(l, 10) // AF_INET6
if err != nil {
return false, err
}
for _, addr := range addrs {
if addr.IP.IsLinkLocalUnicast() && (addr.Flags&0x40 == 0) { // IFA_F_TENTATIVE
if addr.Flags&0x80 != 0 { // IFA_F_DADFAILED
logger.Warn().Str("address", addr.IP.String()).Msg("DADFAILED for address, continuing anyhow")
}
return true, nil
}
}
return false, nil
}
// isIPv6RouteReady returns true if serverAddr is reachable.
func isIPv6RouteReady(serverAddr net.IP) waitForCondition {
return func(l netlink.Link, logger *zerolog.Logger) (bool, error) {
if serverAddr.IsMulticast() {
return true, nil
}
routes, err := netlink.RouteList(l, 10) // AF_INET6
if err != nil {
return false, err
}
for _, route := range routes {
if route.LinkIndex != l.Attrs().Index {
continue
}
// Default route.
if route.Dst == nil {
return true, nil
}
if route.Dst.Contains(serverAddr) {
return true, nil
}
}
return false, nil
}
}
func (c *Client) requestLease6(ifname string) (*Lease, error) {
l := c.l.With().Str("interface", ifname).Logger()
iface, err := netlink.LinkByName(ifname)
if err != nil {
return nil, err
}
clientPort := dhcpv6.DefaultClientPort
if c.cfg.V6ClientPort != nil {
clientPort = *c.cfg.V6ClientPort
}
// For ipv6, we cannot bind to the port until Duplicate Address
// Detection (DAD) is complete which is indicated by the link being no
// longer marked as "tentative". This usually takes about a second.
// If the link is never going to be ready, don't wait forever.
// (The user may not have configured a ctx with a timeout.)
linkUpTimeout := time.After(c.cfg.LinkUpTimeout)
if err := c.waitFor(
iface,
linkUpTimeout,
isIPv6LinkReady,
ErrIPv6LinkTimeout,
); err != nil {
return nil, err
}
// If user specified a non-multicast address, make sure it's routable before we start.
if c.cfg.V6ServerAddr != nil {
if err := c.waitFor(
iface,
linkUpTimeout,
isIPv6RouteReady(c.cfg.V6ServerAddr.IP),
ErrIPv6RouteTimeout,
); err != nil {
return nil, err
}
}
mods := []nclient6.ClientOpt{
nclient6.WithTimeout(c.cfg.Timeout),
nclient6.WithRetry(c.cfg.Retries),
c.getDHCP6Logger(),
}
if c.cfg.V6ServerAddr != nil {
mods = append(mods, nclient6.WithBroadcastAddr(c.cfg.V6ServerAddr))
}
conn, err := nclient6.NewIPv6UDPConn(iface.Attrs().Name, clientPort)
if err != nil {
return nil, err
}
client, err := nclient6.NewWithConn(conn, iface.Attrs().HardwareAddr, mods...)
if err != nil {
return nil, err
}
defer client.Close()
// Prepend modifiers with default options, so they can be overridden.
reqmods := append(
[]dhcpv6.Modifier{
dhcpv6.WithNetboot,
},
c.cfg.Modifiers6...)
l.Info().Msg("attempting to get DHCPv6 lease")
p, err := client.RapidSolicit(c.ctx, reqmods...)
if err != nil {
return nil, err
}
l.Info().Msgf("DHCPv6 lease acquired: %s", p.Summary())
return fromNclient6Lease(p, ifname), nil
}

View File

@ -0,0 +1,313 @@
package jetdhcpc
import (
"bufio"
"encoding/binary"
"encoding/json"
"fmt"
"net"
"os"
"reflect"
"strconv"
"strings"
"time"
"github.com/insomniacslk/dhcp/dhcpv4"
"github.com/insomniacslk/dhcp/dhcpv4/nclient4"
"github.com/insomniacslk/dhcp/dhcpv6"
"github.com/jetkvm/kvm/internal/network/types"
)
var (
defaultLeaseTime = time.Duration(30 * time.Minute)
defaultRenewalTime = time.Duration(15 * time.Minute)
)
// Lease is a network configuration obtained by DHCP.
type Lease struct {
types.DHCPLease
p4 *nclient4.Lease
p6 *dhcpv6.Message
isEmpty map[string]bool
}
// ToDHCPLease converts a lease to a DHCP lease.
func (l *Lease) ToDHCPLease() *types.DHCPLease {
lease := &l.DHCPLease
lease.DHCPClient = "jetdhcpc"
return lease
}
// fromNclient4Lease creates a lease from a nclient4.Lease.
func fromNclient4Lease(l *nclient4.Lease, iface string) *Lease {
lease := &Lease{}
lease.p4 = l
// only the fields that we need are set
lease.Routers = l.ACK.Router()
lease.IPAddress = l.ACK.YourIPAddr
lease.Netmask = net.IP(l.ACK.SubnetMask())
lease.Broadcast = l.ACK.BroadcastAddress()
lease.NTPServers = l.ACK.NTPServers()
lease.HostName = l.ACK.HostName()
lease.Domain = l.ACK.DomainName()
searchList := l.ACK.DomainSearch()
if searchList != nil {
lease.SearchList = searchList.Labels
}
lease.DNS = l.ACK.DNS()
lease.ClassIdentifier = l.ACK.ClassIdentifier()
lease.ServerID = l.ACK.ServerIdentifier().String()
mtu := l.ACK.Options.Get(dhcpv4.OptionInterfaceMTU)
if mtu != nil {
lease.MTU = int(binary.BigEndian.Uint16(mtu))
}
lease.Message = l.ACK.Message()
lease.LeaseTime = l.ACK.IPAddressLeaseTime(defaultLeaseTime)
lease.RenewalTime = l.ACK.IPAddressRenewalTime(defaultRenewalTime)
lease.InterfaceName = iface
return lease
}
// fromNclient6Lease creates a lease from a nclient6.Message.
func fromNclient6Lease(l *dhcpv6.Message, iface string) *Lease {
lease := &Lease{}
lease.p6 = l
iana := l.Options.OneIANA()
if iana == nil {
return nil
}
address := iana.Options.OneAddress()
if address == nil {
return nil
}
lease.IPAddress = address.IPv6Addr
lease.Netmask = net.IP(net.CIDRMask(128, 128))
lease.DNS = l.Options.DNS()
// lease.LeaseTime = iana.Options.OnePreferredLifetime()
// lease.RenewalTime = iana.Options.OneValidLifetime()
// lease.RebindingTime = iana.Options.OneRebindingTime()
lease.InterfaceName = iface
return lease
}
func (l *Lease) setIsEmpty(m map[string]bool) {
l.isEmpty = m
}
// IsEmpty returns true if the lease is empty for the given key.
func (l *Lease) IsEmpty(key string) bool {
return l.isEmpty[key]
}
// ToJSON returns the lease as a JSON string.
func (l *Lease) ToJSON() string {
json, err := json.Marshal(l)
if err != nil {
return ""
}
return string(json)
}
// SetLeaseExpiry sets the lease expiry time.
func (l *Lease) SetLeaseExpiry() (time.Time, error) {
if l.Uptime == 0 || l.LeaseTime == 0 {
return time.Time{}, fmt.Errorf("uptime or lease time isn't set")
}
// get the uptime of the device
file, err := os.Open("/proc/uptime")
if err != nil {
return time.Time{}, fmt.Errorf("failed to open uptime file: %w", err)
}
defer file.Close()
var uptime time.Duration
scanner := bufio.NewScanner(file)
for scanner.Scan() {
text := scanner.Text()
parts := strings.Split(text, " ")
uptime, err = time.ParseDuration(parts[0] + "s")
if err != nil {
return time.Time{}, fmt.Errorf("failed to parse uptime: %w", err)
}
}
relativeLeaseRemaining := (l.Uptime + l.LeaseTime) - uptime
leaseExpiry := time.Now().Add(relativeLeaseRemaining)
l.LeaseExpiry = &leaseExpiry
return leaseExpiry, nil
}
func (l *Lease) Apply() error {
if l.p4 != nil {
return l.applyIPv4()
}
if l.p6 != nil {
return l.applyIPv6()
}
return nil
}
func (l *Lease) applyIPv4() error {
return nil
}
func (l *Lease) applyIPv6() error {
return nil
}
// UnmarshalDHCPCLease unmarshals a lease from a string.
func UnmarshalDHCPCLease(lease *Lease, str string) error {
// parse the lease file as a map
data := make(map[string]string)
for _, line := range strings.Split(str, "\n") {
line = strings.TrimSpace(line)
// skip empty lines and comments
if line == "" || strings.HasPrefix(line, "#") {
continue
}
parts := strings.SplitN(line, "=", 2)
if len(parts) != 2 {
continue
}
key := strings.TrimSpace(parts[0])
value := strings.TrimSpace(parts[1])
data[key] = value
}
// now iterate over the lease struct and set the values
leaseType := reflect.TypeOf(lease).Elem()
leaseValue := reflect.ValueOf(lease).Elem()
valuesParsed := make(map[string]bool)
for i := 0; i < leaseType.NumField(); i++ {
field := leaseValue.Field(i)
// get the env tag
key := leaseType.Field(i).Tag.Get("env")
if key == "" {
continue
}
valuesParsed[key] = false
// get the value from the data map
value, ok := data[key]
if !ok || value == "" {
continue
}
switch field.Interface().(type) {
case string:
field.SetString(value)
case int:
val, err := strconv.Atoi(value)
if err != nil {
continue
}
field.SetInt(int64(val))
case time.Duration:
val, err := time.ParseDuration(value + "s")
if err != nil {
continue
}
field.Set(reflect.ValueOf(val))
case net.IP:
ip := net.ParseIP(value)
if ip == nil {
continue
}
field.Set(reflect.ValueOf(ip))
case []net.IP:
val := make([]net.IP, 0)
for _, ipStr := range strings.Fields(value) {
ip := net.ParseIP(ipStr)
if ip == nil {
continue
}
val = append(val, ip)
}
field.Set(reflect.ValueOf(val))
default:
return fmt.Errorf("unsupported field `%s` type: %s", key, field.Type().String())
}
valuesParsed[key] = true
}
lease.setIsEmpty(valuesParsed)
return nil
}
// MarshalDHCPCLease marshals a lease to a string.
func MarshalDHCPCLease(lease *Lease) (string, error) {
leaseType := reflect.TypeOf(lease).Elem()
leaseValue := reflect.ValueOf(lease).Elem()
leaseFile := ""
for i := 0; i < leaseType.NumField(); i++ {
field := leaseValue.Field(i)
key := leaseType.Field(i).Tag.Get("env")
if key == "" {
continue
}
outValue := ""
switch field.Interface().(type) {
case string:
outValue = field.String()
case int:
outValue = strconv.Itoa(int(field.Int()))
case time.Duration:
outValue = strconv.Itoa(int(field.Int()))
case net.IP:
outValue = field.String()
case []net.IP:
ips := field.Interface().([]net.IP)
ipStrings := make([]string, len(ips))
for i, ip := range ips {
ipStrings[i] = ip.String()
}
outValue = strings.Join(ipStrings, " ")
default:
return "", fmt.Errorf("unsupported field `%s` type: %s", key, field.Type().String())
}
leaseFile += fmt.Sprintf("%s=%s\n", key, outValue)
}
return leaseFile, nil
}

View File

@ -0,0 +1,102 @@
package jetdhcpc
import (
"bytes"
"io"
"os"
"path/filepath"
"strconv"
"strings"
"syscall"
"github.com/rs/zerolog"
)
func readFileNoStat(filename string) ([]byte, error) {
const maxBufferSize = 1024 * 1024
f, err := os.Open(filename)
if err != nil {
return nil, err
}
defer f.Close()
reader := io.LimitReader(f, maxBufferSize)
return io.ReadAll(reader)
}
func toCmdline(path string) ([]string, error) {
data, err := readFileNoStat(path)
if err != nil {
return nil, err
}
if len(data) < 1 {
return []string{}, nil
}
return strings.Split(string(bytes.TrimRight(data, "\x00")), "\x00"), nil
}
// KillUdhcpC kills all udhcpc processes
func KillUdhcpC(l *zerolog.Logger) error {
// read procfs for udhcpc processes
// we do not use procfs.AllProcs() because we want to avoid the overhead of reading the entire procfs
processes, err := os.ReadDir("/proc")
if err != nil {
return err
}
matchedPids := make([]int, 0)
// iterate over the processes
for _, d := range processes {
// check if file is numeric
pid, err := strconv.Atoi(d.Name())
if err != nil {
continue
}
// check if it's a directory
if !d.IsDir() {
continue
}
cmdline, err := toCmdline(filepath.Join("/proc", d.Name(), "cmdline"))
if err != nil {
continue
}
if len(cmdline) < 1 {
continue
}
if cmdline[0] != "udhcpc" {
continue
}
matchedPids = append(matchedPids, pid)
}
if len(matchedPids) == 0 {
l.Info().Msg("no udhcpc processes found")
return nil
}
l.Info().Ints("pids", matchedPids).Msg("found udhcpc processes, terminating")
for _, pid := range matchedPids {
err := syscall.Kill(pid, syscall.SIGTERM)
if err != nil {
return err
}
l.Info().Int("pid", pid).Msg("terminated udhcpc process")
}
return nil
}
func (c *Client) killUdhcpc() error {
return KillUdhcpC(c.l)
}

View File

@ -0,0 +1,64 @@
package jetdhcpc
import (
"github.com/insomniacslk/dhcp/dhcpv4"
"github.com/insomniacslk/dhcp/dhcpv4/nclient4"
"github.com/insomniacslk/dhcp/dhcpv6/nclient6"
"github.com/rs/zerolog"
)
type dhcpLogger struct {
// Printfer is used for actual output of the logger
nclient4.Printfer
l *zerolog.Logger
}
// Printf prints a log message as-is via predefined Printfer
func (s dhcpLogger) Printf(format string, v ...interface{}) {
s.l.Info().Msgf(format, v...)
}
// PrintMessage prints a DHCP message in the short format via predefined Printfer
func (s dhcpLogger) PrintMessage(prefix string, message *dhcpv4.DHCPv4) {
s.l.Info().Msgf("%s: %s", prefix, message.String())
}
func summaryStructured(d *dhcpv4.DHCPv4, l *zerolog.Logger) *zerolog.Logger {
logger := l.With().
Str("opCode", d.OpCode.String()).
Str("hwType", d.HWType.String()).
Int("hopCount", int(d.HopCount)).
Str("transactionID", d.TransactionID.String()).
Int("numSeconds", int(d.NumSeconds)).
Str("flagsString", d.FlagsToString()).
Int("flags", int(d.Flags)).
Str("clientIP", d.ClientIPAddr.String()).
Str("yourIP", d.YourIPAddr.String()).
Str("serverIP", d.ServerIPAddr.String()).
Str("gatewayIP", d.GatewayIPAddr.String()).
Str("clientMAC", d.ClientHWAddr.String()).
Str("serverHostname", d.ServerHostName).
Str("bootFileName", d.BootFileName).
Str("options", d.Options.Summary(nil)).
Logger()
return &logger
}
func (c *Client) getDHCP4Logger(ifname string) nclient4.ClientOpt {
logger := c.l.With().
Str("interface", ifname).
Str("source", "dhcp4").
Logger()
return nclient4.WithLogger(dhcpLogger{
l: &logger,
})
}
// TODO: nclient6 doesn't implement the WithLogger option,
// we might need to open a PR to add it
func (c *Client) getDHCP6Logger() nclient6.ClientOpt {
return nclient6.WithSummaryLogger()
}

View File

@ -0,0 +1,247 @@
package jetdhcpc
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"time"
"github.com/jetkvm/kvm/internal/network/types"
)
const (
// DefaultStateDir is the default state directory
DefaultStateDir = "/var/run/"
// DHCPStateFile is the name of the DHCP state file
DHCPStateFile = "jetkvm_dhcp_state.json"
)
// DHCPState represents the persistent state of DHCP clients
type DHCPState struct {
InterfaceStates map[string]*InterfaceDHCPState `json:"interface_states"`
LastUpdated time.Time `json:"last_updated"`
Version string `json:"version"`
}
// InterfaceDHCPState represents the DHCP state for a specific interface
type InterfaceDHCPState struct {
InterfaceName string `json:"interface_name"`
IPv4Enabled bool `json:"ipv4_enabled"`
IPv6Enabled bool `json:"ipv6_enabled"`
IPv4Lease *Lease `json:"ipv4_lease,omitempty"`
IPv6Lease *Lease `json:"ipv6_lease,omitempty"`
LastRenewal time.Time `json:"last_renewal"`
Config *types.NetworkConfig `json:"config,omitempty"`
}
// SaveState saves the current DHCP state to disk
func (c *Client) SaveState(state *DHCPState) error {
if state == nil {
return fmt.Errorf("state cannot be nil")
}
// Return error if state directory doesn't exist
if _, err := os.Stat(c.stateDir); os.IsNotExist(err) {
return fmt.Errorf("state directory does not exist: %w", err)
}
// Update timestamp
state.LastUpdated = time.Now()
state.Version = "1.0"
// Serialize state
data, err := json.MarshalIndent(state, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal state: %w", err)
}
// Write to temporary file first, then rename to ensure atomic operation
tmpFile, err := os.CreateTemp(c.stateDir, DHCPStateFile)
if err != nil {
return fmt.Errorf("failed to create temporary file: %w", err)
}
defer tmpFile.Close()
if err := os.WriteFile(tmpFile.Name(), data, 0644); err != nil {
return fmt.Errorf("failed to write state file: %w", err)
}
stateFile := filepath.Join(c.stateDir, DHCPStateFile)
if err := os.Rename(tmpFile.Name(), stateFile); err != nil {
os.Remove(tmpFile.Name())
return fmt.Errorf("failed to rename state file: %w", err)
}
c.l.Debug().Str("file", stateFile).Msg("DHCP state saved")
return nil
}
// LoadState loads the DHCP state from disk
func (c *Client) LoadState() (*DHCPState, error) {
stateFile := filepath.Join(c.stateDir, DHCPStateFile)
// Check if state file exists
if _, err := os.Stat(stateFile); os.IsNotExist(err) {
c.l.Debug().Msg("No existing DHCP state file found")
return &DHCPState{
InterfaceStates: make(map[string]*InterfaceDHCPState),
LastUpdated: time.Now(),
Version: "1.0",
}, nil
}
// Read state file
data, err := os.ReadFile(stateFile)
if err != nil {
return nil, fmt.Errorf("failed to read state file: %w", err)
}
// Deserialize state
var state DHCPState
if err := json.Unmarshal(data, &state); err != nil {
return nil, fmt.Errorf("failed to unmarshal state: %w", err)
}
// Initialize interface states map if nil
if state.InterfaceStates == nil {
state.InterfaceStates = make(map[string]*InterfaceDHCPState)
}
c.l.Debug().Str("file", stateFile).Msg("DHCP state loaded")
return &state, nil
}
// UpdateInterfaceState updates the state for a specific interface
func (c *Client) UpdateInterfaceState(ifaceName string, state *InterfaceDHCPState) error {
// Load current state
currentState, err := c.LoadState()
if err != nil {
return fmt.Errorf("failed to load current state: %w", err)
}
// Update interface state
currentState.InterfaceStates[ifaceName] = state
// Save updated state
return c.SaveState(currentState)
}
// GetInterfaceState gets the state for a specific interface
func (c *Client) GetInterfaceState(ifaceName string) (*InterfaceDHCPState, error) {
state, err := c.LoadState()
if err != nil {
return nil, fmt.Errorf("failed to load state: %w", err)
}
return state.InterfaceStates[ifaceName], nil
}
// RemoveInterfaceState removes the state for a specific interface
func (c *Client) RemoveInterfaceState(ifaceName string) error {
// Load current state
currentState, err := c.LoadState()
if err != nil {
return fmt.Errorf("failed to load current state: %w", err)
}
// Remove interface state
delete(currentState.InterfaceStates, ifaceName)
// Save updated state
return c.SaveState(currentState)
}
// IsLeaseValid checks if a DHCP lease is still valid
func (c *Client) IsLeaseValid(lease *Lease) bool {
if lease == nil {
return false
}
// Check if lease has expired
if lease.LeaseExpiry == nil {
return false
}
return time.Now().Before(*lease.LeaseExpiry)
}
// ShouldRenewLease checks if a lease should be renewed
func (c *Client) ShouldRenewLease(lease *Lease) bool {
if !c.IsLeaseValid(lease) {
return false
}
expiry := *lease.LeaseExpiry
leaseTime := time.Now().Add(time.Duration(lease.LeaseTime) * time.Second)
// Renew if lease expires within 50% of its lifetime
leaseDuration := expiry.Sub(leaseTime)
renewalTime := leaseTime.Add(leaseDuration / 2)
return time.Now().After(renewalTime)
}
// CleanupExpiredStates removes expired states from the state file
func (c *Client) CleanupExpiredStates() error {
state, err := c.LoadState()
if err != nil {
return fmt.Errorf("failed to load state: %w", err)
}
cleaned := false
for ifaceName, ifaceState := range state.InterfaceStates {
// Remove interface state if both leases are expired
ipv4Valid := c.IsLeaseValid(ifaceState.IPv4Lease)
ipv6Valid := c.IsLeaseValid(ifaceState.IPv6Lease)
if !ipv4Valid && !ipv6Valid {
delete(state.InterfaceStates, ifaceName)
cleaned = true
c.l.Debug().Str("interface", ifaceName).Msg("Removed expired DHCP state")
}
}
if cleaned {
return c.SaveState(state)
}
return nil
}
// GetStateSummary returns a summary of the current state
func (c *Client) GetStateSummary() (map[string]interface{}, error) {
state, err := c.LoadState()
if err != nil {
return nil, fmt.Errorf("failed to load state: %w", err)
}
summary := map[string]interface{}{
"last_updated": state.LastUpdated,
"version": state.Version,
"interface_count": len(state.InterfaceStates),
"interfaces": make(map[string]interface{}),
}
interfaces := summary["interfaces"].(map[string]interface{})
for ifaceName, ifaceState := range state.InterfaceStates {
interfaceInfo := map[string]interface{}{
"ipv4_enabled": ifaceState.IPv4Enabled,
"ipv6_enabled": ifaceState.IPv6Enabled,
"last_renewal": ifaceState.LastRenewal,
// "ipv4_lease_valid": c.IsLeaseValid(ifaceState.IPv4Lease.(*Lease)),
// "ipv6_lease_valid": c.IsLeaseValid(ifaceState.IPv6Lease),
}
if ifaceState.IPv4Lease != nil {
interfaceInfo["ipv4_lease_expiry"] = ifaceState.IPv4Lease.LeaseExpiry
}
if ifaceState.IPv6Lease != nil {
interfaceInfo["ipv6_lease_expiry"] = ifaceState.IPv6Lease.LeaseExpiry
}
interfaces[ifaceName] = interfaceInfo
}
return summary, nil
}

View File

@ -0,0 +1,48 @@
package jetdhcpc
import (
"context"
"time"
"github.com/rs/zerolog"
"github.com/vishvananda/netlink"
)
type waitForCondition func(l netlink.Link, logger *zerolog.Logger) (ready bool, err error)
func (c *Client) waitFor(
link netlink.Link,
timeout <-chan time.Time,
condition waitForCondition,
timeoutError error,
) error {
return waitFor(c.ctx, link, c.l, timeout, condition, timeoutError)
}
func waitFor(
ctx context.Context,
link netlink.Link,
logger *zerolog.Logger,
timeout <-chan time.Time,
condition waitForCondition,
timeoutError error,
) error {
for {
if ready, err := condition(link, logger); err != nil {
return err
} else if ready {
break
}
select {
case <-time.After(100 * time.Millisecond):
continue
case <-timeout:
return timeoutError
case <-ctx.Done():
return timeoutError
}
}
return nil
}

13
pkg/nmlite/link/consts.go Normal file
View File

@ -0,0 +1,13 @@
package link
const (
// AfUnspec is the unspecified address family constant
AfUnspec = 0
// AfInet is the IPv4 address family constant
AfInet = 2
// AfInet6 is the IPv6 address family constant
AfInet6 = 10
sysctlBase = "/proc/sys"
sysctlFileMode = 0640
)

544
pkg/nmlite/link/manager.go Normal file
View File

@ -0,0 +1,544 @@
package link
import (
"context"
"fmt"
"net"
"time"
"github.com/jetkvm/kvm/internal/sync"
"github.com/jetkvm/kvm/internal/network/types"
"github.com/rs/zerolog"
"github.com/vishvananda/netlink"
)
// StateChangeHandler is the function type for link state callbacks
type StateChangeHandler func(link *Link)
// StateChangeCallback is the struct for link state callbacks
type StateChangeCallback struct {
Async bool
Func StateChangeHandler
}
// NetlinkManager provides centralized netlink operations
type NetlinkManager struct {
logger *zerolog.Logger
mu sync.RWMutex
stateChangeCallbacks map[string][]StateChangeCallback
}
func newNetlinkManager(logger *zerolog.Logger) *NetlinkManager {
if logger == nil {
logger = &zerolog.Logger{} // Default no-op logger
}
n := &NetlinkManager{
logger: logger,
stateChangeCallbacks: make(map[string][]StateChangeCallback),
}
n.monitorStateChange()
return n
}
// GetNetlinkManager returns the singleton NetlinkManager instance
func GetNetlinkManager() *NetlinkManager {
netlinkManagerOnce.Do(func() {
netlinkManagerInstance = newNetlinkManager(nil)
})
return netlinkManagerInstance
}
// InitializeNetlinkManager initializes the singleton NetlinkManager with a logger
func InitializeNetlinkManager(logger *zerolog.Logger) *NetlinkManager {
netlinkManagerOnce.Do(func() {
netlinkManagerInstance = newNetlinkManager(logger)
})
return netlinkManagerInstance
}
// AddStateChangeCallback adds a callback for link state changes
func (nm *NetlinkManager) AddStateChangeCallback(ifname string, callback StateChangeCallback) {
nm.mu.Lock()
defer nm.mu.Unlock()
if _, ok := nm.stateChangeCallbacks[ifname]; !ok {
nm.stateChangeCallbacks[ifname] = make([]StateChangeCallback, 0)
}
nm.stateChangeCallbacks[ifname] = append(nm.stateChangeCallbacks[ifname], callback)
}
// Interface operations
func (nm *NetlinkManager) monitorStateChange() {
updateCh := make(chan netlink.LinkUpdate)
// we don't need to stop the subscription, as it will be closed when the program exits
stopCh := make(chan struct{}) //nolint:unused
if err := netlink.LinkSubscribe(updateCh, stopCh); err != nil {
nm.logger.Error().Err(err).Msg("failed to subscribe to link state changes")
}
nm.logger.Info().Msg("state change monitoring started")
go func() {
for update := range updateCh {
nm.runCallbacks(update)
}
}()
}
func (nm *NetlinkManager) runCallbacks(update netlink.LinkUpdate) {
nm.mu.RLock()
defer nm.mu.RUnlock()
ifname := update.Link.Attrs().Name
callbacks, ok := nm.stateChangeCallbacks[ifname]
l := nm.logger.With().Str("interface", ifname).Logger()
if !ok {
l.Trace().Msg("no state change callbacks for interface")
return
}
for _, callback := range callbacks {
l.Trace().
Interface("callback", callback).
Bool("async", callback.Async).
Msg("calling callback")
if callback.Async {
go callback.Func(&Link{Link: update.Link})
} else {
callback.Func(&Link{Link: update.Link})
}
}
}
// GetLinkByName gets a network link by name
func (nm *NetlinkManager) GetLinkByName(name string) (*Link, error) {
nm.mu.RLock()
defer nm.mu.RUnlock()
link, err := netlink.LinkByName(name)
if err != nil {
return nil, err
}
return &Link{Link: link}, nil
}
// LinkSetUp brings a network interface up
func (nm *NetlinkManager) LinkSetUp(link *Link) error {
nm.mu.RLock()
defer nm.mu.RUnlock()
return netlink.LinkSetUp(link)
}
// LinkSetDown brings a network interface down
func (nm *NetlinkManager) LinkSetDown(link *Link) error {
nm.mu.RLock()
defer nm.mu.RUnlock()
return netlink.LinkSetDown(link)
}
// EnsureInterfaceUp ensures the interface is up
func (nm *NetlinkManager) EnsureInterfaceUp(link *Link) error {
if link.Attrs().OperState == netlink.OperUp {
return nil
}
return nm.LinkSetUp(link)
}
// EnsureInterfaceUpWithTimeout ensures the interface is up with timeout and retry logic
func (nm *NetlinkManager) EnsureInterfaceUpWithTimeout(ctx context.Context, iface *Link, timeout time.Duration) (*Link, error) {
ifname := iface.Attrs().Name
l := nm.logger.With().Str("interface", ifname).Logger()
linkUpTimeout := time.After(timeout)
var attempt int
start := time.Now()
for {
link, err := nm.GetLinkByName(ifname)
if err != nil {
return nil, err
}
state := link.Attrs().OperState
l = l.With().
Int("attempt", attempt).
Dur("duration", time.Since(start)).
Str("state", state.String()).
Logger()
if state == netlink.OperUp || state == netlink.OperUnknown {
if attempt > 0 {
l.Info().Int("attempt", attempt-1).Msg("interface is up")
}
return link, nil
}
l.Info().Msg("bringing up interface")
// bring up the interface
if err = nm.LinkSetUp(link); err != nil {
l.Error().Err(err).Msg("interface can't make it up")
}
// refresh the link attributes
if err = link.Refresh(); err != nil {
l.Error().Err(err).Msg("failed to refresh link attributes")
}
// check the state again
state = link.Attrs().OperState
l = l.With().Str("new_state", state.String()).Logger()
if state == netlink.OperUp {
l.Info().Msg("interface is up")
return link, nil
}
l.Warn().Msg("interface is still down, retrying")
select {
case <-time.After(500 * time.Millisecond):
attempt++
continue
case <-ctx.Done():
if err != nil {
return nil, err
}
return nil, ErrInterfaceUpCanceled
case <-linkUpTimeout:
attempt++
l.Error().
Int("attempt", attempt).Msg("interface is still down after timeout")
if err != nil {
return nil, err
}
return nil, ErrInterfaceUpTimeout
}
}
}
// Address operations
// AddrList gets all addresses for a link
func (nm *NetlinkManager) AddrList(link *Link, family int) ([]netlink.Addr, error) {
nm.mu.RLock()
defer nm.mu.RUnlock()
return netlink.AddrList(link, family)
}
// AddrAdd adds an address to a link
func (nm *NetlinkManager) AddrAdd(link *Link, addr *netlink.Addr) error {
nm.mu.RLock()
defer nm.mu.RUnlock()
return netlink.AddrAdd(link, addr)
}
// AddrDel removes an address from a link
func (nm *NetlinkManager) AddrDel(link *Link, addr *netlink.Addr) error {
nm.mu.RLock()
defer nm.mu.RUnlock()
return netlink.AddrDel(link, addr)
}
// RemoveAllAddresses removes all addresses of a specific family from a link
func (nm *NetlinkManager) RemoveAllAddresses(link *Link, family int) error {
addrs, err := nm.AddrList(link, family)
if err != nil {
return fmt.Errorf("failed to get addresses: %w", err)
}
for _, addr := range addrs {
if err := nm.AddrDel(link, &addr); err != nil {
nm.logger.Warn().Err(err).Str("address", addr.IP.String()).Msg("failed to remove address")
}
}
return nil
}
// RemoveNonLinkLocalIPv6Addresses removes all non-link-local IPv6 addresses
func (nm *NetlinkManager) RemoveNonLinkLocalIPv6Addresses(link *Link) error {
addrs, err := nm.AddrList(link, AfInet6)
if err != nil {
return fmt.Errorf("failed to get IPv6 addresses: %w", err)
}
for _, addr := range addrs {
if !addr.IP.IsLinkLocalUnicast() {
if err := nm.AddrDel(link, &addr); err != nil {
nm.logger.Warn().Err(err).Str("address", addr.IP.String()).Msg("failed to remove IPv6 address")
}
}
}
return nil
}
// RouteList gets all routes
func (nm *NetlinkManager) RouteList(link *Link, family int) ([]netlink.Route, error) {
nm.mu.RLock()
defer nm.mu.RUnlock()
return netlink.RouteList(link, family)
}
// RouteAdd adds a route
func (nm *NetlinkManager) RouteAdd(route *netlink.Route) error {
nm.mu.RLock()
defer nm.mu.RUnlock()
return netlink.RouteAdd(route)
}
// RouteDel removes a route
func (nm *NetlinkManager) RouteDel(route *netlink.Route) error {
nm.mu.RLock()
defer nm.mu.RUnlock()
return netlink.RouteDel(route)
}
// RouteReplace replaces a route
func (nm *NetlinkManager) RouteReplace(route *netlink.Route) error {
nm.mu.RLock()
defer nm.mu.RUnlock()
return netlink.RouteReplace(route)
}
// ListDefaultRoutes lists the default routes for the given family
func (nm *NetlinkManager) ListDefaultRoutes(family int) ([]netlink.Route, error) {
routes, err := netlink.RouteListFiltered(
family,
&netlink.Route{Dst: nil, Table: 254},
netlink.RT_FILTER_DST|netlink.RT_FILTER_TABLE,
)
if err != nil {
nm.logger.Error().Err(err).Int("family", family).Msg("failed to list default routes")
return nil, err
}
return routes, nil
}
// HasDefaultRoute checks if a default route exists for the given family
func (nm *NetlinkManager) HasDefaultRoute(family int) bool {
routes, err := nm.ListDefaultRoutes(family)
if err != nil {
return false
}
return len(routes) > 0
}
// AddDefaultRoute adds a default route
func (nm *NetlinkManager) AddDefaultRoute(link *Link, gateway net.IP, family int) error {
var dst *net.IPNet
switch family {
case AfInet:
dst = &ipv4DefaultRoute
case AfInet6:
dst = &ipv6DefaultRoute
default:
return fmt.Errorf("unsupported address family: %d", family)
}
route := &netlink.Route{
Dst: dst,
Gw: gateway,
LinkIndex: link.Attrs().Index,
}
return nm.RouteReplace(route)
}
// RemoveDefaultRoute removes the default route for the given family
func (nm *NetlinkManager) RemoveDefaultRoute(family int) error {
routes, err := nm.RouteList(nil, family)
if err != nil {
return fmt.Errorf("failed to get routes: %w", err)
}
for _, route := range routes {
if route.Dst != nil {
if family == AfInet && route.Dst.IP.Equal(net.IPv4zero) && route.Dst.Mask.String() == "0.0.0.0/0" {
if err := nm.RouteDel(&route); err != nil {
nm.logger.Warn().Err(err).Msg("failed to remove IPv4 default route")
}
}
if family == AfInet6 && route.Dst.IP.Equal(net.IPv6zero) && route.Dst.Mask.String() == "::/0" {
if err := nm.RouteDel(&route); err != nil {
nm.logger.Warn().Err(err).Msg("failed to remove IPv6 default route")
}
}
}
}
return nil
}
func (nm *NetlinkManager) reconcileDefaultRoute(link *Link, expected map[string]net.IP, family int) error {
linkIndex := link.Attrs().Index
added := 0
toRemove := make([]*netlink.Route, 0)
defaultRoutes, err := nm.ListDefaultRoutes(family)
if err != nil {
return fmt.Errorf("failed to get default routes: %w", err)
}
// check existing default routes
for _, defaultRoute := range defaultRoutes {
// only check the default routes for the current link
// TODO: we should also check others later
if defaultRoute.LinkIndex != linkIndex {
continue
}
key := defaultRoute.Gw.String()
if _, ok := expected[key]; !ok {
toRemove = append(toRemove, &defaultRoute)
continue
}
nm.logger.Warn().Str("gateway", key).Msg("keeping default route")
delete(expected, key)
}
// remove remaining default routes
for _, defaultRoute := range toRemove {
nm.logger.Warn().Str("gateway", defaultRoute.Gw.String()).Msg("removing default route")
if err := nm.RouteDel(defaultRoute); err != nil {
nm.logger.Warn().Err(err).Msg("failed to remove default route")
}
}
// add remaining expected default routes
for _, gateway := range expected {
nm.logger.Warn().Str("gateway", gateway.String()).Msg("adding default route")
route := &netlink.Route{
Dst: &ipv4DefaultRoute,
Gw: gateway,
LinkIndex: linkIndex,
}
if family == AfInet6 {
route.Dst = &ipv6DefaultRoute
}
if err := nm.RouteAdd(route); err != nil {
nm.logger.Warn().Err(err).Interface("route", route).Msg("failed to add default route")
}
added++
}
nm.logger.Info().
Int("added", added).
Int("removed", len(toRemove)).
Msg("default routes reconciled")
return nil
}
// ReconcileLink reconciles the addresses and routes of a link
func (nm *NetlinkManager) ReconcileLink(link *Link, expected []types.IPAddress, family int) error {
toAdd := make([]*types.IPAddress, 0)
toRemove := make([]*netlink.Addr, 0)
toUpdate := make([]*types.IPAddress, 0)
expectedAddrs := make(map[string]*types.IPAddress)
expectedGateways := make(map[string]net.IP)
mtu := link.Attrs().MTU
expectedMTU := mtu
// add all expected addresses to the map
for _, addr := range expected {
expectedAddrs[addr.String()] = &addr
if addr.Gateway != nil {
expectedGateways[addr.String()] = addr.Gateway
}
if addr.MTU != 0 {
mtu = addr.MTU
}
}
if expectedMTU != mtu {
if err := link.SetMTU(expectedMTU); err != nil {
nm.logger.Warn().Err(err).Int("expected_mtu", expectedMTU).Int("mtu", mtu).Msg("failed to set MTU")
}
}
addrs, err := nm.AddrList(link, family)
if err != nil {
return fmt.Errorf("failed to get addresses: %w", err)
}
// check existing addresses
for _, addr := range addrs {
// skip the link-local address
if addr.IP.IsLinkLocalUnicast() {
continue
}
expectedAddr, ok := expectedAddrs[addr.IPNet.String()]
if !ok {
toRemove = append(toRemove, &addr)
continue
}
// if it's not fully equal, we need to update it
if !expectedAddr.Compare(addr) {
toUpdate = append(toUpdate, expectedAddr)
continue
}
// remove it from expected addresses
delete(expectedAddrs, addr.IPNet.String())
}
// add remaining expected addresses
for _, addr := range expectedAddrs {
toAdd = append(toAdd, addr)
}
for _, addr := range toUpdate {
netlinkAddr := addr.NetlinkAddr()
if err := nm.AddrDel(link, &netlinkAddr); err != nil {
nm.logger.Warn().Err(err).Str("address", addr.Address.String()).Msg("failed to update address")
}
// we'll add it again later
toAdd = append(toAdd, addr)
}
for _, addr := range toAdd {
netlinkAddr := addr.NetlinkAddr()
if err := nm.AddrAdd(link, &netlinkAddr); err != nil {
nm.logger.Warn().Err(err).Str("address", addr.Address.String()).Msg("failed to add address")
}
}
for _, netlinkAddr := range toRemove {
if err := nm.AddrDel(link, netlinkAddr); err != nil {
nm.logger.Warn().Err(err).Str("address", netlinkAddr.IP.String()).Msg("failed to remove address")
}
}
for _, addr := range toAdd {
netlinkAddr := addr.NetlinkAddr()
if err := nm.AddrAdd(link, &netlinkAddr); err != nil {
nm.logger.Warn().Err(err).Str("address", addr.Address.String()).Msg("failed to add address")
}
}
actualToAdd := len(toAdd) - len(toUpdate)
if len(toAdd) > 0 || len(toUpdate) > 0 || len(toRemove) > 0 {
nm.logger.Info().
Int("added", actualToAdd).
Int("updated", len(toUpdate)).
Int("removed", len(toRemove)).
Msg("addresses reconciled")
}
if err := nm.reconcileDefaultRoute(link, expectedGateways, family); err != nil {
nm.logger.Warn().Err(err).Msg("failed to reconcile default route")
}
return nil
}

164
pkg/nmlite/link/netlink.go Normal file
View File

@ -0,0 +1,164 @@
// Package link provides a wrapper around netlink.Link and provides a singleton netlink manager.
package link
import (
"errors"
"fmt"
"net"
"github.com/jetkvm/kvm/internal/sync"
"github.com/vishvananda/netlink"
)
var (
ipv4DefaultRoute = net.IPNet{
IP: net.IPv4zero,
Mask: net.CIDRMask(0, 0),
}
ipv6DefaultRoute = net.IPNet{
IP: net.IPv6zero,
Mask: net.CIDRMask(0, 0),
}
// Singleton instance
netlinkManagerInstance *NetlinkManager
netlinkManagerOnce sync.Once
// ErrInterfaceUpTimeout is the error returned when the interface does not come up within the timeout
ErrInterfaceUpTimeout = errors.New("timeout after waiting for an interface to come up")
// ErrInterfaceUpCanceled is the error returned when the interface does not come up due to context cancellation
ErrInterfaceUpCanceled = errors.New("context canceled while waiting for an interface to come up")
)
// Link is a wrapper around netlink.Link
type Link struct {
netlink.Link
mu sync.Mutex
}
// All lock actions should be done in external functions
// and the internal functions should not be called directly
func (l *Link) refresh() error {
linkName := l.ifName()
link, err := netlink.LinkByName(linkName)
if err != nil {
return err
}
if link == nil {
return fmt.Errorf("link not found: %s", linkName)
}
l.Link = link
return nil
}
func (l *Link) attrs() *netlink.LinkAttrs {
return l.Link.Attrs()
}
func (l *Link) ifName() string {
attrs := l.attrs()
if attrs.Name == "" {
return ""
}
return attrs.Name
}
// Refresh refreshes the link
func (l *Link) Refresh() error {
l.mu.Lock()
defer l.mu.Unlock()
return l.refresh()
}
// Attrs returns the attributes of the link
func (l *Link) Attrs() *netlink.LinkAttrs {
l.mu.Lock()
defer l.mu.Unlock()
return l.attrs()
}
// Interface returns the interface of the link
func (l *Link) Interface() *net.Interface {
l.mu.Lock()
defer l.mu.Unlock()
ifname := l.ifName()
if ifname == "" {
return nil
}
iface, err := net.InterfaceByName(ifname)
if err != nil {
return nil
}
return iface
}
// HardwareAddr returns the hardware address of the link
func (l *Link) HardwareAddr() net.HardwareAddr {
l.mu.Lock()
defer l.mu.Unlock()
attrs := l.attrs()
if attrs.HardwareAddr == nil {
return nil
}
return attrs.HardwareAddr
}
// AddrList returns the addresses of the link
func (l *Link) AddrList(family int) ([]netlink.Addr, error) {
l.mu.Lock()
defer l.mu.Unlock()
return netlink.AddrList(l.Link, family)
}
func (l *Link) SetMTU(mtu int) error {
l.mu.Lock()
defer l.mu.Unlock()
return netlink.LinkSetMTU(l.Link, mtu)
}
// HasGlobalUnicastAddress returns true if the link has a global unicast address
func (l *Link) HasGlobalUnicastAddress() bool {
addrs, err := l.AddrList(AfUnspec)
if err != nil {
return false
}
for _, addr := range addrs {
if addr.IP.IsGlobalUnicast() {
return true
}
}
return false
}
// IsSame checks if the link is the same as another link
func (l *Link) IsSame(other *Link) bool {
if l == nil || other == nil {
return false
}
a := l.Attrs()
b := other.Attrs()
if a.OperState != b.OperState {
return false
}
if a.Index != b.Index {
return false
}
if a.MTU != b.MTU {
return false
}
if a.HardwareAddr.String() != b.HardwareAddr.String() {
return false
}
return true
}

52
pkg/nmlite/link/sysctl.go Normal file
View File

@ -0,0 +1,52 @@
package link
import (
"fmt"
"os"
"path"
"strconv"
"strings"
)
func (nm *NetlinkManager) setSysctlValues(ifaceName string, values map[string]int) error {
for name, value := range values {
name = fmt.Sprintf(name, ifaceName)
name = strings.ReplaceAll(name, ".", "/")
if err := os.WriteFile(path.Join(sysctlBase, name), []byte(strconv.Itoa(value)), sysctlFileMode); err != nil {
return fmt.Errorf("failed to set sysctl %s=%d: %w", name, value, err)
}
}
return nil
}
// EnableIPv6 enables IPv6 on the interface
func (nm *NetlinkManager) EnableIPv6(ifaceName string) error {
return nm.setSysctlValues(ifaceName, map[string]int{
"net.ipv6.conf.%s.disable_ipv6": 0,
"net.ipv6.conf.%s.accept_ra": 2,
})
}
// DisableIPv6 disables IPv6 on the interface
func (nm *NetlinkManager) DisableIPv6(ifaceName string) error {
return nm.setSysctlValues(ifaceName, map[string]int{
"net.ipv6.conf.%s.disable_ipv6": 1,
})
}
// EnableIPv6SLAAC enables IPv6 SLAAC on the interface
func (nm *NetlinkManager) EnableIPv6SLAAC(ifaceName string) error {
return nm.setSysctlValues(ifaceName, map[string]int{
"net.ipv6.conf.%s.disable_ipv6": 0,
"net.ipv6.conf.%s.accept_ra": 2,
})
}
// EnableIPv6LinkLocal enables IPv6 link-local only on the interface
func (nm *NetlinkManager) EnableIPv6LinkLocal(ifaceName string) error {
return nm.setSysctlValues(ifaceName, map[string]int{
"net.ipv6.conf.%s.disable_ipv6": 0,
"net.ipv6.conf.%s.accept_ra": 0,
})
}

13
pkg/nmlite/link/types.go Normal file
View File

@ -0,0 +1,13 @@
package link
import (
"net"
)
// IPv4Address represents an IPv4 address and its gateway
type IPv4Address struct {
Address net.IPNet
Gateway net.IP
Secondary bool
Permanent bool
}

87
pkg/nmlite/link/utils.go Normal file
View File

@ -0,0 +1,87 @@
package link
import (
"fmt"
"net"
"strings"
)
// ParseIPv4Netmask parses an IPv4 netmask string and returns the IPNet
func ParseIPv4Netmask(address, netmask string) (*net.IPNet, error) {
if strings.Contains(address, "/") {
_, ipNet, err := net.ParseCIDR(address)
if err != nil {
return nil, fmt.Errorf("invalid IPv4 address: %s", address)
}
return ipNet, nil
}
ip := net.ParseIP(address)
if ip == nil {
return nil, fmt.Errorf("invalid IPv4 address: %s", address)
}
if ip.To4() == nil {
return nil, fmt.Errorf("not an IPv4 address: %s", address)
}
mask := net.ParseIP(netmask)
if mask == nil {
return nil, fmt.Errorf("invalid IPv4 netmask: %s", netmask)
}
if mask.To4() == nil {
return nil, fmt.Errorf("not an IPv4 netmask: %s", netmask)
}
return &net.IPNet{
IP: ip,
Mask: net.IPv4Mask(mask[12], mask[13], mask[14], mask[15]),
}, nil
}
// ParseIPv6Prefix parses an IPv6 address and prefix length
func ParseIPv6Prefix(address string, prefixLength int) (*net.IPNet, error) {
if strings.Contains(address, "/") {
_, ipNet, err := net.ParseCIDR(address)
if err != nil {
return nil, fmt.Errorf("invalid IPv6 address: %s", address)
}
return ipNet, nil
}
ip := net.ParseIP(address)
if ip == nil {
return nil, fmt.Errorf("invalid IPv6 address: %s", address)
}
if ip.To16() == nil || ip.To4() != nil {
return nil, fmt.Errorf("not an IPv6 address: %s", address)
}
if prefixLength < 0 || prefixLength > 128 {
return nil, fmt.Errorf("invalid IPv6 prefix length: %d (must be 0-128)", prefixLength)
}
return &net.IPNet{
IP: ip,
Mask: net.CIDRMask(prefixLength, 128),
}, nil
}
// ValidateIPAddress validates an IP address
func ValidateIPAddress(address string, isIPv6 bool) error {
ip := net.ParseIP(address)
if ip == nil {
return fmt.Errorf("invalid IP address: %s", address)
}
if isIPv6 {
if ip.To16() == nil || ip.To4() != nil {
return fmt.Errorf("not an IPv6 address: %s", address)
}
} else {
if ip.To4() == nil {
return fmt.Errorf("not an IPv4 address: %s", address)
}
}
return nil
}

260
pkg/nmlite/manager.go Normal file
View File

@ -0,0 +1,260 @@
// Package nmlite provides a lightweight network management system.
// It supports multiple network interfaces with static and DHCP configuration,
// IPv4/IPv6 support, and proper separation of concerns.
package nmlite
import (
"context"
"fmt"
"github.com/jetkvm/kvm/internal/sync"
"github.com/jetkvm/kvm/internal/logging"
"github.com/jetkvm/kvm/internal/network/types"
"github.com/jetkvm/kvm/pkg/nmlite/jetdhcpc"
"github.com/jetkvm/kvm/pkg/nmlite/link"
"github.com/rs/zerolog"
)
// NetworkManager manages multiple network interfaces
type NetworkManager struct {
interfaces map[string]*InterfaceManager
mu sync.RWMutex
logger *zerolog.Logger
ctx context.Context
cancel context.CancelFunc
resolvConf *ResolvConfManager
// Callback functions for state changes
onInterfaceStateChange func(iface string, state types.InterfaceState)
onConfigChange func(iface string, config *types.NetworkConfig)
onDHCPLeaseChange func(iface string, lease *types.DHCPLease)
}
// NewNetworkManager creates a new network manager
func NewNetworkManager(ctx context.Context, logger *zerolog.Logger) *NetworkManager {
if logger == nil {
logger = logging.GetSubsystemLogger("nm")
}
// Initialize the NetlinkManager singleton
link.InitializeNetlinkManager(logger)
ctx, cancel := context.WithCancel(ctx)
return &NetworkManager{
interfaces: make(map[string]*InterfaceManager),
logger: logger,
ctx: ctx,
cancel: cancel,
resolvConf: NewResolvConfManager(logger),
}
}
// SetHostname sets the hostname and domain for the network manager
func (nm *NetworkManager) SetHostname(hostname string, domain string) error {
return nm.resolvConf.SetHostname(hostname, domain)
}
// Domain returns the effective domain for the network manager
func (nm *NetworkManager) Domain() string {
return nm.resolvConf.Domain()
}
// AddInterface adds a new network interface to be managed
func (nm *NetworkManager) AddInterface(iface string, config *types.NetworkConfig) error {
nm.mu.Lock()
defer nm.mu.Unlock()
if _, exists := nm.interfaces[iface]; exists {
return fmt.Errorf("interface %s already managed", iface)
}
im, err := NewInterfaceManager(nm.ctx, iface, config, nm.logger)
if err != nil {
return fmt.Errorf("failed to create interface manager for %s: %w", iface, err)
}
// Set up callbacks
im.SetOnStateChange(func(state types.InterfaceState) {
if nm.onInterfaceStateChange != nil {
state.Hostname = nm.Hostname()
nm.onInterfaceStateChange(iface, state)
}
})
im.SetOnConfigChange(func(config *types.NetworkConfig) {
if nm.onConfigChange != nil {
nm.onConfigChange(iface, config)
}
})
im.SetOnDHCPLeaseChange(func(lease *types.DHCPLease) {
if nm.onDHCPLeaseChange != nil {
nm.onDHCPLeaseChange(iface, lease)
}
})
im.SetOnResolvConfChange(func(family int, resolvConf *types.InterfaceResolvConf) error {
return nm.resolvConf.SetInterfaceConfig(iface, family, *resolvConf)
})
nm.interfaces[iface] = im
// Start monitoring the interface
if err := im.Start(); err != nil {
delete(nm.interfaces, iface)
return fmt.Errorf("failed to start interface manager for %s: %w", iface, err)
}
nm.logger.Info().Str("interface", iface).Msg("added interface to network manager")
return nil
}
// RemoveInterface removes a network interface from management
func (nm *NetworkManager) RemoveInterface(iface string) error {
nm.mu.Lock()
defer nm.mu.Unlock()
im, exists := nm.interfaces[iface]
if !exists {
return fmt.Errorf("interface %s not managed", iface)
}
if err := im.Stop(); err != nil {
nm.logger.Error().Err(err).Str("interface", iface).Msg("failed to stop interface manager")
}
delete(nm.interfaces, iface)
nm.logger.Info().Str("interface", iface).Msg("removed interface from network manager")
return nil
}
// GetInterface returns the interface manager for a specific interface
func (nm *NetworkManager) GetInterface(iface string) (*InterfaceManager, error) {
nm.mu.RLock()
defer nm.mu.RUnlock()
im, exists := nm.interfaces[iface]
if !exists {
return nil, fmt.Errorf("interface %s not managed", iface)
}
return im, nil
}
// ListInterfaces returns a list of all managed interfaces
func (nm *NetworkManager) ListInterfaces() []string {
nm.mu.RLock()
defer nm.mu.RUnlock()
interfaces := make([]string, 0, len(nm.interfaces))
for iface := range nm.interfaces {
interfaces = append(interfaces, iface)
}
return interfaces
}
// GetInterfaceState returns the current state of a specific interface
func (nm *NetworkManager) GetInterfaceState(iface string) (*types.InterfaceState, error) {
im, err := nm.GetInterface(iface)
if err != nil {
return nil, err
}
state := im.GetState()
state.Hostname = nm.Hostname()
return state, nil
}
// GetInterfaceConfig returns the current configuration of a specific interface
func (nm *NetworkManager) GetInterfaceConfig(iface string) (*types.NetworkConfig, error) {
im, err := nm.GetInterface(iface)
if err != nil {
return nil, err
}
return im.GetConfig(), nil
}
// SetInterfaceConfig updates the configuration of a specific interface
func (nm *NetworkManager) SetInterfaceConfig(iface string, config *types.NetworkConfig) error {
im, err := nm.GetInterface(iface)
if err != nil {
return err
}
return im.SetConfig(config)
}
// RenewDHCPLease renews the DHCP lease for a specific interface
func (nm *NetworkManager) RenewDHCPLease(iface string) error {
im, err := nm.GetInterface(iface)
if err != nil {
return err
}
return im.RenewDHCPLease()
}
// SetOnInterfaceStateChange sets the callback for interface state changes
func (nm *NetworkManager) SetOnInterfaceStateChange(callback func(iface string, state types.InterfaceState)) {
nm.onInterfaceStateChange = callback
}
// SetOnConfigChange sets the callback for configuration changes
func (nm *NetworkManager) SetOnConfigChange(callback func(iface string, config *types.NetworkConfig)) {
nm.onConfigChange = callback
}
// SetOnDHCPLeaseChange sets the callback for DHCP lease changes
func (nm *NetworkManager) SetOnDHCPLeaseChange(callback func(iface string, lease *types.DHCPLease)) {
nm.onDHCPLeaseChange = callback
}
func (nm *NetworkManager) shouldKillLegacyDHCPClients() bool {
nm.mu.RLock()
defer nm.mu.RUnlock()
// TODO: remove it when we need to support multiple interfaces
for _, im := range nm.interfaces {
if im.dhcpClient.clientType != "udhcpc" {
return true
}
if im.config.IPv4Mode.String != "dhcp" {
return true
}
}
return false
}
// CleanUpLegacyDHCPClients cleans up legacy DHCP clients
func (nm *NetworkManager) CleanUpLegacyDHCPClients() error {
shouldKill := nm.shouldKillLegacyDHCPClients()
if shouldKill {
return jetdhcpc.KillUdhcpC(nm.logger)
}
return nil
}
// Stop stops the network manager and all managed interfaces
func (nm *NetworkManager) Stop() error {
nm.mu.Lock()
defer nm.mu.Unlock()
var lastErr error
for iface, im := range nm.interfaces {
if err := im.Stop(); err != nil {
nm.logger.Error().Err(err).Str("interface", iface).Msg("failed to stop interface manager")
lastErr = err
}
}
nm.cancel()
nm.logger.Info().Msg("network manager stopped")
return lastErr
}

7
pkg/nmlite/netlink.go Normal file
View File

@ -0,0 +1,7 @@
package nmlite
import "github.com/jetkvm/kvm/pkg/nmlite/link"
func getNetlinkManager() *link.NetlinkManager {
return link.GetNetlinkManager()
}

209
pkg/nmlite/resolvconf.go Normal file
View File

@ -0,0 +1,209 @@
package nmlite
import (
"bytes"
"fmt"
"html/template"
"os"
"strings"
"github.com/jetkvm/kvm/internal/network/types"
"github.com/jetkvm/kvm/internal/sync"
"github.com/jetkvm/kvm/pkg/nmlite/link"
"github.com/rs/zerolog"
)
const (
resolvConfPath = "/etc/resolv.conf"
resolvConfFileMode = 0644
resolvConfTemplate = `# the resolv.conf file is managed by JetKVM
# DO NOT EDIT THIS FILE BY HAND -- YOUR CHANGES WILL BE OVERWRITTEN
{{ if .searchList }}
search {{ join .searchList " " }}
{{- end -}}
{{ if .domain }}
domain {{ .domain }}
{{- end -}}
{{ range $ns, $comment := .nameservers }}
nameserver {{ printf "%s" $ns }} # {{ join $comment ", " }}
{{- end }}
`
)
var (
tplFuncMap = template.FuncMap{
"join": strings.Join,
}
)
// ResolvConfManager manages the resolv.conf file
type ResolvConfManager struct {
logger *zerolog.Logger
mu sync.Mutex
conf *types.ResolvConf
hostname string
domain string
}
// NewResolvConfManager creates a new resolv.conf manager
func NewResolvConfManager(logger *zerolog.Logger) *ResolvConfManager {
if logger == nil {
// Create a no-op logger if none provided
logger = &zerolog.Logger{}
}
return &ResolvConfManager{
logger: logger,
mu: sync.Mutex{},
conf: &types.ResolvConf{
ConfigIPv4: make(map[string]types.InterfaceResolvConf),
ConfigIPv6: make(map[string]types.InterfaceResolvConf),
},
}
}
// SetInterfaceConfig sets the resolv.conf configuration for a specific interface
func (rcm *ResolvConfManager) SetInterfaceConfig(iface string, family int, config types.InterfaceResolvConf) error {
// DO NOT USE defer HERE, rcm.update() also locks the mutex
rcm.mu.Lock()
switch family {
case link.AfInet:
rcm.conf.ConfigIPv4[iface] = config
case link.AfInet6:
rcm.conf.ConfigIPv6[iface] = config
default:
rcm.mu.Unlock()
return fmt.Errorf("invalid family: %d", family)
}
rcm.mu.Unlock()
if err := rcm.reconcileHostname(); err != nil {
return fmt.Errorf("failed to reconcile hostname: %w", err)
}
return rcm.update()
}
// SetConfig sets the resolv.conf configuration
func (rcm *ResolvConfManager) SetConfig(resolvConf *types.ResolvConf) error {
if resolvConf == nil {
return fmt.Errorf("resolvConf cannot be nil")
}
rcm.mu.Lock()
rcm.conf = resolvConf
defer rcm.mu.Unlock()
return rcm.update()
}
// Reconcile reconciles the resolv.conf configuration
func (rcm *ResolvConfManager) Reconcile() error {
if err := rcm.reconcileHostname(); err != nil {
return fmt.Errorf("failed to reconcile hostname: %w", err)
}
return rcm.update()
}
// Update updates the resolv.conf file
func (rcm *ResolvConfManager) update() error {
rcm.mu.Lock()
defer rcm.mu.Unlock()
rcm.logger.Debug().Msg("updating resolv.conf")
// Generate resolv.conf content
content, err := rcm.generateResolvConf(rcm.conf)
if err != nil {
return fmt.Errorf("failed to generate resolv.conf: %w", err)
}
// Check if the file is the same
if _, err := os.Stat(resolvConfPath); err == nil {
existingContent, err := os.ReadFile(resolvConfPath)
if err != nil {
rcm.logger.Warn().Err(err).Msg("failed to read existing resolv.conf")
}
if bytes.Equal(existingContent, content) {
rcm.logger.Debug().Msg("resolv.conf is the same, skipping write")
return nil
}
}
// Write to file
if err := os.WriteFile(resolvConfPath, content, resolvConfFileMode); err != nil {
return fmt.Errorf("failed to write resolv.conf: %w", err)
}
rcm.logger.Info().
Interface("config", rcm.conf).
Msg("resolv.conf updated successfully")
return nil
}
type configMap map[string][]string
func mergeConfig(nameservers *configMap, searchList *configMap, config *types.InterfaceResolvConfMap) {
localNameservers := *nameservers
localSearchList := *searchList
for ifname, iface := range *config {
comment := ifname
if iface.Source != "" {
comment += fmt.Sprintf(" (%s)", iface.Source)
}
for _, ip := range iface.NameServers {
ns := ip.String()
if _, ok := localNameservers[ns]; !ok {
localNameservers[ns] = []string{}
}
localNameservers[ns] = append(localNameservers[ns], comment)
}
for _, search := range iface.SearchList {
search = strings.Trim(search, ".")
if _, ok := localSearchList[search]; !ok {
localSearchList[search] = []string{}
}
localSearchList[search] = append(localSearchList[search], comment)
}
}
*nameservers = localNameservers
*searchList = localSearchList
}
// generateResolvConf generates resolv.conf content
func (rcm *ResolvConfManager) generateResolvConf(conf *types.ResolvConf) ([]byte, error) {
tmpl, err := template.New("resolv.conf").Funcs(tplFuncMap).Parse(resolvConfTemplate)
if err != nil {
return nil, fmt.Errorf("failed to parse template: %w", err)
}
// merge the nameservers and searchList
nameservers := configMap{}
searchList := configMap{}
mergeConfig(&nameservers, &searchList, &conf.ConfigIPv4)
mergeConfig(&nameservers, &searchList, &conf.ConfigIPv6)
flattenedSearchList := []string{}
for search := range searchList {
flattenedSearchList = append(flattenedSearchList, search)
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, map[string]any{
"nameservers": nameservers,
"searchList": flattenedSearchList,
}); err != nil {
return nil, fmt.Errorf("failed to execute template: %w", err)
}
return buf.Bytes(), nil
}

106
pkg/nmlite/state.go Normal file
View File

@ -0,0 +1,106 @@
package nmlite
import "net"
func (nm *NetworkManager) IsOnline() bool {
for _, iface := range nm.interfaces {
if iface.IsOnline() {
return true
}
}
return false
}
func (nm *NetworkManager) IsUp() bool {
for _, iface := range nm.interfaces {
if iface.IsUp() {
return true
}
}
return false
}
func (nm *NetworkManager) Hostname() string {
return nm.resolvConf.Hostname()
}
func (nm *NetworkManager) FQDN() string {
return nm.resolvConf.FQDN()
}
func (nm *NetworkManager) NTPServers() []net.IP {
servers := []net.IP{}
for _, iface := range nm.interfaces {
servers = append(servers, iface.NTPServers()...)
}
return servers
}
func (nm *NetworkManager) NTPServerStrings() []string {
servers := []string{}
for _, server := range nm.NTPServers() {
servers = append(servers, server.String())
}
return servers
}
func (nm *NetworkManager) GetIPv4Addresses() []string {
for _, iface := range nm.interfaces {
return iface.GetIPv4Addresses()
}
return []string{}
}
func (nm *NetworkManager) GetIPv4Address() string {
for _, iface := range nm.interfaces {
return iface.GetIPv4Address()
}
return ""
}
func (nm *NetworkManager) GetIPv6Address() string {
for _, iface := range nm.interfaces {
return iface.GetIPv6Address()
}
return ""
}
func (nm *NetworkManager) GetIPv6Addresses() []string {
for _, iface := range nm.interfaces {
return iface.GetIPv6Addresses()
}
return []string{}
}
func (nm *NetworkManager) GetMACAddress() string {
for _, iface := range nm.interfaces {
return iface.GetMACAddress()
}
return ""
}
func (nm *NetworkManager) IPv4Ready() bool {
for _, iface := range nm.interfaces {
return iface.IPv4Ready()
}
return false
}
func (nm *NetworkManager) IPv6Ready() bool {
for _, iface := range nm.interfaces {
return iface.IPv6Ready()
}
return false
}
func (nm *NetworkManager) IPv4String() string {
return nm.GetIPv4Address()
}
func (nm *NetworkManager) IPv6String() string {
return nm.GetIPv6Address()
}
func (nm *NetworkManager) MACString() string {
return nm.GetMACAddress()
}

184
pkg/nmlite/static.go Normal file
View File

@ -0,0 +1,184 @@
package nmlite
import (
"fmt"
"net"
"github.com/jetkvm/kvm/internal/network/types"
"github.com/jetkvm/kvm/pkg/nmlite/link"
"github.com/rs/zerolog"
)
// StaticConfigManager manages static network configuration
type StaticConfigManager struct {
ifaceName string
logger *zerolog.Logger
}
// NewStaticConfigManager creates a new static configuration manager
func NewStaticConfigManager(ifaceName string, logger *zerolog.Logger) (*StaticConfigManager, error) {
if ifaceName == "" {
return nil, fmt.Errorf("interface name cannot be empty")
}
if logger == nil {
return nil, fmt.Errorf("logger cannot be nil")
}
return &StaticConfigManager{
ifaceName: ifaceName,
logger: logger,
}, nil
}
// ToIPv4Static applies static IPv4 configuration
func (scm *StaticConfigManager) ToIPv4Static(config *types.IPv4StaticConfig) (*types.ParsedIPConfig, error) {
if config == nil {
return nil, fmt.Errorf("config is nil")
}
// Parse IP address and netmask
ipNet, err := link.ParseIPv4Netmask(config.Address.String, config.Netmask.String)
if err != nil {
return nil, err
}
scm.logger.Info().Str("ipNet", ipNet.String()).Interface("ipc", config).Msg("parsed IPv4 address and netmask")
// Parse gateway
gateway := net.ParseIP(config.Gateway.String)
if gateway == nil {
return nil, fmt.Errorf("invalid gateway: %s", config.Gateway.String)
}
// Parse DNS servers
var dns []net.IP
for _, dnsStr := range config.DNS {
if err := link.ValidateIPAddress(dnsStr, false); err != nil {
return nil, fmt.Errorf("invalid DNS server: %w", err)
}
dns = append(dns, net.ParseIP(dnsStr))
}
address := types.IPAddress{
Family: link.AfInet,
Address: *ipNet,
Gateway: gateway,
Secondary: false,
Permanent: true,
}
return &types.ParsedIPConfig{
Addresses: []types.IPAddress{address},
Nameservers: dns,
Interface: scm.ifaceName,
}, nil
}
// ToIPv6Static applies static IPv6 configuration
func (scm *StaticConfigManager) ToIPv6Static(config *types.IPv6StaticConfig) (*types.ParsedIPConfig, error) {
if config == nil {
return nil, fmt.Errorf("config is nil")
}
// Parse IP address and prefix
ipNet, err := link.ParseIPv6Prefix(config.Prefix.String, 64) // Default to /64 if not specified
if err != nil {
return nil, err
}
// Parse gateway
gateway := net.ParseIP(config.Gateway.String)
if gateway == nil {
return nil, fmt.Errorf("invalid gateway: %s", config.Gateway.String)
}
// Parse DNS servers
var dns []net.IP
for _, dnsStr := range config.DNS {
dnsIP := net.ParseIP(dnsStr)
if dnsIP == nil {
return nil, fmt.Errorf("invalid DNS server: %s", dnsStr)
}
dns = append(dns, dnsIP)
}
address := types.IPAddress{
Family: link.AfInet6,
Address: *ipNet,
Gateway: gateway,
Secondary: false,
Permanent: true,
}
return &types.ParsedIPConfig{
Addresses: []types.IPAddress{address},
Nameservers: dns,
Interface: scm.ifaceName,
}, nil
}
// DisableIPv4 disables IPv4 on the interface
func (scm *StaticConfigManager) DisableIPv4() error {
scm.logger.Info().Msg("disabling IPv4")
netlinkMgr := getNetlinkManager()
iface, err := netlinkMgr.GetLinkByName(scm.ifaceName)
if err != nil {
return fmt.Errorf("failed to get interface: %w", err)
}
// Remove all IPv4 addresses
if err := netlinkMgr.RemoveAllAddresses(iface, link.AfInet); err != nil {
return fmt.Errorf("failed to remove IPv4 addresses: %w", err)
}
// Remove default route
if err := scm.removeIPv4DefaultRoute(); err != nil {
scm.logger.Warn().Err(err).Msg("failed to remove IPv4 default route")
}
scm.logger.Info().Msg("IPv4 disabled")
return nil
}
// DisableIPv6 disables IPv6 on the interface
func (scm *StaticConfigManager) DisableIPv6() error {
scm.logger.Info().Msg("disabling IPv6")
netlinkMgr := getNetlinkManager()
return netlinkMgr.DisableIPv6(scm.ifaceName)
}
// EnableIPv6SLAAC enables IPv6 SLAAC
func (scm *StaticConfigManager) EnableIPv6SLAAC() error {
scm.logger.Info().Msg("enabling IPv6 SLAAC")
netlinkMgr := getNetlinkManager()
return netlinkMgr.EnableIPv6SLAAC(scm.ifaceName)
}
// EnableIPv6LinkLocal enables IPv6 link-local only
func (scm *StaticConfigManager) EnableIPv6LinkLocal() error {
scm.logger.Info().Msg("enabling IPv6 link-local only")
netlinkMgr := getNetlinkManager()
if err := netlinkMgr.EnableIPv6LinkLocal(scm.ifaceName); err != nil {
return err
}
// Remove all non-link-local IPv6 addresses
link, err := netlinkMgr.GetLinkByName(scm.ifaceName)
if err != nil {
return fmt.Errorf("failed to get interface: %w", err)
}
if err := netlinkMgr.RemoveNonLinkLocalIPv6Addresses(link); err != nil {
return fmt.Errorf("failed to remove non-link-local IPv6 addresses: %w", err)
}
return netlinkMgr.EnsureInterfaceUp(link)
}
// removeIPv4DefaultRoute removes IPv4 default route
func (scm *StaticConfigManager) removeIPv4DefaultRoute() error {
netlinkMgr := getNetlinkManager()
return netlinkMgr.RemoveDefaultRoute(link.AfInet)
}

171
pkg/nmlite/udhcpc/parser.go Normal file
View File

@ -0,0 +1,171 @@
package udhcpc
import (
"bufio"
"encoding/json"
"fmt"
"net"
"os"
"reflect"
"strconv"
"strings"
"time"
"github.com/jetkvm/kvm/internal/network/types"
)
type Lease struct {
types.DHCPLease
// from https://udhcp.busybox.net/README.udhcpc
isEmpty map[string]bool
}
func (l *Lease) setIsEmpty(m map[string]bool) {
l.isEmpty = m
}
// IsEmpty returns true if the lease is empty for the given key.
func (l *Lease) IsEmpty(key string) bool {
return l.isEmpty[key]
}
// ToJSON returns the lease as a JSON string.
func (l *Lease) ToJSON() string {
json, err := json.Marshal(l)
if err != nil {
return ""
}
return string(json)
}
// ToDHCPLease converts a lease to a DHCP lease.
func (l *Lease) ToDHCPLease() *types.DHCPLease {
lease := &l.DHCPLease
lease.DHCPClient = "udhcpc"
return lease
}
// SetLeaseExpiry sets the lease expiry time.
func (l *Lease) SetLeaseExpiry() (time.Time, error) {
if l.Uptime == 0 || l.LeaseTime == 0 {
return time.Time{}, fmt.Errorf("uptime or lease time isn't set")
}
// get the uptime of the device
file, err := os.Open("/proc/uptime")
if err != nil {
return time.Time{}, fmt.Errorf("failed to open uptime file: %w", err)
}
defer file.Close()
var uptime time.Duration
scanner := bufio.NewScanner(file)
for scanner.Scan() {
text := scanner.Text()
parts := strings.Split(text, " ")
uptime, err = time.ParseDuration(parts[0] + "s")
if err != nil {
return time.Time{}, fmt.Errorf("failed to parse uptime: %w", err)
}
}
relativeLeaseRemaining := (l.Uptime + l.LeaseTime) - uptime
leaseExpiry := time.Now().Add(relativeLeaseRemaining)
l.LeaseExpiry = &leaseExpiry
return leaseExpiry, nil
}
// UnmarshalDHCPCLease unmarshals a lease from a string.
func UnmarshalDHCPCLease(obj *Lease, str string) error {
lease := &obj.DHCPLease
// parse the lease file as a map
data := make(map[string]string)
for line := range strings.SplitSeq(str, "\n") {
line = strings.TrimSpace(line)
// skip empty lines and comments
if line == "" || strings.HasPrefix(line, "#") {
continue
}
parts := strings.SplitN(line, "=", 2)
if len(parts) != 2 {
continue
}
key := strings.TrimSpace(parts[0])
value := strings.TrimSpace(parts[1])
data[key] = value
}
// now iterate over the lease struct and set the values
leaseType := reflect.TypeOf(lease).Elem()
leaseValue := reflect.ValueOf(lease).Elem()
valuesParsed := make(map[string]bool)
for i := 0; i < leaseType.NumField(); i++ {
field := leaseValue.Field(i)
// get the env tag
key := leaseType.Field(i).Tag.Get("env")
if key == "" {
continue
}
valuesParsed[key] = false
// get the value from the data map
value, ok := data[key]
if !ok || value == "" {
continue
}
switch field.Interface().(type) {
case string:
field.SetString(value)
case int:
val, err := strconv.Atoi(value)
if err != nil {
continue
}
field.SetInt(int64(val))
case time.Duration:
val, err := time.ParseDuration(value + "s")
if err != nil {
continue
}
field.Set(reflect.ValueOf(val))
case net.IP:
ip := net.ParseIP(value)
if ip == nil {
continue
}
field.Set(reflect.ValueOf(ip))
case []net.IP:
val := make([]net.IP, 0)
for ipStr := range strings.FieldsSeq(value) {
ip := net.ParseIP(ipStr)
if ip == nil {
continue
}
val = append(val, ip)
}
field.Set(reflect.ValueOf(val))
default:
return fmt.Errorf("unsupported field `%s` type: %s", key, field.Type().String())
}
valuesParsed[key] = true
}
obj.setIsEmpty(valuesParsed)
return nil
}

View File

@ -6,9 +6,13 @@ import (
"os"
"path/filepath"
"reflect"
"time"
"github.com/jetkvm/kvm/internal/sync"
"github.com/fsnotify/fsnotify"
"github.com/jetkvm/kvm/internal/network/types"
"github.com/rs/zerolog"
)
@ -18,20 +22,22 @@ const (
)
type DHCPClient struct {
types.DHCPClient
InterfaceName string
leaseFile string
pidFile string
lease *Lease
logger *zerolog.Logger
process *os.Process
onLeaseChange func(lease *Lease)
runOnce sync.Once
onLeaseChange func(lease *types.DHCPLease)
}
type DHCPClientOptions struct {
InterfaceName string
PidFile string
Logger *zerolog.Logger
OnLeaseChange func(lease *Lease)
OnLeaseChange func(lease *types.DHCPLease)
}
var defaultLogger = zerolog.New(os.Stdout).Level(zerolog.InfoLevel)
@ -67,8 +73,8 @@ func (c *DHCPClient) getWatchPaths() []string {
}
// Run starts the DHCP client and watches the lease file for changes.
// this isn't a blocking call, and the lease file is reloaded when a change is detected.
func (c *DHCPClient) Run() error {
// this is a blocking call.
func (c *DHCPClient) run() error {
err := c.loadLeaseFile()
if err != nil && !errors.Is(err, os.ErrNotExist) {
return err
@ -125,7 +131,7 @@ func (c *DHCPClient) Run() error {
// c.logger.Error().Msg("udhcpc process not found")
// }
// block the goroutine until the lease file is updated
// block the goroutine
<-make(chan struct{})
return nil
@ -182,7 +188,7 @@ func (c *DHCPClient) loadLeaseFile() error {
Msg("current dhcp lease expiry time calculated")
}
c.onLeaseChange(lease)
c.onLeaseChange(lease.ToDHCPLease())
c.logger.Info().
Str("ip", lease.IPAddress.String()).
@ -196,3 +202,47 @@ func (c *DHCPClient) loadLeaseFile() error {
func (c *DHCPClient) GetLease() *Lease {
return c.lease
}
func (c *DHCPClient) Domain() string {
return c.lease.Domain
}
func (c *DHCPClient) Lease4() *types.DHCPLease {
if c.lease == nil {
return nil
}
return c.lease.ToDHCPLease()
}
func (c *DHCPClient) Lease6() *types.DHCPLease {
// TODO: implement
return nil
}
func (c *DHCPClient) SetIPv4(enabled bool) {
// TODO: implement
}
func (c *DHCPClient) SetIPv6(enabled bool) {
// TODO: implement
}
func (c *DHCPClient) SetOnLeaseChange(callback func(lease *types.DHCPLease)) {
c.onLeaseChange = callback
}
func (c *DHCPClient) Start() error {
c.runOnce.Do(func() {
go func() {
err := c.run()
if err != nil {
c.logger.Error().Err(err).Msg("failed to run udhcpc")
}
}()
})
return nil
}
func (c *DHCPClient) Stop() error {
return c.KillProcess() // udhcpc already has KillProcess()
}

76
pkg/nmlite/utils.go Normal file
View File

@ -0,0 +1,76 @@
package nmlite
import (
"sort"
"time"
"github.com/jetkvm/kvm/internal/network/types"
)
func lifetimeToTime(lifetime int) *time.Time {
if lifetime == 0 {
return nil
}
// Check for infinite lifetime (0xFFFFFFFF = 4294967295)
// This is used for static/permanent addresses
// Use uint32 to avoid int overflow on 32-bit systems
const infiniteLifetime uint32 = 0xFFFFFFFF
if uint32(lifetime) == infiniteLifetime || lifetime < 0 {
return nil // Infinite lifetime - no expiration
}
// For finite lifetimes (SLAAC addresses)
t := time.Now().Add(time.Duration(lifetime) * time.Second)
return &t
}
func sortAndCompareStringSlices(a, b []string) bool {
if len(a) != len(b) {
return false
}
sort.Strings(a)
sort.Strings(b)
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
func sortAndCompareIPv6AddressSlices(a, b []types.IPv6Address) bool {
if len(a) != len(b) {
return false
}
sort.SliceStable(a, func(i, j int) bool {
return a[i].Address.String() < b[j].Address.String()
})
sort.SliceStable(b, func(i, j int) bool {
return b[i].Address.String() < a[j].Address.String()
})
for i := range a {
if a[i].Address.String() != b[i].Address.String() {
return false
}
if a[i].Prefix.String() != b[i].Prefix.String() {
return false
}
if a[i].Flags != b[i].Flags {
return false
}
// we don't compare the lifetimes because they are not always same
if a[i].Scope != b[i].Scope {
return false
}
}
return true
}

View File

@ -17,6 +17,7 @@ show_help() {
echo " --skip-ui-build Skip frontend/UI build"
echo " --skip-native-build Skip native build"
echo " --disable-docker Disable docker build"
echo " --enable-sync-trace Enable sync trace (do not use in release builds)"
echo " -i, --install Build for release and install the app"
echo " --help Display this help message"
echo
@ -32,6 +33,7 @@ REMOTE_PATH="/userdata/jetkvm/bin"
SKIP_UI_BUILD=false
SKIP_UI_BUILD_RELEASE=0
SKIP_NATIVE_BUILD=0
ENABLE_SYNC_TRACE=0
RESET_USB_HID_DEVICE=false
LOG_TRACE_SCOPES="${LOG_TRACE_SCOPES:-jetkvm,cloud,websocket,native,jsonrpc}"
RUN_GO_TESTS=false
@ -64,6 +66,11 @@ while [[ $# -gt 0 ]]; do
RESET_USB_HID_DEVICE=true
shift
;;
--enable-sync-trace)
ENABLE_SYNC_TRACE=1
LOG_TRACE_SCOPES="${LOG_TRACE_SCOPES},synctrace"
shift
;;
--disable-docker)
BUILD_IN_DOCKER=false
shift
@ -180,7 +187,10 @@ fi
if [ "$INSTALL_APP" = true ]
then
msg_info "▶ Building release binary"
do_make build_release SKIP_NATIVE_IF_EXISTS=${SKIP_NATIVE_BUILD} SKIP_UI_BUILD=${SKIP_UI_BUILD_RELEASE}
do_make build_release \
SKIP_NATIVE_IF_EXISTS=${SKIP_NATIVE_BUILD} \
SKIP_UI_BUILD=${SKIP_UI_BUILD_RELEASE} \
ENABLE_SYNC_TRACE=${ENABLE_SYNC_TRACE}
# Copy the binary to the remote host as if we were the OTA updater.
ssh "${REMOTE_USER}@${REMOTE_HOST}" "cat > /userdata/jetkvm/jetkvm_app.update" < bin/jetkvm_app
@ -189,7 +199,10 @@ then
ssh "${REMOTE_USER}@${REMOTE_HOST}" "reboot"
else
msg_info "▶ Building development binary"
do_make build_dev SKIP_NATIVE_IF_EXISTS=${SKIP_NATIVE_BUILD} SKIP_UI_BUILD=${SKIP_UI_BUILD_RELEASE}
do_make build_dev \
SKIP_NATIVE_IF_EXISTS=${SKIP_NATIVE_BUILD} \
SKIP_UI_BUILD=${SKIP_UI_BUILD_RELEASE} \
ENABLE_SYNC_TRACE=${ENABLE_SYNC_TRACE}
# Kill any existing instances of the application
ssh "${REMOTE_USER}@${REMOTE_HOST}" "killall jetkvm_app_debug || true"

View File

@ -43,8 +43,20 @@ func initTimeSync() {
timeSync = timesync.NewTimeSync(&timesync.TimeSyncOptions{
Logger: timesyncLogger,
NetworkConfig: config.NetworkConfig,
PreCheckIPv4: func() (bool, error) {
if !networkManager.IPv4Ready() {
return false, nil
}
return true, nil
},
PreCheckIPv6: func() (bool, error) {
if !networkManager.IPv6Ready() {
return false, nil
}
return true, nil
},
PreCheckFunc: func() (bool, error) {
if !networkState.IsOnline() {
if !networkManager.IsOnline() {
return false, nil
}
return true, nil

17
ui/package-lock.json generated
View File

@ -28,6 +28,7 @@
"react": "^19.1.1",
"react-animate-height": "^3.2.3",
"react-dom": "^19.1.1",
"react-hook-form": "^7.65.0",
"react-hot-toast": "^2.6.0",
"react-icons": "^5.5.0",
"react-router": "^7.9.3",
@ -5856,6 +5857,22 @@
"react": "^19.1.1"
}
},
"node_modules/react-hook-form": {
"version": "7.65.0",
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.65.0.tgz",
"integrity": "sha512-xtOzDz063WcXvGWaHgLNrNzlsdFgtUWcb32E6WFaGTd7kPZG3EeDusjdZfUsPwKCKVXy1ZlntifaHZ4l8pAsmw==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/react-hook-form"
},
"peerDependencies": {
"react": "^16.8.0 || ^17 || ^18 || ^19"
}
},
"node_modules/react-hot-toast": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.6.0.tgz",

View File

@ -40,6 +40,7 @@
"react-animate-height": "^3.2.3",
"react-dom": "^19.1.1",
"react-hot-toast": "^2.6.0",
"react-hook-form": "^7.65.0",
"react-icons": "^5.5.0",
"react-router": "^7.9.3",
"react-simple-keyboard": "^3.8.125",

View File

@ -1,5 +1,5 @@
import { MdOutlineContentPasteGo } from "react-icons/md";
import { LuCable, LuHardDrive, LuMaximize, LuSettings, LuSignal } from "react-icons/lu";
import { LuCable, LuHardDrive, LuMaximize, LuSettings, LuSignal, LuVolume2 } from "react-icons/lu";
import { FaKeyboard } from "react-icons/fa6";
import { Popover, PopoverButton, PopoverPanel } from "@headlessui/react";
import { Fragment, useCallback, useRef } from "react";
@ -18,6 +18,7 @@ import PasteModal from "@/components/popovers/PasteModal";
import WakeOnLanModal from "@/components/popovers/WakeOnLan/Index";
import MountPopopover from "@/components/popovers/MountPopover";
import ExtensionPopover from "@/components/popovers/ExtensionPopover";
import AudioPopover from "@/components/popovers/AudioPopover";
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
export default function Actionbar({
@ -203,6 +204,36 @@ export default function Actionbar({
onClick={() => setVirtualKeyboardEnabled(!isVirtualKeyboardEnabled)}
/>
</div>
<Popover>
<PopoverButton as={Fragment}>
<Button
size="XS"
theme="light"
text="Audio"
LeadingIcon={LuVolume2}
onClick={() => {
setDisableVideoFocusTrap(true);
}}
/>
</PopoverButton>
<PopoverPanel
anchor="bottom start"
transition
className={cx(
"z-10 flex w-[420px] flex-col overflow-visible!",
"flex origin-top flex-col transition duration-300 ease-out data-closed:translate-y-8 data-closed:opacity-0",
)}
>
{({ open }) => {
checkIfStateChanged(open);
return (
<div className="mx-auto w-full max-w-xl">
<AudioPopover />
</div>
);
}}
</PopoverPanel>
</Popover>
</div>
<div className="flex flex-wrap items-center gap-x-2 gap-y-2">

View File

@ -1,8 +1,5 @@
import {
CheckCircleIcon,
ExclamationTriangleIcon,
InformationCircleIcon,
} from "@heroicons/react/24/outline";
import { CloseButton } from "@headlessui/react";
import { LuCircleAlert, LuInfo, LuTriangleAlert } from "react-icons/lu";
import { Button } from "@/components/Button";
import Modal from "@/components/Modal";
@ -24,27 +21,23 @@ interface ConfirmDialogProps {
const variantConfig = {
danger: {
icon: ExclamationTriangleIcon,
iconClass: "text-red-600",
iconBgClass: "bg-red-100",
icon: LuCircleAlert,
iconClass: "text-red-600 dark:text-red-400",
buttonTheme: "danger",
},
success: {
icon: CheckCircleIcon,
iconClass: "text-green-600",
iconBgClass: "bg-green-100",
icon: LuCircleAlert,
iconClass: "text-emerald-600 dark:text-emerald-400",
buttonTheme: "primary",
},
warning: {
icon: ExclamationTriangleIcon,
iconClass: "text-yellow-600",
iconBgClass: "bg-yellow-100",
buttonTheme: "lightDanger",
icon: LuTriangleAlert,
iconClass: "text-amber-600 dark:text-amber-400",
buttonTheme: "primary",
},
info: {
icon: InformationCircleIcon,
iconClass: "text-blue-600",
iconBgClass: "bg-blue-100",
icon: LuInfo,
iconClass: "text-slate-700 dark:text-slate-300",
buttonTheme: "primary",
},
} as Record<
@ -52,7 +45,6 @@ const variantConfig = {
{
icon: React.ElementType;
iconClass: string;
iconBgClass: string;
buttonTheme: "danger" | "primary" | "blank" | "light" | "lightDanger";
}
>;
@ -68,47 +60,50 @@ export function ConfirmDialog({
onConfirm,
isConfirming = false,
}: ConfirmDialogProps) {
const { icon: Icon, iconClass, iconBgClass, buttonTheme } = variantConfig[variant];
const { icon: Icon, iconClass, buttonTheme } = variantConfig[variant];
const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.key === "Escape") {
e.stopPropagation();
onClose();
}
};
return (
<Modal open={open} onClose={onClose}>
<div className="mx-auto max-w-xl px-4 transition-all duration-300 ease-in-out">
<div className="pointer-events-auto relative w-full overflow-hidden rounded-lg bg-white p-6 text-left align-middle shadow-xl transition-all dark:bg-slate-800">
<div className="space-y-4">
<div className="sm:flex sm:items-start">
<div
className={cx(
"mx-auto flex size-12 shrink-0 items-center justify-center rounded-full sm:mx-0 sm:size-10",
iconBgClass,
)}
>
<Icon aria-hidden="true" className={cx("size-6", iconClass)} />
</div>
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<h2 className="text-lg leading-tight font-bold text-black dark:text-white">
{title}
</h2>
<div className="mt-2 text-sm leading-snug text-slate-600 dark:text-slate-400">
{description}
<div onKeyDown={handleKeyDown}>
<Modal open={open} onClose={onClose}>
<div className="mx-auto max-w-md px-4 transition-all duration-300 ease-in-out">
<div className="pointer-events-auto relative w-full overflow-hidden rounded-lg border border-slate-200 bg-white shadow-sm transition-all dark:border-slate-800 dark:bg-slate-900">
<div className="p-6">
<div className="flex items-start gap-3.5">
<Icon aria-hidden="true" className={cx("size-[18px] shrink-0 mt-[2px]", iconClass)} />
<div className="flex-1 min-w-0 space-y-2">
<h2 className="font-semibold text-slate-950 dark:text-white">
{title}
</h2>
<div className="text-sm text-slate-700 dark:text-slate-300">
{description}
</div>
</div>
</div>
</div>
<div className="flex justify-end gap-x-2">
{cancelText && (
<Button size="SM" theme="blank" text={cancelText} onClick={onClose} />
)}
<Button
size="SM"
theme={buttonTheme}
text={isConfirming ? `${confirmText}...` : confirmText}
onClick={onConfirm}
disabled={isConfirming}
/>
<div className="mt-6 flex justify-end gap-2">
{cancelText && (
<CloseButton as={Button} size="SM" theme="blank" text={cancelText} />
)}
<Button
size="SM"
type="button"
theme={buttonTheme}
text={isConfirming ? `${confirmText}...` : confirmText}
onClick={onConfirm}
disabled={isConfirming}
/>
</div>
</div>
</div>
</div>
</div>
</Modal>
</Modal>
</div>
);
}
}

View File

@ -5,20 +5,47 @@ import { GridCard } from "@/components/Card";
import { LifeTimeLabel } from "@/routes/devices.$id.settings.network";
import { NetworkState } from "@/hooks/stores";
import EmptyCard from "./EmptyCard";
export default function DhcpLeaseCard({
networkState,
setShowRenewLeaseConfirm,
}: {
networkState: NetworkState;
networkState: NetworkState | null;
setShowRenewLeaseConfirm: (show: boolean) => void;
}) {
const isDhcpLeaseEmpty = Object.keys(networkState?.dhcp_lease || {}).length === 0;
if (isDhcpLeaseEmpty) {
return (
<EmptyCard
headline="No DHCP Lease information"
description="We haven't received any DHCP lease information from the device yet."
/>
);
}
return (
<GridCard>
<div className="animate-fadeIn p-4 opacity-0 animation-duration-500 text-black dark:text-white">
<div className="animate-fadeIn p-4 text-black opacity-0 animation-duration-500 dark:text-white">
<div className="space-y-3">
<h3 className="text-base font-bold text-slate-900 dark:text-white">
DHCP Lease Information
</h3>
<div className="flex items-center justify-between">
<h3 className="text-base font-bold text-slate-900 dark:text-white">
DHCP Lease Information
</h3>
<div>
<Button
size="XS"
theme="light"
type="button"
className="text-red-500"
text="Renew DHCP Lease"
LeadingIcon={LuRefreshCcw}
onClick={() => setShowRenewLeaseConfirm(true)}
/>
</div>
</div>
<div className="flex gap-x-6 gap-y-2">
<div className="flex-1 space-y-2">
@ -44,24 +71,15 @@ export default function DhcpLeaseCard({
</div>
)}
{networkState?.dhcp_lease?.dns && (
{networkState?.dhcp_lease?.dns_servers && (
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm text-slate-600 dark:text-slate-400">
DNS Servers
</span>
<span className="text-right text-sm font-medium">
{networkState?.dhcp_lease?.dns.map(dns => <div key={dns}>{dns}</div>)}
</span>
</div>
)}
{networkState?.dhcp_lease?.broadcast && (
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm text-slate-600 dark:text-slate-400">
Broadcast
</span>
<span className="text-sm font-medium">
{networkState?.dhcp_lease?.broadcast}
{networkState?.dhcp_lease?.dns_servers.map(dns => (
<div key={dns}>{dns}</div>
))}
</span>
</div>
)}
@ -142,6 +160,17 @@ export default function DhcpLeaseCard({
</div>
)}
{networkState?.dhcp_lease?.broadcast && (
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm text-slate-600 dark:text-slate-400">
Broadcast
</span>
<span className="text-sm font-medium">
{networkState?.dhcp_lease?.broadcast}
</span>
</div>
)}
{networkState?.dhcp_lease?.mtu && (
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm text-slate-600 dark:text-slate-400">MTU</span>
@ -192,18 +221,14 @@ export default function DhcpLeaseCard({
</span>
</div>
)}
</div>
</div>
<div>
<Button
size="SM"
theme="light"
className="text-red-500"
text="Renew DHCP Lease"
LeadingIcon={LuRefreshCcw}
onClick={() => setShowRenewLeaseConfirm(true)}
/>
{networkState?.dhcp_lease?.dhcp_client && (
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm text-slate-600 dark:text-slate-400">DHCP Client</span>
<span className="text-sm font-medium">{networkState?.dhcp_lease?.dhcp_client}</span>
</div>
)}
</div>
</div>
</div>
</div>

View File

@ -1,12 +1,26 @@
import { cx } from "@/cva.config";
import { NetworkState } from "../hooks/stores";
import { LifeTimeLabel } from "../routes/devices.$id.settings.network";
import { GridCard } from "./Card";
export function FlagLabel({ flag, className }: { flag: string, className?: string }) {
const classes = cx(
"ml-2 rounded-sm bg-red-500 px-2 py-1 text-[10px] font-medium leading-none text-white dark:border",
"bg-red-500 text-white dark:border-red-700 dark:bg-red-800 dark:text-red-50",
className,
);
return <span className={classes}>
{flag}
</span>
}
export default function Ipv6NetworkCard({
networkState,
}: {
networkState: NetworkState;
networkState: NetworkState | undefined;
}) {
return (
<GridCard>
@ -17,72 +31,82 @@ export default function Ipv6NetworkCard({
</h3>
<div className="grid grid-cols-2 gap-x-6 gap-y-2">
{networkState?.ipv6_link_local && (
<div className="flex flex-col justify-between">
<span className="text-sm text-slate-600 dark:text-slate-400">
Link-local
</span>
<span className="text-sm font-medium">
{networkState?.ipv6_link_local}
</span>
</div>
)}
<div className="flex flex-col justify-between">
<span className="text-sm text-slate-600 dark:text-slate-400">
Link-local
</span>
<span className="text-sm font-medium">
{networkState?.ipv6_link_local}
</span>
</div>
<div className="flex flex-col justify-between">
<span className="text-sm text-slate-600 dark:text-slate-400">
Gateway
</span>
<span className="text-sm font-medium">
{networkState?.ipv6_gateway}
</span>
</div>
</div>
<div className="space-y-3 pt-2">
{networkState?.ipv6_addresses && networkState?.ipv6_addresses.length > 0 && (
<div className="space-y-3">
<h4 className="text-sm font-semibold">IPv6 Addresses</h4>
{networkState.ipv6_addresses.map(
addr => (
<div
key={addr.address}
className="rounded-md rounded-l-none border border-slate-500/10 border-l-blue-700/50 bg-white p-4 pl-4 backdrop-blur-sm dark:bg-transparent"
>
<div className="grid grid-cols-2 gap-x-8 gap-y-4">
<div className="col-span-2 flex flex-col justify-between">
<span className="text-sm text-slate-600 dark:text-slate-400">
Address
{networkState.ipv6_addresses.map(addr => (
<div
key={addr.address}
className="rounded-md rounded-l-none border border-slate-500/10 border-l-blue-700/50 bg-white p-4 pl-4 backdrop-blur-sm dark:bg-transparent"
>
<div className="grid grid-cols-2 gap-x-8 gap-y-4">
<div className="col-span-2 flex flex-col justify-between">
<span className="text-sm text-slate-600 dark:text-slate-400">
Address
</span>
<span className="text-sm font-medium flex">
<span className="flex-1">{addr.address}</span>
<span className="text-sm font-medium flex gap-x-1">
{addr.flag_deprecated ? <FlagLabel flag="Deprecated" /> : null}
{addr.flag_dad_failed ? <FlagLabel flag="DAD Failed" /> : null}
</span>
<span className="text-sm font-medium">{addr.address}</span>
</div>
{addr.valid_lifetime && (
<div className="flex flex-col justify-between">
<span className="text-sm text-slate-600 dark:text-slate-400">
Valid Lifetime
</span>
<span className="text-sm font-medium">
{addr.valid_lifetime === "" ? (
<span className="text-slate-400 dark:text-slate-600">
N/A
</span>
) : (
<LifeTimeLabel lifetime={`${addr.valid_lifetime}`} />
)}
</span>
</div>
)}
{addr.preferred_lifetime && (
<div className="flex flex-col justify-between">
<span className="text-sm text-slate-600 dark:text-slate-400">
Preferred Lifetime
</span>
<span className="text-sm font-medium">
{addr.preferred_lifetime === "" ? (
<span className="text-slate-400 dark:text-slate-600">
N/A
</span>
) : (
<LifeTimeLabel lifetime={`${addr.preferred_lifetime}`} />
)}
</span>
</div>
)}
</span>
</div>
{addr.valid_lifetime && (
<div className="flex flex-col justify-between">
<span className="text-sm text-slate-600 dark:text-slate-400">
Valid Lifetime
</span>
<span className="text-sm font-medium">
{addr.valid_lifetime === "" ? (
<span className="text-slate-400 dark:text-slate-600">
N/A
</span>
) : (
<LifeTimeLabel lifetime={`${addr.valid_lifetime}`} />
)}
</span>
</div>
)}
{addr.preferred_lifetime && (
<div className="flex flex-col justify-between">
<span className="text-sm text-slate-600 dark:text-slate-400">
Preferred Lifetime
</span>
<span className="text-sm font-medium">
{addr.preferred_lifetime === "" ? (
<span className="text-slate-400 dark:text-slate-600">
N/A
</span>
) : (
<LifeTimeLabel lifetime={`${addr.preferred_lifetime}`} />
)}
</span>
</div>
)}
</div>
),
)}
</div>
))}
</div>
)}
</div>

View File

@ -3,14 +3,19 @@ import { ReactNode } from "react";
export function SettingsPageHeader({
title,
description,
action,
}: {
title: string | ReactNode;
description: string | ReactNode;
action?: ReactNode;
}) {
return (
<div className="select-none">
<h2 className=" text-xl font-extrabold text-black dark:text-white">{title}</h2>
<div className="text-sm text-black dark:text-slate-300">{description}</div>
<div className="flex items-center justify-between gap-x-2 select-none">
<div className="flex flex-col gap-y-1">
<h2 className="text-xl font-extrabold text-black dark:text-white">{title}</h2>
<div className="text-sm text-black dark:text-slate-300">{description}</div>
</div>
{action && <div className="">{action}</div>}
</div>
);
}
}

View File

@ -0,0 +1,137 @@
import { LuPlus, LuX } from "react-icons/lu";
import { useFieldArray, useFormContext } from "react-hook-form";
import { useEffect } from "react";
import validator from "validator";
import { cx } from "cva";
import { GridCard } from "@/components/Card";
import { Button } from "@/components/Button";
import { InputFieldWithLabel } from "@/components/InputField";
import { NetworkSettings } from "@/hooks/stores";
import { netMaskFromCidr4 } from "@/utils/ip";
export default function StaticIpv4Card() {
const formMethods = useFormContext<NetworkSettings>();
const { register, formState, watch, setValue } = formMethods;
const { fields, append, remove } = useFieldArray({ name: "ipv4_static.dns" });
useEffect(() => {
if (fields.length === 0) append("");
}, [append, fields.length]);
const dns = watch("ipv4_static.dns");
const ipv4StaticAddress = watch("ipv4_static.address");
const hideSubnetMask = ipv4StaticAddress?.includes("/");
useEffect(() => {
const parts = ipv4StaticAddress?.split("/", 2);
if (parts?.length !== 2) return;
const cidrNotation = parseInt(parts?.[1] ?? "");
if (isNaN(cidrNotation) || cidrNotation < 0 || cidrNotation > 32) return;
const mask = netMaskFromCidr4(cidrNotation);
setValue("ipv4_static.netmask", mask);
}, [ipv4StaticAddress, setValue]);
const validate = (value: string) => {
if (!validator.isIP(value)) return "Invalid IP address";
return true;
};
const validateIsIPOrCIDR4 = (value: string) => {
if (!validator.isIP(value, 4) && !validator.isIPRange(value, 4)) return "Invalid IP address or CIDR notation";
return true;
};
return (
<GridCard>
<div className="animate-fadeIn p-4 text-black opacity-0 animation-duration-500 dark:text-white">
<div className="space-y-4">
<h3 className="text-base font-bold text-slate-900 dark:text-white">
Static IPv4 Configuration
</h3>
<div className={cx("grid grid-cols-1 gap-4", hideSubnetMask ? "md:grid-cols-1" : "md:grid-cols-2")}>
<InputFieldWithLabel
label="IP Address"
type="text"
size="SM"
placeholder="192.168.1.100"
{
...register("ipv4_static.address", {
validate: (value: string | undefined) => validateIsIPOrCIDR4(value ?? "")
})}
error={formState.errors.ipv4_static?.address?.message}
/>
{!hideSubnetMask && <InputFieldWithLabel
label="Subnet Mask"
type="text"
size="SM"
placeholder="255.255.255.0"
{...register("ipv4_static.netmask", { validate: (value: string | undefined) => validate(value ?? "") })}
error={formState.errors.ipv4_static?.netmask?.message}
/>}
</div>
<InputFieldWithLabel
label="Gateway"
type="text"
size="SM"
placeholder="192.168.1.1"
{...register("ipv4_static.gateway", { validate: (value: string | undefined) => validate(value ?? "") })}
error={formState.errors.ipv4_static?.gateway?.message}
/>
{/* DNS server fields */}
<div className="space-y-4">
{fields.map((dns, index) => {
return (
<div key={dns.id}>
<div className="flex items-start gap-x-2">
<div className="flex-1">
<InputFieldWithLabel
label={index === 0 ? "DNS Server" : null}
type="text"
size="SM"
placeholder="1.1.1.1"
{...register(
`ipv4_static.dns.${index}`,
{ validate: (value: string | undefined) => validate(value ?? "") }
)}
error={formState.errors.ipv4_static?.dns?.[index]?.message}
/>
</div>
{index > 0 && (
<div className="flex-shrink-0">
<Button
size="SM"
theme="light"
type="button"
onClick={() => remove(index)}
LeadingIcon={LuX}
/>
</div>
)}
</div>
</div>
);
})}
</div>
<Button
size="SM"
theme="light"
onClick={() => append("", { shouldFocus: true })}
LeadingIcon={LuPlus}
type="button"
text="Add DNS Server"
disabled={dns?.[0] === ""}
/>
</div>
</div>
</GridCard>
);
}

View File

@ -0,0 +1,117 @@
import { LuPlus, LuX } from "react-icons/lu";
import { useFieldArray, useFormContext } from "react-hook-form";
import validator from "validator";
import { useEffect } from "react";
import { GridCard } from "@/components/Card";
import { Button } from "@/components/Button";
import { InputFieldWithLabel } from "@/components/InputField";
import { NetworkSettings } from "@/hooks/stores";
export default function StaticIpv6Card() {
const formMethods = useFormContext<NetworkSettings>();
const { register, formState, watch } = formMethods;
const { fields, append, remove } = useFieldArray({ name: "ipv6_static.dns" });
useEffect(() => {
if (fields.length === 0) append("");
}, [append, fields.length]);
const dns = watch("ipv6_static.dns");
const cidrValidation = (value: string) => {
if (value === "") return true;
// Check if it's a valid IPv6 address with CIDR notation
const parts = value.split("/");
if (parts.length !== 2) return "Please use CIDR notation (e.g., 2001:db8::1/64)";
const [address, prefix] = parts;
if (!validator.isIP(address, 6)) return "Invalid IPv6 address";
const prefixNum = parseInt(prefix);
if (isNaN(prefixNum) || prefixNum < 0 || prefixNum > 128) {
return "Prefix must be between 0 and 128";
}
return true;
};
const ipv6Validation = (value: string) => {
if (!validator.isIP(value, 6)) return "Invalid IPv6 address";
return true;
};
return (
<GridCard>
<div className="animate-fadeIn p-4 text-black opacity-0 animation-duration-500 dark:text-white">
<div className="space-y-4">
<h3 className="text-base font-bold text-slate-900 dark:text-white">
Static IPv6 Configuration
</h3>
<InputFieldWithLabel
label="IP Prefix"
type="text"
size="SM"
placeholder="2001:db8::1/64"
{...register("ipv6_static.prefix", { validate: (value: string | undefined) => cidrValidation(value ?? "") })}
error={formState.errors.ipv6_static?.prefix?.message}
/>
<InputFieldWithLabel
label="Gateway"
type="text"
size="SM"
placeholder="2001:db8::1"
{...register("ipv6_static.gateway", { validate: (value: string | undefined) => ipv6Validation(value ?? "") })}
error={formState.errors.ipv6_static?.gateway?.message}
/>
{/* DNS server fields */}
<div className="space-y-4">
{fields.map((dns, index) => {
return (
<div key={dns.id}>
<div className="flex items-start gap-x-2">
<div className="flex-1">
<InputFieldWithLabel
label={index === 0 ? "DNS Server" : null}
type="text"
size="SM"
placeholder="2001:4860:4860::8888"
{...register(`ipv6_static.dns.${index}`, { validate: (value: string | undefined) => ipv6Validation(value ?? "") })}
error={formState.errors.ipv6_static?.dns?.[index]?.message}
/>
</div>
{index > 0 && (
<div className="flex-shrink-0">
<Button
size="SM"
theme="light"
type="button"
onClick={() => remove(index)}
LeadingIcon={LuX}
/>
</div>
)}
</div>
</div>
);
})}
</div>
<Button
size="SM"
theme="light"
onClick={() => append("", { shouldFocus: true })}
LeadingIcon={LuPlus}
type="button"
text="Add DNS Server"
disabled={dns?.[0] === ""}
/>
</div>
</div>
</GridCard>
);
}

Some files were not shown because too many files have changed in this diff Show More