Compare commits

...

5 Commits

Author SHA1 Message Date
Daniel Collins c62772ce3b
Merge 002c2f4937 into 0d7f47c109 2025-06-20 14:41:46 +02:00
Marc Brooks 0d7f47c109
fix(ui) firefox permissions error handling (#631) 2025-06-20 14:24:54 +02:00
iain MacDonnell 254c001572
fix: keyboard_layout default config (en-US/en_US) (#633) 2025-06-20 14:13:36 +02:00
Aveline 6f037a832d
feat(native): restart jetkvm_native automatically (#629) 2025-06-20 14:08:19 +02:00
Daniel Collins 002c2f4937 Implement HTTP proxy option (#515).
This commit adds a "Proxy" field to the network settings screen, which
can be used to specify a HTTP proxy for any outgoing requests from the
device.
2025-05-28 20:52:51 +01:00
11 changed files with 188 additions and 29 deletions

View File

@ -111,7 +111,7 @@ var defaultConfig = &Config{
ActiveExtension: "", ActiveExtension: "",
KeyboardMacros: []KeyboardMacro{}, KeyboardMacros: []KeyboardMacro{},
DisplayRotation: "270", DisplayRotation: "270",
KeyboardLayout: "en-US", KeyboardLayout: "en_US",
DisplayMaxBrightness: 64, DisplayMaxBrightness: 64,
DisplayDimAfterSec: 120, // 2 minutes DisplayDimAfterSec: 120, // 2 minutes
DisplayOffAfterSec: 1800, // 30 minutes DisplayOffAfterSec: 1800, // 30 minutes

View File

@ -3,6 +3,7 @@ package confparser
import ( import (
"fmt" "fmt"
"net" "net"
"net/url"
"reflect" "reflect"
"slices" "slices"
"strconv" "strconv"
@ -372,6 +373,10 @@ func (f *FieldConfig) validateField() error {
if _, err := idna.Lookup.ToASCII(val); err != nil { 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("field `%s` is not a valid hostname: %s", f.Name, 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)
}
default: 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", f.Name, validateType)
} }

View File

@ -3,6 +3,8 @@ package network
import ( import (
"fmt" "fmt"
"net" "net"
"net/http"
"net/url"
"time" "time"
"github.com/guregu/null/v6" "github.com/guregu/null/v6"
@ -32,8 +34,9 @@ type IPv6StaticConfig struct {
DNS []string `json:"dns,omitempty" validate_type:"ipv6" required:"true"` DNS []string `json:"dns,omitempty" validate_type:"ipv6" required:"true"`
} }
type NetworkConfig struct { type NetworkConfig struct {
Hostname null.String `json:"hostname,omitempty" validate_type:"hostname"` Hostname null.String `json:"hostname,omitempty" validate_type:"hostname"`
Domain null.String `json:"domain,omitempty" validate_type:"hostname"` HTTPProxy null.String `json:"http_proxy,omitempty" validate_type:"proxy"`
Domain null.String `json:"domain,omitempty" validate_type:"hostname"`
IPv4Mode null.String `json:"ipv4_mode,omitempty" one_of:"dhcp,static,disabled" default:"dhcp"` IPv4Mode null.String `json:"ipv4_mode,omitempty" one_of:"dhcp,static,disabled" default:"dhcp"`
IPv4Static *IPv4StaticConfig `json:"ipv4_static,omitempty" required_if:"IPv4Mode=static"` IPv4Static *IPv4StaticConfig `json:"ipv4_static,omitempty" required_if:"IPv4Mode=static"`
@ -69,6 +72,18 @@ func (c *NetworkConfig) GetMDNSMode() *mdns.MDNSListenOptions {
return listenOptions return listenOptions
} }
func (s *NetworkConfig) GetTransportProxyFunc() func(*http.Request) (*url.URL, error) {
return func(*http.Request) (*url.URL, error) {
if s.HTTPProxy.String == "" {
return nil, nil
} else {
proxyUrl, _ := url.Parse(s.HTTPProxy.String)
return proxyUrl, nil
}
}
}
func (s *NetworkInterfaceState) GetHostname() string { func (s *NetworkInterfaceState) GetHostname() string {
hostname := ToValidHostname(s.config.Hostname.String) hostname := ToValidHostname(s.config.Hostname.String)

View File

@ -5,6 +5,7 @@ import (
"errors" "errors"
"math/rand" "math/rand"
"net/http" "net/http"
"net/url"
"strconv" "strconv"
"time" "time"
) )
@ -57,6 +58,7 @@ func (t *TimeSync) queryMultipleHttp(urls []string, timeout time.Duration) (now
ctx, ctx,
url, url,
timeout, timeout,
t.networkConfig.GetTransportProxyFunc(),
) )
duration := time.Since(startTime) duration := time.Since(startTime)
@ -122,10 +124,16 @@ func queryHttpTime(
ctx context.Context, ctx context.Context,
url string, url string,
timeout time.Duration, timeout time.Duration,
proxyFunc func(*http.Request) (*url.URL, error),
) (now *time.Time, response *http.Response, err error) { ) (now *time.Time, response *http.Response, err error) {
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.Proxy = proxyFunc
client := http.Client{ client := http.Client{
Timeout: timeout, Transport: transport,
Timeout: timeout,
} }
req, err := http.NewRequestWithContext(ctx, "GET", url, nil) req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err

View File

@ -8,6 +8,7 @@ import (
"io" "io"
"net" "net"
"os" "os"
"os/exec"
"sync" "sync"
"time" "time"
@ -41,6 +42,11 @@ var ongoingRequests = make(map[int32]chan *CtrlResponse)
var lock = &sync.Mutex{} var lock = &sync.Mutex{}
var (
nativeCmd *exec.Cmd
nativeCmdLock = &sync.Mutex{}
)
func CallCtrlAction(action string, params map[string]interface{}) (*CtrlResponse, error) { func CallCtrlAction(action string, params map[string]interface{}) (*CtrlResponse, error) {
lock.Lock() lock.Lock()
defer lock.Unlock() defer lock.Unlock()
@ -129,16 +135,26 @@ func StartNativeSocketServer(socketPath string, handleClient func(net.Conn), isC
scopedLogger.Info().Msg("server listening") scopedLogger.Info().Msg("server listening")
go func() { go func() {
conn, err := listener.Accept() for {
listener.Close() conn, err := listener.Accept()
if err != nil {
scopedLogger.Warn().Err(err).Msg("failed to accept socket") if err != nil {
scopedLogger.Warn().Err(err).Msg("failed to accept socket")
continue
}
if isCtrl {
// check if the channel is closed
select {
case <-ctrlClientConnected:
scopedLogger.Debug().Msg("ctrl client reconnected")
default:
close(ctrlClientConnected)
scopedLogger.Debug().Msg("first native ctrl socket client connected")
}
}
go handleClient(conn)
} }
if isCtrl {
close(ctrlClientConnected)
scopedLogger.Debug().Msg("first native ctrl socket client connected")
}
handleClient(conn)
}() }()
return listener return listener
@ -235,6 +251,51 @@ func handleVideoClient(conn net.Conn) {
} }
} }
func startNativeBinaryWithLock(binaryPath string) (*exec.Cmd, error) {
nativeCmdLock.Lock()
defer nativeCmdLock.Unlock()
cmd, err := startNativeBinary(binaryPath)
if err != nil {
return nil, err
}
nativeCmd = cmd
return cmd, nil
}
func restartNativeBinary(binaryPath string) error {
time.Sleep(10 * time.Second)
// restart the binary
nativeLogger.Info().Msg("restarting jetkvm_native binary")
cmd, err := startNativeBinary(binaryPath)
if err != nil {
nativeLogger.Warn().Err(err).Msg("failed to restart binary")
}
nativeCmd = cmd
return err
}
func superviseNativeBinary(binaryPath string) error {
nativeCmdLock.Lock()
defer nativeCmdLock.Unlock()
if nativeCmd == nil || nativeCmd.Process == nil {
return restartNativeBinary(binaryPath)
}
err := nativeCmd.Wait()
if err == nil {
nativeLogger.Info().Err(err).Msg("jetkvm_native binary exited with no error")
} else if exiterr, ok := err.(*exec.ExitError); ok {
nativeLogger.Warn().Int("exit_code", exiterr.ExitCode()).Msg("jetkvm_native binary exited with error")
} else {
nativeLogger.Warn().Err(err).Msg("jetkvm_native binary exited with unknown error")
}
return restartNativeBinary(binaryPath)
}
func ExtractAndRunNativeBin() error { func ExtractAndRunNativeBin() error {
binaryPath := "/userdata/jetkvm/bin/jetkvm_native" binaryPath := "/userdata/jetkvm/bin/jetkvm_native"
if err := ensureBinaryUpdated(binaryPath); err != nil { if err := ensureBinaryUpdated(binaryPath); err != nil {
@ -246,12 +307,28 @@ func ExtractAndRunNativeBin() error {
return fmt.Errorf("failed to make binary executable: %w", err) return fmt.Errorf("failed to make binary executable: %w", err)
} }
// Run the binary in the background // Run the binary in the background
cmd, err := startNativeBinary(binaryPath) cmd, err := startNativeBinaryWithLock(binaryPath)
if err != nil { if err != nil {
return fmt.Errorf("failed to start binary: %w", err) return fmt.Errorf("failed to start binary: %w", err)
} }
//TODO: add auto restart // check if the binary is still running every 10 seconds
go func() {
for {
select {
case <-appCtx.Done():
nativeLogger.Info().Msg("stopping native binary supervisor")
return
default:
err := superviseNativeBinary(binaryPath)
if err != nil {
nativeLogger.Warn().Err(err).Msg("failed to supervise native binary")
time.Sleep(1 * time.Second) // Add a short delay to prevent rapid successive calls
}
}
}
}()
go func() { go func() {
<-appCtx.Done() <-appCtx.Done()
nativeLogger.Info().Int("pid", cmd.Process.Pid).Msg("killing process") nativeLogger.Info().Int("pid", cmd.Process.Pid).Msg("killing process")

10
ota.go
View File

@ -89,7 +89,14 @@ func fetchUpdateMetadata(ctx context.Context, deviceId string, includePreRelease
return nil, fmt.Errorf("error creating request: %w", err) return nil, fmt.Errorf("error creating request: %w", err)
} }
resp, err := http.DefaultClient.Do(req) transport := http.DefaultTransport.(*http.Transport).Clone()
transport.Proxy = config.NetworkConfig.GetTransportProxyFunc()
client := &http.Client{
Transport: transport,
}
resp, err := client.Do(req)
if err != nil { if err != nil {
return nil, fmt.Errorf("error sending request: %w", err) return nil, fmt.Errorf("error sending request: %w", err)
} }
@ -135,6 +142,7 @@ func downloadFile(ctx context.Context, path string, url string, downloadProgress
client := http.Client{ client := http.Client{
Timeout: 10 * time.Minute, Timeout: 10 * time.Minute,
Transport: &http.Transport{ Transport: &http.Transport{
Proxy: config.NetworkConfig.GetTransportProxyFunc(),
TLSHandshakeTimeout: 30 * time.Second, TLSHandshakeTimeout: 30 * time.Second,
TLSClientConfig: &tls.Config{ TLSClientConfig: &tls.Config{
RootCAs: rootcerts.ServerCertPool(), RootCAs: rootcerts.ServerCertPool(),

View File

@ -115,9 +115,18 @@ export default function WebRTCVideo() {
const isFullscreenEnabled = document.fullscreenEnabled; const isFullscreenEnabled = document.fullscreenEnabled;
const checkNavigatorPermissions = useCallback(async (permissionName: string) => { const checkNavigatorPermissions = useCallback(async (permissionName: string) => {
const name = permissionName as PermissionName; if (!navigator.permissions || !navigator.permissions.query) {
const { state } = await navigator.permissions.query({ name }); return false; // if can't query permissions, assume NOT granted
return state === "granted"; }
try {
const name = permissionName as PermissionName;
const { state } = await navigator.permissions.query({ name });
return state === "granted";
} catch {
// ignore errors
}
return false; // if query fails, assume NOT granted
}, []); }, []);
const requestPointerLock = useCallback(async () => { const requestPointerLock = useCallback(async () => {
@ -128,7 +137,11 @@ export default function WebRTCVideo() {
const isPointerLockGranted = await checkNavigatorPermissions("pointer-lock"); const isPointerLockGranted = await checkNavigatorPermissions("pointer-lock");
if (isPointerLockGranted && settings.mouseMode === "relative") { if (isPointerLockGranted && settings.mouseMode === "relative") {
await videoElm.current.requestPointerLock(); try {
await videoElm.current.requestPointerLock();
} catch {
// ignore errors
}
} }
}, [checkNavigatorPermissions, isPointerLockPossible, settings.mouseMode]); }, [checkNavigatorPermissions, isPointerLockPossible, settings.mouseMode]);
@ -136,10 +149,13 @@ export default function WebRTCVideo() {
if (videoElm.current === null) return; if (videoElm.current === null) return;
const isKeyboardLockGranted = await checkNavigatorPermissions("keyboard-lock"); const isKeyboardLockGranted = await checkNavigatorPermissions("keyboard-lock");
if (isKeyboardLockGranted) {
if ("keyboard" in navigator) { if (isKeyboardLockGranted && "keyboard" in navigator) {
try {
// @ts-expect-error - keyboard lock is not supported in all browsers // @ts-expect-error - keyboard lock is not supported in all browsers
await navigator.keyboard.lock(); await navigator.keyboard.lock();
} catch {
// ignore errors
} }
} }
}, [checkNavigatorPermissions]); }, [checkNavigatorPermissions]);
@ -148,8 +164,12 @@ export default function WebRTCVideo() {
if (videoElm.current === null || document.fullscreenElement !== videoElm.current) return; if (videoElm.current === null || document.fullscreenElement !== videoElm.current) return;
if ("keyboard" in navigator) { if ("keyboard" in navigator) {
// @ts-expect-error - keyboard unlock is not supported in all browsers try {
await navigator.keyboard.unlock(); // @ts-expect-error - keyboard unlock is not supported in all browsers
await navigator.keyboard.unlock();
} catch {
// ignore errors
}
} }
}, []); }, []);

View File

@ -39,11 +39,11 @@ export default function PasteModal() {
state => state.setKeyboardLayout, state => state.setKeyboardLayout,
); );
// this ensures we always get the original en-US if it hasn't been set yet // this ensures we always get the original en_US if it hasn't been set yet
const safeKeyboardLayout = useMemo(() => { const safeKeyboardLayout = useMemo(() => {
if (keyboardLayout && keyboardLayout.length > 0) if (keyboardLayout && keyboardLayout.length > 0)
return keyboardLayout; return keyboardLayout;
return "en-US"; return "en_US";
}, [keyboardLayout]); }, [keyboardLayout]);
useEffect(() => { useEffect(() => {

View File

@ -753,6 +753,7 @@ export type TimeSyncMode =
export interface NetworkSettings { export interface NetworkSettings {
hostname: string; hostname: string;
domain: string; domain: string;
http_proxy: string;
ipv4_mode: IPv4Mode; ipv4_mode: IPv4Mode;
ipv6_mode: IPv6Mode; ipv6_mode: IPv6Mode;
lldp_mode: LLDPMode; lldp_mode: LLDPMode;

View File

@ -25,11 +25,11 @@ export default function SettingsKeyboardRoute() {
state => state.setShowPressedKeys, state => state.setShowPressedKeys,
); );
// this ensures we always get the original en-US if it hasn't been set yet // this ensures we always get the original en_US if it hasn't been set yet
const safeKeyboardLayout = useMemo(() => { const safeKeyboardLayout = useMemo(() => {
if (keyboardLayout && keyboardLayout.length > 0) if (keyboardLayout && keyboardLayout.length > 0)
return keyboardLayout; return keyboardLayout;
return "en-US"; return "en_US";
}, [keyboardLayout]); }, [keyboardLayout]);
const layoutOptions = Object.entries(layouts).map(([code, language]) => { return { value: code, label: language } }) const layoutOptions = Object.entries(layouts).map(([code, language]) => { return { value: code, label: language } })

View File

@ -34,6 +34,7 @@ dayjs.extend(relativeTime);
const defaultNetworkSettings: NetworkSettings = { const defaultNetworkSettings: NetworkSettings = {
hostname: "", hostname: "",
http_proxy: "",
domain: "", domain: "",
ipv4_mode: "unknown", ipv4_mode: "unknown",
ipv6_mode: "unknown", ipv6_mode: "unknown",
@ -185,6 +186,10 @@ export default function SettingsNetworkRoute() {
setNetworkSettings({ ...networkSettings, hostname: value }); setNetworkSettings({ ...networkSettings, hostname: value });
}; };
const handleProxyChange = (value: string) => {
setNetworkSettings({ ...networkSettings, http_proxy: value });
};
const handleDomainChange = (value: string) => { const handleDomainChange = (value: string) => {
setNetworkSettings({ ...networkSettings, domain: value }); setNetworkSettings({ ...networkSettings, domain: value });
}; };
@ -253,6 +258,26 @@ export default function SettingsNetworkRoute() {
</div> </div>
</SettingsItem> </SettingsItem>
</div> </div>
<div className="space-y-4">
<SettingsItem
title="HTTP Proxy"
description="Proxy server for outgoing HTTP(S) requests from the device. Blank for none."
>
<div className="relative">
<div>
<InputField
size="SM"
type="text"
placeholder="http://proxy.example.com:8080/"
defaultValue={networkSettings.http_proxy}
onChange={e => {
handleProxyChange(e.target.value);
}}
/>
</div>
</div>
</SettingsItem>
</div>
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-1"> <div className="space-y-1">