From d5bfaffd8609c86080d75221cd9c3fdc2b4faa52 Mon Sep 17 00:00:00 2001
From: luckfox-eng29
Date: Mon, 16 Mar 2026 21:49:37 +0800
Subject: [PATCH] Update App version to 0.1.2
Signed-off-by: luckfox-eng29
---
Makefile | 4 +-
boot_storage.go | 57 +
config.go | 50 +
firewall.go | 1470 +++++++++++++++++
internal/network/netif.go | 43 +
internal/usbgadget/udc.go | 17 +-
jsonrpc.go | 137 ++
keys.go | 245 +++
log.go | 1 +
main.go | 5 +
network_mac.go | 20 +
ota.go | 147 +-
ui/package-lock.json | 295 ++--
.../components/FileManager/FileUploader.tsx | 34 +-
ui/src/hooks/stores.ts | 16 +
ui/src/hooks/useBootStorage.ts | 30 +
.../access/AccessContent.tsx | 4 +
.../access/FirewallSettings.tsx | 940 +++++++++++
.../advanced/AdvancedContent.tsx | 139 +-
.../network/NetworkContent.tsx | 92 +-
.../version/VersionContent.tsx | 101 +-
.../SharedFolders/FileManager.tsx | 40 +-
.../SharedFolders/SDFilePage.tsx | 22 +-
.../components_side/SharedFolders/index.tsx | 7 +
.../VirtualMediaSource/ImageManager.tsx | 75 +-
.../VirtualMediaSource/index.tsx | 18 +-
ui/src/layout/core/desktop/DesktopPC.tsx | 4 +-
ui/src/layout/core/desktop/index.tsx | 4 +-
ui/src/locales/en.json | 58 +-
ui/src/locales/zh.json | 58 +-
usb.go | 4 +-
usb_mass_storage.go | 156 +-
32 files changed, 4064 insertions(+), 229 deletions(-)
create mode 100644 boot_storage.go
create mode 100644 firewall.go
create mode 100644 keys.go
create mode 100644 network_mac.go
create mode 100644 ui/src/hooks/useBootStorage.ts
create mode 100644 ui/src/layout/components_setting/access/FirewallSettings.tsx
diff --git a/Makefile b/Makefile
index 7bceb6b..80a47e9 100644
--- a/Makefile
+++ b/Makefile
@@ -2,8 +2,8 @@ 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.1.1-dev
-VERSION ?= 0.1.1
+VERSION_DEV ?= 0.1.2-dev
+VERSION ?= 0.1.2
PROMETHEUS_TAG := github.com/prometheus/common/version
KVM_PKG_NAME := kvm
diff --git a/boot_storage.go b/boot_storage.go
new file mode 100644
index 0000000..53a0c4b
--- /dev/null
+++ b/boot_storage.go
@@ -0,0 +1,57 @@
+package kvm
+
+import (
+ "os"
+ "strings"
+ "sync"
+)
+
+type BootStorageType string
+
+const (
+ BootStorageUnknown BootStorageType = "unknown"
+ BootStorageEMMC BootStorageType = "emmc"
+ BootStorageSD BootStorageType = "sd"
+)
+
+var (
+ bootStorageOnce sync.Once
+ bootStorageType BootStorageType = BootStorageUnknown
+)
+
+func GetBootStorageType() BootStorageType {
+ bootStorageOnce.Do(func() {
+ bootStorageType = detectBootStorageType()
+ })
+ return bootStorageType
+}
+
+func IsBootFromSD() bool {
+ return GetBootStorageType() == BootStorageSD
+}
+
+func detectBootStorageType() BootStorageType {
+ cmdlineBytes, err := os.ReadFile("/proc/cmdline")
+ if err != nil {
+ return BootStorageUnknown
+ }
+
+ cmdline := strings.TrimSpace(string(cmdlineBytes))
+ for _, field := range strings.Fields(cmdline) {
+ if !strings.HasPrefix(field, "root=") {
+ continue
+ }
+ root := strings.TrimPrefix(field, "root=")
+ switch {
+ case strings.HasPrefix(root, "/dev/mmcblk0"):
+ return BootStorageEMMC
+ case strings.HasPrefix(root, "/dev/mmcblk1"):
+ return BootStorageSD
+ default:
+ return BootStorageUnknown
+ }
+ }
+
+ return BootStorageUnknown
+}
+
diff --git a/config.go b/config.go
index 3b43a16..c1162d1 100644
--- a/config.go
+++ b/config.go
@@ -84,10 +84,12 @@ type Config struct {
JigglerEnabled bool `json:"jiggler_enabled"`
AutoUpdateEnabled bool `json:"auto_update_enabled"`
IncludePreRelease bool `json:"include_pre_release"`
+ UpdateDownloadProxy string `json:"update_download_proxy"`
HashedPassword string `json:"hashed_password"`
LocalAuthToken string `json:"local_auth_token"`
LocalAuthMode string `json:"localAuthMode"` //TODO: fix it with migration
LocalLoopbackOnly bool `json:"local_loopback_only"`
+ UsbEnhancedDetection bool `json:"usb_enhanced_detection"`
WakeOnLanDevices []WakeOnLanDevice `json:"wake_on_lan_devices"`
KeyboardMacros []KeyboardMacro `json:"keyboard_macros"`
KeyboardLayout string `json:"keyboard_layout"`
@@ -126,6 +128,40 @@ type Config struct {
WireguardAutoStart bool `json:"wireguard_autostart"`
WireguardConfig WireguardConfig `json:"wireguard_config"`
NpuAppEnabled bool `json:"npu_app_enabled"`
+ Firewall *FirewallConfig `json:"firewall"`
+}
+
+type FirewallConfig struct {
+ Base FirewallBaseRule `json:"base"`
+ Rules []FirewallRule `json:"rules"`
+ PortForwards []FirewallPortRule `json:"portForwards"`
+}
+
+type FirewallBaseRule struct {
+ InputPolicy string `json:"inputPolicy"`
+ OutputPolicy string `json:"outputPolicy"`
+ ForwardPolicy string `json:"forwardPolicy"`
+}
+
+type FirewallRule struct {
+ Chain string `json:"chain"`
+ SourceIP string `json:"sourceIP"`
+ SourcePort *int `json:"sourcePort,omitempty"`
+ Protocols []string `json:"protocols"`
+ DestinationIP string `json:"destinationIP"`
+ DestinationPort *int `json:"destinationPort,omitempty"`
+ Action string `json:"action"`
+ Comment string `json:"comment"`
+}
+
+type FirewallPortRule struct {
+ Chain string `json:"chain,omitempty"`
+ Managed *bool `json:"managed,omitempty"`
+ SourcePort int `json:"sourcePort"`
+ Protocols []string `json:"protocols"`
+ DestinationIP string `json:"destinationIP"`
+ DestinationPort int `json:"destinationPort"`
+ Comment string `json:"comment"`
}
type VntConfig struct {
@@ -160,6 +196,7 @@ var defaultConfig = &Config{
DisplayOffAfterSec: 1800, // 30 minutes
TLSMode: "",
ForceHpd: false, // 默认不强制输出EDID
+ UsbEnhancedDetection: true,
UsbConfig: &usbgadget.Config{
VendorId: "0x1d6b", //The Linux Foundation
ProductId: "0x0104", //Multifunction Composite Gadget
@@ -191,6 +228,15 @@ var defaultConfig = &Config{
AutoMountSystemInfo: true,
WireguardAutoStart: false,
NpuAppEnabled: false,
+ Firewall: &FirewallConfig{
+ Base: FirewallBaseRule{
+ InputPolicy: "accept",
+ OutputPolicy: "accept",
+ ForwardPolicy: "accept",
+ },
+ Rules: []FirewallRule{},
+ PortForwards: []FirewallPortRule{},
+ },
}
var (
@@ -249,6 +295,10 @@ func LoadConfig() {
loadedConfig.NetworkConfig = defaultConfig.NetworkConfig
}
+ if loadedConfig.Firewall == nil {
+ loadedConfig.Firewall = defaultConfig.Firewall
+ }
+
config = &loadedConfig
logging.GetRootLogger().UpdateLogLevel(config.DefaultLogLevel)
diff --git a/firewall.go b/firewall.go
new file mode 100644
index 0000000..a58a42c
--- /dev/null
+++ b/firewall.go
@@ -0,0 +1,1470 @@
+package kvm
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net"
+ "os/exec"
+ "strconv"
+ "strings"
+ "time"
+)
+
+const (
+ firewallChainInput = "KVM_UI_INPUT"
+ firewallChainOutput = "KVM_UI_OUTPUT"
+ firewallChainForward = "KVM_UI_FORWARD"
+
+ firewallChainNatPrerouting = "KVM_UI_PREROUTING"
+ firewallChainNatPostrouting = "KVM_UI_POSTROUTING"
+
+ firewallNatOutputCommentPrefix = "KVM_UI_PF"
+)
+
+func ApplyFirewallConfig(cfg *FirewallConfig) error {
+ if cfg == nil {
+ return nil
+ }
+
+ if _, err := exec.LookPath("iptables"); err != nil {
+ return fmt.Errorf("iptables not found: %w", err)
+ }
+
+ if err := validateFirewallConfig(cfg); err != nil {
+ return err
+ }
+
+ needPortForward := len(cfg.PortForwards) > 0
+ natSupported, err := iptablesTableSupported("nat")
+ if err != nil {
+ return err
+ }
+ if needPortForward && !natSupported {
+ return fmt.Errorf("iptables nat table not supported; port forwarding unavailable")
+ }
+
+ manageNat := natSupported
+
+ if err := ensureFirewallChains(manageNat); err != nil {
+ return err
+ }
+
+ if err := flushFirewallChains(manageNat); err != nil {
+ return err
+ }
+
+ if err := ensureFirewallJumps(manageNat); err != nil {
+ return err
+ }
+
+ if err := buildBaseRules(cfg.Base); err != nil {
+ return err
+ }
+
+ if err := buildCommunicationRules(cfg.Rules); err != nil {
+ return err
+ }
+
+ if err := buildPortForwardRules(cfg.PortForwards, manageNat); err != nil {
+ return err
+ }
+
+ if err := appendDefaultPolicies(cfg.Base); err != nil {
+ return err
+ }
+
+ return nil
+}
+
+func validateFirewallConfig(cfg *FirewallConfig) error {
+ policies := []struct {
+ name string
+ value string
+ }{
+ {"inputPolicy", cfg.Base.InputPolicy},
+ {"outputPolicy", cfg.Base.OutputPolicy},
+ {"forwardPolicy", cfg.Base.ForwardPolicy},
+ }
+ for _, p := range policies {
+ if _, err := normalizeFirewallAction(p.value); err != nil {
+ return fmt.Errorf("invalid %s: %w", p.name, err)
+ }
+ }
+
+ for i, r := range cfg.Rules {
+ if _, err := normalizeFirewallChain(r.Chain); err != nil {
+ return fmt.Errorf("rules[%d].chain: %w", i, err)
+ }
+ if r.SourceIP != "" && !isValidIPOrCIDR(r.SourceIP) {
+ return fmt.Errorf("rules[%d].sourceIP: invalid ip", i)
+ }
+ if r.DestinationIP != "" && !isValidIPOrCIDR(r.DestinationIP) {
+ return fmt.Errorf("rules[%d].destinationIP: invalid ip", i)
+ }
+ if r.SourcePort != nil && (*r.SourcePort < 1 || *r.SourcePort > 65535) {
+ return fmt.Errorf("rules[%d].sourcePort: out of range", i)
+ }
+ if r.DestinationPort != nil && (*r.DestinationPort < 1 || *r.DestinationPort > 65535) {
+ return fmt.Errorf("rules[%d].destinationPort: out of range", i)
+ }
+ if len(r.Protocols) == 0 {
+ return fmt.Errorf("rules[%d].protocols: required", i)
+ }
+ for _, proto := range r.Protocols {
+ if _, err := normalizeFirewallProtocol(proto); err != nil {
+ return fmt.Errorf("rules[%d].protocols: %w", i, err)
+ }
+ }
+ if _, err := normalizeFirewallAction(r.Action); err != nil {
+ return fmt.Errorf("rules[%d].action: %w", i, err)
+ }
+ }
+
+ for i, r := range cfg.PortForwards {
+ if !isManagedPortForward(r) {
+ continue
+ }
+ chain := strings.ToLower(strings.TrimSpace(r.Chain))
+ if chain == "" {
+ if isLocalRedirectDestination(r.DestinationIP) {
+ chain = "output"
+ } else {
+ chain = "prerouting"
+ }
+ }
+ if chain != "output" && chain != "prerouting" && chain != "prerouting_redirect" {
+ return fmt.Errorf("portForwards[%d].chain: unsupported", i)
+ }
+ if r.SourcePort < 1 || r.SourcePort > 65535 {
+ return fmt.Errorf("portForwards[%d].sourcePort: out of range", i)
+ }
+ if r.DestinationPort < 1 || r.DestinationPort > 65535 {
+ return fmt.Errorf("portForwards[%d].destinationPort: out of range", i)
+ }
+ if chain == "prerouting" {
+ ip := net.ParseIP(r.DestinationIP)
+ if ip == nil {
+ return fmt.Errorf("portForwards[%d].destinationIP: invalid ip", i)
+ }
+ if ip.IsUnspecified() {
+ return fmt.Errorf("portForwards[%d].destinationIP: invalid ip", i)
+ }
+ }
+ if chain != "prerouting" && r.DestinationIP != "" && net.ParseIP(r.DestinationIP) == nil {
+ return fmt.Errorf("portForwards[%d].destinationIP: invalid ip", i)
+ }
+ if len(r.Protocols) == 0 {
+ return fmt.Errorf("portForwards[%d].protocols: required", i)
+ }
+ for _, proto := range r.Protocols {
+ if _, err := normalizeFirewallProtocol(proto); err != nil {
+ return fmt.Errorf("portForwards[%d].protocols: %w", i, err)
+ }
+ switch strings.ToLower(strings.TrimSpace(proto)) {
+ case "tcp", "udp", "sctp", "dccp":
+ default:
+ return fmt.Errorf("portForwards[%d].protocols: %s not supported for port forwarding", i, proto)
+ }
+ }
+ }
+
+ return nil
+}
+
+func isManagedPortForward(r FirewallPortRule) bool {
+ return r.Managed == nil || *r.Managed
+}
+
+func isValidIPOrCIDR(s string) bool {
+ t := strings.TrimSpace(s)
+ if t == "" {
+ return false
+ }
+ if strings.Contains(t, "/") {
+ _, _, err := net.ParseCIDR(t)
+ return err == nil
+ }
+ return net.ParseIP(t) != nil
+}
+
+func ensureFirewallChains(needNat bool) error {
+ for _, chain := range []string{firewallChainInput, firewallChainOutput, firewallChainForward} {
+ if err := ensureChain("filter", chain); err != nil {
+ return err
+ }
+ }
+ if needNat {
+ for _, chain := range []string{firewallChainNatPrerouting, firewallChainNatPostrouting} {
+ if err := ensureChain("nat", chain); err != nil {
+ return err
+ }
+ }
+ }
+ return nil
+}
+
+func flushFirewallChains(needNat bool) error {
+ for _, chain := range []string{firewallChainInput, firewallChainOutput, firewallChainForward} {
+ if err := iptables("filter", "-F", chain); err != nil {
+ return err
+ }
+ }
+ if needNat {
+ for _, chain := range []string{firewallChainNatPrerouting, firewallChainNatPostrouting} {
+ if err := iptables("nat", "-F", chain); err != nil {
+ return err
+ }
+ }
+ }
+ return nil
+}
+
+func ensureFirewallJumps(needNat bool) error {
+ if err := ensureJump("filter", "INPUT", firewallChainInput); err != nil {
+ return err
+ }
+ if err := ensureJump("filter", "OUTPUT", firewallChainOutput); err != nil {
+ return err
+ }
+ if err := ensureJump("filter", "FORWARD", firewallChainForward); err != nil {
+ return err
+ }
+ if needNat {
+ if err := ensureJump("nat", "PREROUTING", firewallChainNatPrerouting); err != nil {
+ return err
+ }
+ if err := ensureJump("nat", "POSTROUTING", firewallChainNatPostrouting); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func iptablesTableSupported(table string) (bool, error) {
+ err := iptables(table, "-S")
+ if err == nil {
+ return true, nil
+ }
+ if isIptablesTableMissingErr(err) {
+ return false, nil
+ }
+ return false, err
+}
+
+func isIptablesTableMissingErr(err error) bool {
+ if err == nil {
+ return false
+ }
+ msg := err.Error()
+ if strings.Contains(msg, "Table does not exist") {
+ return true
+ }
+ if strings.Contains(msg, "can't initialize iptables table") {
+ return true
+ }
+ return false
+}
+
+func buildBaseRules(base FirewallBaseRule) error {
+ if err := iptables("filter", "-A", firewallChainInput, "-i", "lo", "-j", "ACCEPT"); err != nil {
+ return err
+ }
+ if err := iptables("filter", "-A", firewallChainOutput, "-o", "lo", "-j", "ACCEPT"); err != nil {
+ return err
+ }
+
+ for _, chain := range []string{firewallChainInput, firewallChainOutput, firewallChainForward} {
+ if err := iptables("filter", "-A", chain, "-m", "conntrack", "--ctstate", "ESTABLISHED,RELATED", "-j", "ACCEPT"); err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
+func appendDefaultPolicies(base FirewallBaseRule) error {
+ inputDefault, err := normalizeFirewallAction(base.InputPolicy)
+ if err != nil {
+ return err
+ }
+ outputDefault, err := normalizeFirewallAction(base.OutputPolicy)
+ if err != nil {
+ return err
+ }
+ forwardDefault, err := normalizeFirewallAction(base.ForwardPolicy)
+ if err != nil {
+ return err
+ }
+
+ if err := iptables("filter", "-A", firewallChainInput, "-j", inputDefault); err != nil {
+ return err
+ }
+ if err := iptables("filter", "-A", firewallChainOutput, "-j", outputDefault); err != nil {
+ return err
+ }
+ if err := iptables("filter", "-A", firewallChainForward, "-j", forwardDefault); err != nil {
+ return err
+ }
+
+ return nil
+}
+
+func buildCommunicationRules(rules []FirewallRule) error {
+ for _, r := range rules {
+ chain, err := normalizeFirewallChain(r.Chain)
+ if err != nil {
+ return err
+ }
+ target, err := normalizeFirewallAction(r.Action)
+ if err != nil {
+ return err
+ }
+
+ protos, err := normalizeProtocolList(r.Protocols)
+ if err != nil {
+ return err
+ }
+
+ for _, proto := range protos {
+ args := []string{"-A", chain}
+ if r.SourceIP != "" {
+ args = append(args, "-s", r.SourceIP)
+ }
+ if r.DestinationIP != "" {
+ args = append(args, "-d", r.DestinationIP)
+ }
+ if proto != "" {
+ args = append(args, "-p", proto)
+ }
+ if r.SourcePort != nil && protoSupportsPorts(proto) {
+ args = append(args, "--sport", strconv.Itoa(*r.SourcePort))
+ }
+ if r.DestinationPort != nil && protoSupportsPorts(proto) {
+ args = append(args, "--dport", strconv.Itoa(*r.DestinationPort))
+ }
+ if strings.TrimSpace(r.Comment) != "" {
+ args = append(args, "-m", "comment", "--comment", r.Comment)
+ }
+ args = append(args, "-j", target)
+ if err := iptables("filter", args...); err != nil {
+ return err
+ }
+ }
+ }
+ return nil
+}
+
+func buildPortForwardRules(rules []FirewallPortRule, manageNat bool) error {
+ if !manageNat {
+ return nil
+ }
+
+ if err := clearManagedNatOutputRules(); err != nil {
+ return err
+ }
+
+ localRedirects := make([]FirewallPortRule, 0)
+ preroutingRedirects := make([]FirewallPortRule, 0)
+ dnatForwards := make([]FirewallPortRule, 0)
+ for _, r := range rules {
+ if !isManagedPortForward(r) {
+ continue
+ }
+ chain := strings.ToLower(strings.TrimSpace(r.Chain))
+ if chain == "" {
+ if isLocalRedirectDestination(r.DestinationIP) {
+ chain = "output"
+ } else {
+ chain = "prerouting"
+ }
+ }
+ if chain == "output" {
+ localRedirects = append(localRedirects, r)
+ } else if chain == "prerouting_redirect" {
+ preroutingRedirects = append(preroutingRedirects, r)
+ } else {
+ dnatForwards = append(dnatForwards, r)
+ }
+ }
+
+ if err := buildNatOutputRedirectRules(localRedirects); err != nil {
+ return err
+ }
+ if err := buildNatPreroutingRedirectRules(preroutingRedirects); err != nil {
+ return err
+ }
+
+ if len(dnatForwards) == 0 {
+ return nil
+ }
+
+ if err := sysctlWrite("net.ipv4.ip_forward", "1"); err != nil {
+ logger.Warn().Err(err).Msg("failed to enable ip_forward")
+ }
+
+ if err := iptables("nat", "-A", firewallChainNatPostrouting, "-m", "conntrack", "--ctstate", "DNAT", "-j", "MASQUERADE"); err != nil {
+ return err
+ }
+
+ for _, r := range dnatForwards {
+ protos, err := normalizeProtocolList(r.Protocols)
+ if err != nil {
+ return err
+ }
+
+ for _, proto := range protos {
+ if proto == "" {
+ continue
+ }
+
+ preroutingArgs := []string{
+ "-A", firewallChainNatPrerouting,
+ "-p", proto,
+ "--dport", strconv.Itoa(r.SourcePort),
+ }
+ if strings.TrimSpace(r.Comment) != "" {
+ preroutingArgs = append(preroutingArgs, "-m", "comment", "--comment", r.Comment)
+ }
+ preroutingArgs = append(
+ preroutingArgs,
+ "-j", "DNAT",
+ "--to-destination", fmt.Sprintf("%s:%d", r.DestinationIP, r.DestinationPort),
+ )
+ if err := iptables("nat", preroutingArgs...); err != nil {
+ return err
+ }
+
+ forwardArgs := []string{
+ "-A", firewallChainForward,
+ "-p", proto,
+ "-d", r.DestinationIP,
+ "--dport", strconv.Itoa(r.DestinationPort),
+ "-m", "conntrack",
+ "--ctstate", "NEW,ESTABLISHED,RELATED",
+ }
+ if strings.TrimSpace(r.Comment) != "" {
+ forwardArgs = append(forwardArgs, "-m", "comment", "--comment", r.Comment)
+ }
+ forwardArgs = append(forwardArgs, "-j", "ACCEPT")
+ if err := iptables("filter", forwardArgs...); err != nil {
+ return err
+ }
+ }
+ }
+
+ return nil
+}
+
+func buildNatPreroutingRedirectRules(rules []FirewallPortRule) error {
+ for _, r := range rules {
+ protos, err := normalizeProtocolList(r.Protocols)
+ if err != nil {
+ return err
+ }
+ for _, proto := range protos {
+ if proto == "" {
+ continue
+ }
+ args := []string{
+ "-A", firewallChainNatPrerouting,
+ "-p", proto,
+ "--dport", strconv.Itoa(r.SourcePort),
+ }
+ if strings.TrimSpace(r.Comment) != "" {
+ args = append(args, "-m", "comment", "--comment", r.Comment)
+ }
+ args = append(
+ args,
+ "-j", "REDIRECT",
+ "--to-ports", strconv.Itoa(r.DestinationPort),
+ )
+ if err := iptables("nat", args...); err != nil {
+ return err
+ }
+ }
+ }
+ return nil
+}
+
+func isLocalRedirectDestination(dstIP string) bool {
+ switch strings.TrimSpace(dstIP) {
+ case "0.0.0.0", "127.0.0.1":
+ return true
+ default:
+ return false
+ }
+}
+
+func clearManagedNatOutputRules() error {
+ out, err := iptablesOutput("nat", "-S", "OUTPUT")
+ if err != nil {
+ if isIptablesTableMissingErr(err) {
+ return nil
+ }
+ return err
+ }
+
+ lines := strings.Split(strings.ReplaceAll(out, "\r\n", "\n"), "\n")
+ for _, line := range lines {
+ line = strings.TrimSpace(line)
+ if !strings.HasPrefix(line, "-A OUTPUT ") {
+ continue
+ }
+ if !strings.Contains(line, "--comment") {
+ continue
+ }
+ if !strings.Contains(line, firewallNatOutputCommentPrefix) {
+ continue
+ }
+ tokens, err := splitShellLike(line)
+ if err != nil || len(tokens) < 2 {
+ continue
+ }
+ if tokens[0] != "-A" || tokens[1] != "OUTPUT" {
+ continue
+ }
+ tokens[0] = "-D"
+ _ = iptables("nat", tokens...)
+ }
+ return nil
+}
+
+func buildNatOutputRedirectRules(rules []FirewallPortRule) error {
+ for _, r := range rules {
+ protos, err := normalizeProtocolList(r.Protocols)
+ if err != nil {
+ return err
+ }
+ comment := formatManagedPortForwardComment(r.Comment)
+ for _, proto := range protos {
+ if proto == "" {
+ continue
+ }
+ args := []string{
+ "-A", "OUTPUT",
+ "-p", proto,
+ "--dport", strconv.Itoa(r.SourcePort),
+ "-m", "comment", "--comment", comment,
+ "-j", "REDIRECT",
+ "--to-ports", strconv.Itoa(r.DestinationPort),
+ }
+ if err := iptables("nat", args...); err != nil {
+ return err
+ }
+ }
+ }
+ return nil
+}
+
+func formatManagedPortForwardComment(userComment string) string {
+ c := strings.TrimSpace(userComment)
+ if c == "" {
+ return firewallNatOutputCommentPrefix
+ }
+ return firewallNatOutputCommentPrefix + ":" + c
+}
+
+func normalizeFirewallChain(chain string) (string, error) {
+ switch strings.ToLower(strings.TrimSpace(chain)) {
+ case "input":
+ return firewallChainInput, nil
+ case "output":
+ return firewallChainOutput, nil
+ case "forward":
+ return firewallChainForward, nil
+ default:
+ return "", fmt.Errorf("unsupported chain %q", chain)
+ }
+}
+
+func normalizeFirewallAction(action string) (string, error) {
+ switch strings.ToLower(strings.TrimSpace(action)) {
+ case "accept":
+ return "ACCEPT", nil
+ case "drop":
+ return "DROP", nil
+ case "reject":
+ return "REJECT", nil
+ default:
+ return "", fmt.Errorf("unsupported action %q", action)
+ }
+}
+
+func normalizeFirewallProtocol(proto string) (string, error) {
+ switch strings.ToLower(strings.TrimSpace(proto)) {
+ case "any":
+ return "", nil
+ case "tcp", "udp", "icmp", "igmp", "sctp", "dccp":
+ return strings.ToLower(strings.TrimSpace(proto)), nil
+ default:
+ return "", fmt.Errorf("unsupported protocol %q", proto)
+ }
+}
+
+func normalizeProtocolList(protos []string) ([]string, error) {
+ hasAny := false
+ normalized := make([]string, 0, len(protos))
+ for _, p := range protos {
+ np, err := normalizeFirewallProtocol(p)
+ if err != nil {
+ return nil, err
+ }
+ if np == "" {
+ hasAny = true
+ continue
+ }
+ normalized = append(normalized, np)
+ }
+ if hasAny {
+ return []string{""}, nil
+ }
+ if len(normalized) == 0 {
+ return []string{""}, nil
+ }
+ return normalized, nil
+}
+
+func protoSupportsPorts(proto string) bool {
+ switch proto {
+ case "tcp", "udp", "sctp", "dccp":
+ return true
+ default:
+ return false
+ }
+}
+
+func ensureChain(table, chain string) error {
+ if err := iptables(table, "-nL", chain); err == nil {
+ return nil
+ }
+
+ err := iptables(table, "-N", chain)
+ if err == nil {
+ return nil
+ }
+
+ if strings.Contains(err.Error(), "Chain already exists") {
+ return nil
+ }
+
+ return err
+}
+
+func ensureJump(table, fromChain, toChain string) error {
+ checkErr := iptables(table, "-C", fromChain, "-j", toChain)
+ if checkErr == nil {
+ return nil
+ }
+
+ return iptables(table, "-I", fromChain, "1", "-j", toChain)
+}
+
+func iptables(table string, args ...string) error {
+ ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
+ defer cancel()
+
+ allArgs := append([]string{}, args...)
+ cmd := exec.CommandContext(ctx, "iptables", append([]string{"-t", table}, allArgs...)...)
+ out, err := cmd.CombinedOutput()
+ if err == nil {
+ return nil
+ }
+ if errors.Is(ctx.Err(), context.DeadlineExceeded) {
+ return fmt.Errorf("iptables timeout: %s", strings.Join(append([]string{"-t", table}, allArgs...), " "))
+ }
+ return fmt.Errorf("iptables failed: %s: %w: %s", strings.Join(append([]string{"-t", table}, allArgs...), " "), err, strings.TrimSpace(string(out)))
+}
+
+func sysctlWrite(key, value string) error {
+ ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
+ defer cancel()
+
+ cmd := exec.CommandContext(ctx, "sysctl", "-w", fmt.Sprintf("%s=%s", key, value))
+ out, err := cmd.CombinedOutput()
+ if err == nil {
+ return nil
+ }
+ return fmt.Errorf("sysctl failed: %w: %s", err, strings.TrimSpace(string(out)))
+}
+
+func iptablesOutput(table string, args ...string) (string, error) {
+ ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
+ defer cancel()
+
+ cmd := exec.CommandContext(ctx, "iptables", append([]string{"-t", table}, args...)...)
+ out, err := cmd.CombinedOutput()
+ if err == nil {
+ return string(out), nil
+ }
+ if errors.Is(ctx.Err(), context.DeadlineExceeded) {
+ return "", fmt.Errorf("iptables timeout: %s", strings.Join(append([]string{"-t", table}, args...), " "))
+ }
+ return "", fmt.Errorf("iptables failed: %s: %w: %s", strings.Join(append([]string{"-t", table}, args...), " "), err, strings.TrimSpace(string(out)))
+}
+
+func splitShellLike(s string) ([]string, error) {
+ var out []string
+ var cur strings.Builder
+ inQuote := false
+ quoteChar := byte(0)
+ esc := false
+
+ b := []byte(strings.TrimSpace(s))
+ for i := 0; i < len(b); i++ {
+ ch := b[i]
+ if esc {
+ cur.WriteByte(ch)
+ esc = false
+ continue
+ }
+ if ch == '\\' {
+ esc = true
+ continue
+ }
+ if inQuote {
+ if ch == quoteChar {
+ inQuote = false
+ continue
+ }
+ cur.WriteByte(ch)
+ continue
+ }
+ if ch == '"' || ch == '\'' {
+ inQuote = true
+ quoteChar = ch
+ continue
+ }
+ if ch == ' ' || ch == '\t' || ch == '\n' || ch == '\r' {
+ if cur.Len() > 0 {
+ out = append(out, cur.String())
+ cur.Reset()
+ }
+ continue
+ }
+ cur.WriteByte(ch)
+ }
+ if esc {
+ return nil, fmt.Errorf("unterminated escape")
+ }
+ if inQuote {
+ return nil, fmt.Errorf("unterminated quote")
+ }
+ if cur.Len() > 0 {
+ out = append(out, cur.String())
+ }
+ return out, nil
+}
+
+func boolPtr(v bool) *bool {
+ return &v
+}
+
+type iptablesParsedRule struct {
+ chain string
+ srcIP string
+ dstIP string
+ proto string
+ sport *int
+ dport *int
+ toPorts *int
+ jump string
+ inIface string
+ outIface string
+ ctstate string
+ toDest string
+ comment string
+}
+
+func ReadFirewallConfigFromSystem() (*FirewallConfig, error) {
+ if _, err := exec.LookPath("iptables"); err != nil {
+ return nil, fmt.Errorf("iptables not found: %w", err)
+ }
+
+ inputLines, exists, err := iptablesChainSpecLines("filter", firewallChainInput)
+ if err != nil {
+ return nil, err
+ }
+ if !exists {
+ return nil, nil
+ }
+
+ outputLines, _, err := iptablesChainSpecLines("filter", firewallChainOutput)
+ if err != nil {
+ return nil, err
+ }
+ forwardLines, _, err := iptablesChainSpecLines("filter", firewallChainForward)
+ if err != nil {
+ return nil, err
+ }
+
+ preroutingLines, _, err := iptablesChainSpecLines("nat", firewallChainNatPrerouting)
+ if err != nil {
+ return nil, err
+ }
+ systemPreroutingLines, _, err := iptablesChainSpecLines("nat", "PREROUTING")
+ if err != nil {
+ return nil, err
+ }
+ natOutputLines, _, err := iptablesChainSpecLines("nat", "OUTPUT")
+ if err != nil {
+ return nil, err
+ }
+
+ inputRules := parseIptablesSpecLines(inputLines)
+ outputRules := parseIptablesSpecLines(outputLines)
+ forwardRules := parseIptablesSpecLines(forwardLines)
+ preroutingRules := parseIptablesSpecLines(preroutingLines)
+ systemPreroutingRules := parseIptablesSpecLines(systemPreroutingLines)
+ natOutputRules := parseIptablesSpecLines(natOutputLines)
+
+ base := FirewallBaseRule{
+ InputPolicy: chainDefaultPolicy(inputRules),
+ OutputPolicy: chainDefaultPolicy(outputRules),
+ ForwardPolicy: chainDefaultPolicy(forwardRules),
+ }
+
+ inputRules = stripDefaultPolicyRule(inputRules)
+ outputRules = stripDefaultPolicyRule(outputRules)
+ forwardRules = stripDefaultPolicyRule(forwardRules)
+
+ portForwards := make([]FirewallPortRule, 0)
+ portForwards = append(portForwards, parsePortForwardsFromNat(preroutingRules)...)
+ portForwards = append(portForwards, parsePortForwardsFromSystemPrerouting(systemPreroutingRules)...)
+ portForwards = append(portForwards, parsePortForwardsFromNatOutput(natOutputRules)...)
+ portForwards = append(portForwards, parsePortForwardsFromSystemNatOutput(natOutputRules)...)
+ forwardRules = filterAutoForwardRules(forwardRules, portForwards)
+
+ commRules := make([]FirewallRule, 0)
+ commRules = append(commRules, parseCommRulesFromChain("input", inputRules)...)
+ commRules = append(commRules, parseCommRulesFromChain("output", outputRules)...)
+ commRules = append(commRules, parseCommRulesFromChain("forward", forwardRules)...)
+
+ commRules = groupFirewallRules(commRules)
+ portForwards = groupPortForwards(portForwards)
+
+ return &FirewallConfig{
+ Base: base,
+ Rules: commRules,
+ PortForwards: portForwards,
+ }, nil
+}
+
+func iptablesChainSpecLines(table, chain string) ([]string, bool, error) {
+ out, err := iptablesOutput(table, "-S", chain)
+ if err == nil {
+ lines := strings.Split(strings.ReplaceAll(out, "\r\n", "\n"), "\n")
+ res := make([]string, 0, len(lines))
+ for _, l := range lines {
+ l = strings.TrimSpace(l)
+ if l == "" {
+ continue
+ }
+ if strings.HasPrefix(l, "-A ") {
+ res = append(res, l)
+ }
+ }
+ return res, true, nil
+ }
+
+ msg := err.Error()
+ if isIptablesTableMissingErr(err) {
+ return nil, false, nil
+ }
+ if strings.Contains(msg, "No chain/target/match by that name") || strings.Contains(msg, "No such file") {
+ return nil, false, nil
+ }
+
+ return nil, false, err
+}
+
+func parseIptablesSpecLines(lines []string) []iptablesParsedRule {
+ out := make([]iptablesParsedRule, 0, len(lines))
+ for _, line := range lines {
+ tokens, err := splitShellLike(line)
+ if err != nil {
+ continue
+ }
+ r := parseIptablesTokens(tokens)
+ if r.chain == "" {
+ continue
+ }
+ out = append(out, r)
+ }
+ return out
+}
+
+func parseIptablesTokens(tokens []string) iptablesParsedRule {
+ var r iptablesParsedRule
+ for i := 0; i < len(tokens); i++ {
+ switch tokens[i] {
+ case "-A":
+ if i+1 < len(tokens) {
+ r.chain = tokens[i+1]
+ i++
+ }
+ case "-s":
+ if i+1 < len(tokens) {
+ r.srcIP = tokens[i+1]
+ i++
+ }
+ case "-d":
+ if i+1 < len(tokens) {
+ r.dstIP = tokens[i+1]
+ i++
+ }
+ case "-p":
+ if i+1 < len(tokens) {
+ r.proto = strings.ToLower(strings.TrimSpace(tokens[i+1]))
+ i++
+ }
+ case "--sport", "--source-port":
+ if i+1 < len(tokens) {
+ if v, err := strconv.Atoi(tokens[i+1]); err == nil {
+ r.sport = &v
+ }
+ i++
+ }
+ case "--dport", "--destination-port":
+ if i+1 < len(tokens) {
+ if v, err := strconv.Atoi(tokens[i+1]); err == nil {
+ r.dport = &v
+ }
+ i++
+ }
+ case "-j":
+ if i+1 < len(tokens) {
+ r.jump = strings.ToUpper(strings.TrimSpace(tokens[i+1]))
+ i++
+ }
+ case "-i":
+ if i+1 < len(tokens) {
+ r.inIface = tokens[i+1]
+ i++
+ }
+ case "-o":
+ if i+1 < len(tokens) {
+ r.outIface = tokens[i+1]
+ i++
+ }
+ case "--ctstate":
+ if i+1 < len(tokens) {
+ r.ctstate = tokens[i+1]
+ i++
+ }
+ case "--to-destination":
+ if i+1 < len(tokens) {
+ r.toDest = tokens[i+1]
+ i++
+ }
+ case "--to-ports":
+ if i+1 < len(tokens) {
+ if v, err := strconv.Atoi(tokens[i+1]); err == nil {
+ r.toPorts = &v
+ }
+ i++
+ }
+ case "--comment":
+ if i+1 < len(tokens) {
+ r.comment = tokens[i+1]
+ i++
+ }
+ }
+ }
+ return r
+}
+
+func chainDefaultPolicy(rules []iptablesParsedRule) string {
+ for i := len(rules) - 1; i >= 0; i-- {
+ r := rules[i]
+ if isUnconditionalDefaultRule(r) {
+ switch r.jump {
+ case "ACCEPT":
+ return "accept"
+ case "DROP":
+ return "drop"
+ case "REJECT":
+ return "reject"
+ }
+ }
+ }
+ return "accept"
+}
+
+func stripDefaultPolicyRule(rules []iptablesParsedRule) []iptablesParsedRule {
+ out := make([]iptablesParsedRule, 0, len(rules))
+ for i := 0; i < len(rules); i++ {
+ if isUnconditionalDefaultRule(rules[i]) {
+ continue
+ }
+ out = append(out, rules[i])
+ }
+ return out
+}
+
+func isUnconditionalDefaultRule(r iptablesParsedRule) bool {
+ if r.jump != "ACCEPT" && r.jump != "DROP" && r.jump != "REJECT" {
+ return false
+ }
+ if r.srcIP != "" || r.dstIP != "" || r.proto != "" || r.sport != nil || r.dport != nil {
+ return false
+ }
+ if r.inIface != "" || r.outIface != "" || r.ctstate != "" || r.toDest != "" {
+ return false
+ }
+ if strings.TrimSpace(r.comment) != "" {
+ return false
+ }
+ return true
+}
+
+func parsePortForwardsFromNat(prerouting []iptablesParsedRule) []FirewallPortRule {
+ out := make([]FirewallPortRule, 0)
+ for _, r := range prerouting {
+ if r.dport == nil || r.proto == "" {
+ continue
+ }
+ switch r.jump {
+ case "DNAT":
+ dstIP, dstPort := parseToDestination(r.toDest)
+ if dstIP == "" || dstPort == 0 {
+ continue
+ }
+ out = append(out, FirewallPortRule{
+ Chain: "prerouting",
+ Managed: boolPtr(true),
+ SourcePort: *r.dport,
+ Protocols: []string{r.proto},
+ DestinationIP: dstIP,
+ DestinationPort: dstPort,
+ Comment: r.comment,
+ })
+ case "REDIRECT":
+ if r.toPorts == nil {
+ continue
+ }
+ out = append(out, FirewallPortRule{
+ Chain: "prerouting_redirect",
+ Managed: boolPtr(true),
+ SourcePort: *r.dport,
+ Protocols: []string{r.proto},
+ DestinationIP: "0.0.0.0",
+ DestinationPort: *r.toPorts,
+ Comment: r.comment,
+ })
+ default:
+ continue
+ }
+ }
+ return out
+}
+
+func parsePortForwardsFromSystemPrerouting(rules []iptablesParsedRule) []FirewallPortRule {
+ out := make([]FirewallPortRule, 0)
+ for _, r := range rules {
+ if r.chain != "PREROUTING" {
+ continue
+ }
+ if r.dport == nil || r.proto == "" {
+ continue
+ }
+ switch r.jump {
+ case "DNAT":
+ dstIP, dstPort := parseToDestination(r.toDest)
+ if dstIP == "" || dstPort == 0 {
+ continue
+ }
+ out = append(out, FirewallPortRule{
+ Chain: "prerouting",
+ Managed: boolPtr(false),
+ SourcePort: *r.dport,
+ Protocols: []string{r.proto},
+ DestinationIP: dstIP,
+ DestinationPort: dstPort,
+ Comment: r.comment,
+ })
+ case "REDIRECT":
+ if r.toPorts == nil {
+ continue
+ }
+ out = append(out, FirewallPortRule{
+ Chain: "prerouting_redirect",
+ Managed: boolPtr(false),
+ SourcePort: *r.dport,
+ Protocols: []string{r.proto},
+ DestinationIP: "0.0.0.0",
+ DestinationPort: *r.toPorts,
+ Comment: r.comment,
+ })
+ default:
+ continue
+ }
+ }
+ return out
+}
+
+func parsePortForwardsFromNatOutput(rules []iptablesParsedRule) []FirewallPortRule {
+ out := make([]FirewallPortRule, 0)
+ for _, r := range rules {
+ if r.chain != "OUTPUT" {
+ continue
+ }
+ if r.jump != "REDIRECT" {
+ continue
+ }
+ if r.dport == nil || r.toPorts == nil {
+ continue
+ }
+ if r.proto == "" {
+ continue
+ }
+ if strings.TrimSpace(r.comment) != firewallNatOutputCommentPrefix && !strings.HasPrefix(strings.TrimSpace(r.comment), firewallNatOutputCommentPrefix+":") {
+ continue
+ }
+ comment := parseManagedPortForwardComment(r.comment)
+ out = append(out, FirewallPortRule{
+ Chain: "output",
+ Managed: boolPtr(true),
+ SourcePort: *r.dport,
+ Protocols: []string{r.proto},
+ DestinationIP: "0.0.0.0",
+ DestinationPort: *r.toPorts,
+ Comment: comment,
+ })
+ }
+ return out
+}
+
+func parsePortForwardsFromSystemNatOutput(rules []iptablesParsedRule) []FirewallPortRule {
+ out := make([]FirewallPortRule, 0)
+ for _, r := range rules {
+ if r.chain != "OUTPUT" {
+ continue
+ }
+ if r.jump != "REDIRECT" {
+ continue
+ }
+ if r.dport == nil || r.toPorts == nil {
+ continue
+ }
+ if r.proto == "" {
+ continue
+ }
+ if strings.TrimSpace(r.comment) == firewallNatOutputCommentPrefix || strings.HasPrefix(strings.TrimSpace(r.comment), firewallNatOutputCommentPrefix+":") {
+ continue
+ }
+ out = append(out, FirewallPortRule{
+ Chain: "output",
+ Managed: boolPtr(false),
+ SourcePort: *r.dport,
+ Protocols: []string{r.proto},
+ DestinationIP: "0.0.0.0",
+ DestinationPort: *r.toPorts,
+ Comment: r.comment,
+ })
+ }
+ return out
+}
+
+func parseManagedPortForwardComment(s string) string {
+ t := strings.TrimSpace(s)
+ if t == firewallNatOutputCommentPrefix {
+ return ""
+ }
+ if strings.HasPrefix(t, firewallNatOutputCommentPrefix+":") {
+ return strings.TrimPrefix(t, firewallNatOutputCommentPrefix+":")
+ }
+ return ""
+}
+
+func parseToDestination(toDest string) (string, int) {
+ t := strings.TrimSpace(toDest)
+ if t == "" {
+ return "", 0
+ }
+ if strings.Contains(t, ":") {
+ parts := strings.Split(t, ":")
+ if len(parts) < 2 {
+ return "", 0
+ }
+ portStr := parts[len(parts)-1]
+ ip := strings.Join(parts[:len(parts)-1], ":")
+ p, err := strconv.Atoi(portStr)
+ if err != nil || p < 1 || p > 65535 {
+ return "", 0
+ }
+ return ip, p
+ }
+ return "", 0
+}
+
+func filterAutoForwardRules(forward []iptablesParsedRule, portForwards []FirewallPortRule) []iptablesParsedRule {
+ if len(portForwards) == 0 || len(forward) == 0 {
+ return forward
+ }
+ keys := make(map[string]struct{}, len(portForwards))
+ for _, pf := range portForwards {
+ for _, p := range pf.Protocols {
+ switch strings.ToLower(strings.TrimSpace(pf.Chain)) {
+ case "output", "prerouting_redirect":
+ continue
+ }
+ if isLocalRedirectDestination(pf.DestinationIP) {
+ continue
+ }
+ keys[fmt.Sprintf("%s|%d|%s|%s", pf.DestinationIP, pf.DestinationPort, strings.ToLower(p), pf.Comment)] = struct{}{}
+ }
+ }
+
+ out := make([]iptablesParsedRule, 0, len(forward))
+ for _, r := range forward {
+ if r.jump == "ACCEPT" && strings.Contains(r.ctstate, "NEW,ESTABLISHED,RELATED") && r.dstIP != "" && r.dport != nil && r.proto != "" {
+ if _, ok := keys[fmt.Sprintf("%s|%d|%s|%s", r.dstIP, *r.dport, strings.ToLower(r.proto), r.comment)]; ok {
+ continue
+ }
+ }
+ out = append(out, r)
+ }
+ return out
+}
+
+func parseCommRulesFromChain(chain string, rules []iptablesParsedRule) []FirewallRule {
+ out := make([]FirewallRule, 0)
+ for _, r := range rules {
+ if isInternalAcceptRule(chain, r) {
+ continue
+ }
+
+ action := strings.ToLower(r.jump)
+ if action != "accept" && action != "drop" && action != "reject" {
+ continue
+ }
+ protos := []string{"any"}
+ if r.proto != "" {
+ protos = []string{r.proto}
+ }
+
+ src := r.srcIP
+ dst := r.dstIP
+ if src == "0.0.0.0/0" {
+ src = ""
+ }
+ if dst == "0.0.0.0/0" {
+ dst = ""
+ }
+
+ out = append(out, FirewallRule{
+ Chain: chain,
+ SourceIP: src,
+ SourcePort: r.sport,
+ Protocols: protos,
+ DestinationIP: dst,
+ DestinationPort: r.dport,
+ Action: action,
+ Comment: r.comment,
+ })
+ }
+ return out
+}
+
+func isInternalAcceptRule(chain string, r iptablesParsedRule) bool {
+ if r.jump != "ACCEPT" {
+ return false
+ }
+ if chain == "input" && r.inIface == "lo" {
+ return true
+ }
+ if chain == "output" && r.outIface == "lo" {
+ return true
+ }
+ if strings.Contains(r.ctstate, "ESTABLISHED,RELATED") {
+ return true
+ }
+ return false
+}
+
+func groupFirewallRules(in []FirewallRule) []FirewallRule {
+ type key struct {
+ chain string
+ src string
+ dst string
+ sport string
+ dport string
+ act string
+ cmt string
+ }
+
+ out := make([]FirewallRule, 0)
+ index := make(map[key]int)
+ for _, r := range in {
+ k := key{
+ chain: r.Chain,
+ src: r.SourceIP,
+ dst: r.DestinationIP,
+ act: r.Action,
+ cmt: r.Comment,
+ }
+ if r.SourcePort != nil {
+ k.sport = strconv.Itoa(*r.SourcePort)
+ }
+ if r.DestinationPort != nil {
+ k.dport = strconv.Itoa(*r.DestinationPort)
+ }
+
+ if idx, ok := index[k]; ok {
+ if len(out[idx].Protocols) == 1 && out[idx].Protocols[0] == "any" {
+ continue
+ }
+ if len(r.Protocols) == 1 && r.Protocols[0] == "any" {
+ out[idx].Protocols = []string{"any"}
+ continue
+ }
+ out[idx].Protocols = appendUnique(out[idx].Protocols, r.Protocols...)
+ continue
+ }
+
+ index[k] = len(out)
+ out = append(out, r)
+ }
+ return out
+}
+
+func groupPortForwards(in []FirewallPortRule) []FirewallPortRule {
+ type key struct {
+ chain string
+ managed string
+ srcPort int
+ dstIP string
+ dstPort int
+ cmt string
+ }
+
+ out := make([]FirewallPortRule, 0)
+ index := make(map[key]int)
+ for _, r := range in {
+ managed := "true"
+ if r.Managed != nil && !*r.Managed {
+ managed = "false"
+ }
+ k := key{
+ chain: r.Chain,
+ managed: managed,
+ srcPort: r.SourcePort,
+ dstIP: r.DestinationIP,
+ dstPort: r.DestinationPort,
+ cmt: r.Comment,
+ }
+ if idx, ok := index[k]; ok {
+ out[idx].Protocols = appendUnique(out[idx].Protocols, r.Protocols...)
+ continue
+ }
+ index[k] = len(out)
+ out = append(out, r)
+ }
+ return out
+}
+
+func appendUnique(dst []string, items ...string) []string {
+ set := make(map[string]struct{}, len(dst))
+ for _, v := range dst {
+ set[v] = struct{}{}
+ }
+ for _, v := range items {
+ v = strings.ToLower(strings.TrimSpace(v))
+ if v == "" {
+ continue
+ }
+ if _, ok := set[v]; ok {
+ continue
+ }
+ set[v] = struct{}{}
+ dst = append(dst, v)
+ }
+ return dst
+}
+
+func resetFirewallForFactory() {
+ if _, err := exec.LookPath("iptables"); err != nil {
+ return
+ }
+
+ _ = removeFirewallJumps(false)
+ _ = removeFirewallChains(false)
+
+ natSupported, err := iptablesTableSupported("nat")
+ if err == nil && natSupported {
+ _ = removeFirewallJumps(true)
+ _ = removeFirewallChains(true)
+ }
+
+ _ = sysctlWrite("net.ipv4.ip_forward", "0")
+}
+
+func removeFirewallJumps(needNat bool) error {
+ if err := removeJumpAll("filter", "INPUT", firewallChainInput); err != nil {
+ return err
+ }
+ if err := removeJumpAll("filter", "OUTPUT", firewallChainOutput); err != nil {
+ return err
+ }
+ if err := removeJumpAll("filter", "FORWARD", firewallChainForward); err != nil {
+ return err
+ }
+ if needNat {
+ if err := removeJumpAll("nat", "PREROUTING", firewallChainNatPrerouting); err != nil {
+ return err
+ }
+ if err := removeJumpAll("nat", "POSTROUTING", firewallChainNatPostrouting); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func removeJumpAll(table, fromChain, toChain string) error {
+ for i := 0; i < 16; i++ {
+ err := iptables(table, "-D", fromChain, "-j", toChain)
+ if err == nil {
+ continue
+ }
+ if isNoSuchRuleErr(err) {
+ return nil
+ }
+ return err
+ }
+ return nil
+}
+
+func removeFirewallChains(needNat bool) error {
+ for _, chain := range []string{firewallChainInput, firewallChainOutput, firewallChainForward} {
+ _ = iptables("filter", "-F", chain)
+ _ = iptables("filter", "-X", chain)
+ }
+ if needNat {
+ for _, chain := range []string{firewallChainNatPrerouting, firewallChainNatPostrouting} {
+ _ = iptables("nat", "-F", chain)
+ _ = iptables("nat", "-X", chain)
+ }
+ }
+ return nil
+}
+
+func isNoSuchRuleErr(err error) bool {
+ if err == nil {
+ return false
+ }
+ msg := err.Error()
+ if strings.Contains(msg, "No chain/target/match by that name") {
+ return true
+ }
+ if strings.Contains(msg, "Bad rule") {
+ return true
+ }
+ if strings.Contains(msg, "does a matching rule exist in that chain") {
+ return true
+ }
+ if strings.Contains(msg, "No such file or directory") {
+ return true
+ }
+ return errors.Is(err, exec.ErrNotFound)
+}
diff --git a/internal/network/netif.go b/internal/network/netif.go
index 47141a4..3748c66 100644
--- a/internal/network/netif.go
+++ b/internal/network/netif.go
@@ -176,6 +176,49 @@ func (s *NetworkInterfaceState) MACString() string {
return s.macAddr.String()
}
+func (s *NetworkInterfaceState) SetMACAddress(macAddress string) (string, error) {
+ macAddress = strings.TrimSpace(macAddress)
+ if macAddress == "" {
+ return "", fmt.Errorf("mac address is empty")
+ }
+ hw, err := net.ParseMAC(macAddress)
+ if err != nil {
+ return "", fmt.Errorf("invalid mac address")
+ }
+ if len(hw) != 6 {
+ return "", fmt.Errorf("invalid mac address length")
+ }
+ normalized := strings.ToLower(hw.String())
+
+ s.stateLock.Lock()
+ iface, err := netlink.LinkByName(s.interfaceName)
+ if err != nil {
+ s.stateLock.Unlock()
+ return "", err
+ }
+ if err := netlink.LinkSetDown(iface); err != nil {
+ s.stateLock.Unlock()
+ return "", err
+ }
+ if err := netlink.LinkSetHardwareAddr(iface, hw); err != nil {
+ s.stateLock.Unlock()
+ return "", err
+ }
+ if err := netlink.LinkSetUp(iface); err != nil {
+ s.stateLock.Unlock()
+ return "", err
+ }
+ s.stateLock.Unlock()
+
+ if s.dhcpClient != nil && strings.TrimSpace(s.config.IPv4Mode.String) == "dhcp" {
+ _ = s.dhcpClient.Renew()
+ }
+ if _, err := s.update(); err != nil {
+ return normalized, err
+ }
+ return normalized, nil
+}
+
func (s *NetworkInterfaceState) update() (DhcpTargetState, error) {
s.stateLock.Lock()
defer s.stateLock.Unlock()
diff --git a/internal/usbgadget/udc.go b/internal/usbgadget/udc.go
index 26b5ee4..a35e045 100644
--- a/internal/usbgadget/udc.go
+++ b/internal/usbgadget/udc.go
@@ -51,14 +51,15 @@ func (u *UsbGadget) RebindUsb(ignoreUnbindError bool) error {
}
// GetUsbState returns the current state of the USB gadget
-func (u *UsbGadget) GetUsbState() (state string) {
- // Check the auxiliary disc node first
- discFile := "/sys/devices/platform/ff3e0000.usb2-phy/disc"
- discBytes, err := os.ReadFile(discFile)
- if err == nil {
- discState := strings.TrimSpace(string(discBytes))
- if discState == "DISCONNECTED" {
- return "not attached"
+func (u *UsbGadget) GetUsbState(enhancedDetection bool) (state string) {
+ if enhancedDetection {
+ discFile := "/sys/devices/platform/ff3e0000.usb2-phy/disc"
+ discBytes, err := os.ReadFile(discFile)
+ if err == nil {
+ discState := strings.TrimSpace(string(discBytes))
+ if discState == "DISCONNECTED" {
+ return "not attached"
+ }
}
}
diff --git a/jsonrpc.go b/jsonrpc.go
index d004e74..3bc5c94 100644
--- a/jsonrpc.go
+++ b/jsonrpc.go
@@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"fmt"
+ "net/url"
"os"
"os/exec"
"reflect"
@@ -163,6 +164,16 @@ func rpcPing() (string, error) {
return "pong", nil
}
+type BootStorageTypeResponse struct {
+ Type string `json:"type"`
+}
+
+func rpcGetBootStorageType() (*BootStorageTypeResponse, error) {
+ return &BootStorageTypeResponse{
+ Type: string(GetBootStorageType()),
+ }, nil
+}
+
func rpcGetDeviceID() (string, error) {
return GetDeviceID(), nil
}
@@ -388,6 +399,32 @@ func rpcSetCustomUpdateBaseURL(baseURL string) error {
return nil
}
+func rpcGetUpdateDownloadProxy() (string, error) {
+ return config.UpdateDownloadProxy, nil
+}
+
+func rpcSetUpdateDownloadProxy(proxy string) error {
+ proxy = strings.TrimSpace(proxy)
+ if proxy != "" {
+ parsed, err := url.Parse(proxy)
+ if err != nil || strings.TrimSpace(parsed.Scheme) == "" || strings.TrimSpace(parsed.Host) == "" {
+ return fmt.Errorf("invalid update download proxy")
+ }
+ if parsed.Scheme != "http" && parsed.Scheme != "https" {
+ return fmt.Errorf("update download proxy must use http or https")
+ }
+ if !strings.HasSuffix(proxy, "/") {
+ proxy += "/"
+ }
+ }
+
+ config.UpdateDownloadProxy = proxy
+ if err := SaveConfig(); err != nil {
+ return fmt.Errorf("failed to save config: %w", err)
+ }
+ return nil
+}
+
func rpcSetDisplayRotation(params DisplayRotationSettings) error {
var err error
_, err = lvDispSetRotation(params.Rotation)
@@ -723,6 +760,29 @@ func rpcSetUsbEmulationState(enabled bool) error {
}
}
+func rpcGetUsbEnhancedDetection() (bool, error) {
+ ensureConfigLoaded()
+ return config.UsbEnhancedDetection, nil
+}
+
+func rpcSetUsbEnhancedDetection(enabled bool) error {
+ ensureConfigLoaded()
+ if config.UsbEnhancedDetection == enabled {
+ return nil
+ }
+
+ config.UsbEnhancedDetection = enabled
+ if err := SaveConfig(); err != nil {
+ return fmt.Errorf("failed to save config: %w", err)
+ }
+
+ if gadget != nil {
+ checkUSBState()
+ }
+
+ return nil
+}
+
func rpcGetUsbConfig() (usbgadget.Config, error) {
LoadConfig()
return *config.UsbConfig, nil
@@ -762,6 +822,36 @@ func rpcResetConfig() error {
return nil
}
+func rpcGetConfigRaw() (string, error) {
+ configLock.Lock()
+ defer configLock.Unlock()
+
+ data, err := json.MarshalIndent(config, "", " ")
+ if err != nil {
+ return "", fmt.Errorf("failed to marshal config: %w", err)
+ }
+
+ return string(data), nil
+}
+
+func rpcSetConfigRaw(configStr string) error {
+ var newConfig Config
+ if err := json.Unmarshal([]byte(configStr), &newConfig); err != nil {
+ return fmt.Errorf("failed to unmarshal config: %w", err)
+ }
+
+ configLock.Lock()
+ config = &newConfig
+ configLock.Unlock()
+
+ if err := SaveConfig(); err != nil {
+ return fmt.Errorf("failed to save config: %w", err)
+ }
+
+ logger.Info().Msg("Configuration updated via raw JSON")
+ return nil
+}
+
func rpcGetActiveExtension() (string, error) {
return config.ActiveExtension, nil
}
@@ -1194,6 +1284,42 @@ func rpcSetAutoMountSystemInfo(enabled bool) error {
return nil
}
+func rpcGetFirewallConfig() (FirewallConfig, error) {
+ LoadConfig()
+ if systemCfg, err := ReadFirewallConfigFromSystem(); err == nil && systemCfg != nil {
+ return *systemCfg, nil
+ }
+ if config.Firewall == nil {
+ return *defaultConfig.Firewall, nil
+ }
+ return *config.Firewall, nil
+}
+
+func rpcSetFirewallConfig(firewallCfg FirewallConfig) error {
+ LoadConfig()
+ managedCfg := firewallCfg
+ managedCfg.PortForwards = filterManagedPortForwards(firewallCfg.PortForwards)
+ if err := ApplyFirewallConfig(&managedCfg); err != nil {
+ return err
+ }
+ config.Firewall = &managedCfg
+ if err := SaveConfig(); err != nil {
+ return fmt.Errorf("failed to save config: %w", err)
+ }
+ return nil
+}
+
+func filterManagedPortForwards(in []FirewallPortRule) []FirewallPortRule {
+ out := make([]FirewallPortRule, 0, len(in))
+ for _, r := range in {
+ if r.Managed != nil && !*r.Managed {
+ continue
+ }
+ out = append(out, r)
+ }
+ return out
+}
+
func rpcConfirmOtherSession() (bool, error) {
return true, nil
}
@@ -1205,6 +1331,7 @@ var rpcHandlers = map[string]RPCHandler{
"getNetworkState": {Func: rpcGetNetworkState},
"getNetworkSettings": {Func: rpcGetNetworkSettings},
"setNetworkSettings": {Func: rpcSetNetworkSettings, Params: []string{"settings"}},
+ "setEthernetMacAddress": {Func: rpcSetEthernetMacAddress, Params: []string{"macAddress"}},
"renewDHCPLease": {Func: rpcRenewDHCPLease},
"requestDHCPAddress": {Func: rpcRequestDHCPAddress, Params: []string{"ip"}},
"keyboardReport": {Func: rpcKeyboardReport, Params: []string{"modifier", "keys"}},
@@ -1237,6 +1364,8 @@ var rpcHandlers = map[string]RPCHandler{
"tryUpdate": {Func: rpcTryUpdate},
"getCustomUpdateBaseURL": {Func: rpcGetCustomUpdateBaseURL},
"setCustomUpdateBaseURL": {Func: rpcSetCustomUpdateBaseURL, Params: []string{"baseURL"}},
+ "getUpdateDownloadProxy": {Func: rpcGetUpdateDownloadProxy},
+ "setUpdateDownloadProxy": {Func: rpcSetUpdateDownloadProxy, Params: []string{"proxy"}},
"getDevModeState": {Func: rpcGetDevModeState},
"getSSHKeyState": {Func: rpcGetSSHKeyState},
"setSSHKeyState": {Func: rpcSetSSHKeyState, Params: []string{"sshKey"}},
@@ -1247,6 +1376,8 @@ var rpcHandlers = map[string]RPCHandler{
"isUpdatePending": {Func: rpcIsUpdatePending},
"getUsbEmulationState": {Func: rpcGetUsbEmulationState},
"setUsbEmulationState": {Func: rpcSetUsbEmulationState, Params: []string{"enabled"}},
+ "getUsbEnhancedDetection": {Func: rpcGetUsbEnhancedDetection},
+ "setUsbEnhancedDetection": {Func: rpcSetUsbEnhancedDetection, Params: []string{"enabled"}},
"getUsbConfig": {Func: rpcGetUsbConfig},
"setUsbConfig": {Func: rpcSetUsbConfig, Params: []string{"usbConfig"}},
"checkMountUrl": {Func: rpcCheckMountUrl, Params: []string{"url"}},
@@ -1256,6 +1387,7 @@ var rpcHandlers = map[string]RPCHandler{
"resetSDStorage": {Func: rpcResetSDStorage},
"mountSDStorage": {Func: rpcMountSDStorage},
"unmountSDStorage": {Func: rpcUnmountSDStorage},
+ "formatSDStorage": {Func: rpcFormatSDStorage, Params: []string{"confirm"}},
"mountWithHTTP": {Func: rpcMountWithHTTP, Params: []string{"url", "mode"}},
"mountWithWebRTC": {Func: rpcMountWithWebRTC, Params: []string{"filename", "size", "mode"}},
"mountWithStorage": {Func: rpcMountWithStorage, Params: []string{"filename", "mode"}},
@@ -1272,6 +1404,8 @@ var rpcHandlers = map[string]RPCHandler{
"getWakeOnLanDevices": {Func: rpcGetWakeOnLanDevices},
"setWakeOnLanDevices": {Func: rpcSetWakeOnLanDevices, Params: []string{"params"}},
"resetConfig": {Func: rpcResetConfig},
+ "getConfigRaw": {Func: rpcGetConfigRaw},
+ "setConfigRaw": {Func: rpcSetConfigRaw, Params: []string{"configStr"}},
"setDisplayRotation": {Func: rpcSetDisplayRotation, Params: []string{"params"}},
"getDisplayRotation": {Func: rpcGetDisplayRotation},
"setBacklightSettings": {Func: rpcSetBacklightSettings, Params: []string{"params"}},
@@ -1345,4 +1479,7 @@ var rpcHandlers = map[string]RPCHandler{
"getWireguardConfig": {Func: rpcGetWireguardConfig},
"getWireguardLog": {Func: rpcGetWireguardLog},
"getWireguardInfo": {Func: rpcGetWireguardInfo},
+ "getFirewallConfig": {Func: rpcGetFirewallConfig},
+ "setFirewallConfig": {Func: rpcSetFirewallConfig, Params: []string{"config"}},
+ "getBootStorageType": {Func: rpcGetBootStorageType},
}
diff --git a/keys.go b/keys.go
new file mode 100644
index 0000000..bf47538
--- /dev/null
+++ b/keys.go
@@ -0,0 +1,245 @@
+package kvm
+
+import (
+ "context"
+ "encoding/binary"
+ "errors"
+ "io"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "sync"
+ "syscall"
+ "time"
+
+ "golang.org/x/sys/unix"
+)
+
+const (
+ evKey = 0x01
+)
+
+type keyHoldResetDetector struct {
+ mu sync.Mutex
+ pressAt map[uint16]time.Time
+ threshold time.Duration
+ now func() time.Time
+ afterFunc func(d time.Duration, f func()) func() bool
+ stop map[uint16]func() bool
+ triggered bool
+ onTrigger func(code uint16, hold time.Duration)
+}
+
+func newKeyHoldResetDetector(threshold time.Duration, now func() time.Time, afterFunc func(d time.Duration, f func()) func() bool, onTrigger func(code uint16, hold time.Duration)) *keyHoldResetDetector {
+ if now == nil {
+ now = time.Now
+ }
+ if afterFunc == nil {
+ afterFunc = func(d time.Duration, f func()) func() bool {
+ t := time.AfterFunc(d, f)
+ return t.Stop
+ }
+ }
+ return &keyHoldResetDetector{
+ pressAt: map[uint16]time.Time{},
+ threshold: threshold,
+ now: now,
+ afterFunc: afterFunc,
+ stop: map[uint16]func() bool{},
+ onTrigger: onTrigger,
+ }
+}
+
+func (d *keyHoldResetDetector) close() {
+ d.mu.Lock()
+ defer d.mu.Unlock()
+ for code, stop := range d.stop {
+ _ = stop()
+ delete(d.stop, code)
+ delete(d.pressAt, code)
+ }
+}
+
+func (d *keyHoldResetDetector) fire(code uint16) {
+ d.mu.Lock()
+ if d.triggered {
+ d.mu.Unlock()
+ return
+ }
+ d.triggered = true
+ t0, ok := d.pressAt[code]
+ now := d.now()
+ d.mu.Unlock()
+
+ hold := d.threshold
+ if ok {
+ hold = now.Sub(t0)
+ }
+ if d.onTrigger != nil {
+ d.onTrigger(code, hold)
+ }
+}
+
+func (d *keyHoldResetDetector) onEvent(typ uint16, code uint16, val int32) {
+ if typ != evKey {
+ return
+ }
+
+ switch val {
+ case 1, 2:
+ d.mu.Lock()
+ if d.triggered {
+ d.mu.Unlock()
+ return
+ }
+ if _, exists := d.pressAt[code]; exists {
+ d.mu.Unlock()
+ return
+ }
+ d.pressAt[code] = d.now()
+ d.stop[code] = d.afterFunc(d.threshold, func() { d.fire(code) })
+ d.mu.Unlock()
+ return
+ case 0:
+ d.mu.Lock()
+ if stop, ok := d.stop[code]; ok {
+ _ = stop()
+ delete(d.stop, code)
+ }
+ delete(d.pressAt, code)
+ d.mu.Unlock()
+ return
+ default:
+ return
+ }
+}
+
+func defaultInputEventSize() int {
+ if strconv.IntSize == 64 {
+ return 24
+ }
+ return 16
+}
+
+func findInputEventDeviceByName(deviceName string) (string, error) {
+ entries, err := os.ReadDir("/sys/class/input")
+ if err != nil {
+ return "", err
+ }
+ for _, e := range entries {
+ if !strings.HasPrefix(e.Name(), "event") {
+ continue
+ }
+ namePath := filepath.Join("/sys/class/input", e.Name(), "device/name")
+ b, err := os.ReadFile(namePath)
+ if err != nil {
+ continue
+ }
+ n := strings.TrimSpace(string(b))
+ if n == deviceName {
+ return filepath.Join("/dev/input", e.Name()), nil
+ }
+ }
+ return "", errors.New("input device not found")
+}
+
+func watchAdcKeysLongPressReset(ctx context.Context) {
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ default:
+ }
+
+ dev, err := findInputEventDeviceByName("adc-keys")
+ if err != nil {
+ keysLogger.Warn().Err(err).Msg("adc-keys device not found")
+ time.Sleep(2 * time.Second)
+ continue
+ }
+
+ f, err := os.OpenFile(dev, os.O_RDONLY, 0)
+ if err != nil {
+ keysLogger.Warn().Err(err).Str("device", dev).Msg("failed to open adc-keys device")
+ time.Sleep(2 * time.Second)
+ continue
+ }
+
+ keysLogger.Info().Str("device", dev).Msg("watching adc-keys events")
+ var resetOnce sync.Once
+ detector := newKeyHoldResetDetector(
+ 5*time.Second,
+ nil,
+ nil,
+ func(code uint16, hold time.Duration) {
+ resetOnce.Do(func() {
+ keysLogger.Warn().Uint16("code", code).Dur("hold", hold).Msg("adc-keys long press detected, resetting config")
+ resetConfigFileAndReboot()
+ })
+ },
+ )
+ eventSize := defaultInputEventSize()
+ buf := make([]byte, eventSize)
+
+ for {
+ select {
+ case <-ctx.Done():
+ detector.close()
+ _ = f.Close()
+ return
+ default:
+ }
+
+ _, err := io.ReadFull(f, buf)
+ if err != nil {
+ if errors.Is(err, syscall.EINVAL) {
+ if eventSize == 24 {
+ eventSize = 16
+ } else {
+ eventSize = 24
+ }
+ buf = make([]byte, eventSize)
+ keysLogger.Info().Str("device", dev).Int("event_size", eventSize).Msg("adc-keys switched input_event size")
+ continue
+ }
+ detector.close()
+ _ = f.Close()
+ keysLogger.Warn().Err(err).Str("device", dev).Msg("adc-keys read failed, reopening")
+ time.Sleep(500 * time.Millisecond)
+ break
+ }
+
+ typeOff, codeOff, valOff := 16, 18, 20
+ if eventSize == 16 {
+ typeOff, codeOff, valOff = 8, 10, 12
+ }
+
+ typ := binary.LittleEndian.Uint16(buf[typeOff : typeOff+2])
+ code := binary.LittleEndian.Uint16(buf[codeOff : codeOff+2])
+ val := int32(binary.LittleEndian.Uint32(buf[valOff : valOff+4]))
+
+ detector.onEvent(typ, code, val)
+ }
+ }
+}
+
+func resetConfigFileAndReboot() {
+ resetFirewallForFactory()
+
+ if err := os.Remove(configPath); err != nil && !errors.Is(err, os.ErrNotExist) {
+ keysLogger.Error().Err(err).Str("path", configPath).Msg("failed to delete config file")
+ } else {
+ keysLogger.Warn().Str("path", configPath).Msg("config file deleted")
+ }
+
+ unix.Sync()
+ time.Sleep(200 * time.Millisecond)
+
+ if err := unix.Reboot(unix.LINUX_REBOOT_CMD_RESTART); err != nil {
+ keysLogger.Error().Err(err).Msg("syscall reboot failed, trying /sbin/reboot")
+ _ = exec.Command("/sbin/reboot", "-f").Run()
+ _ = exec.Command("reboot", "-f").Run()
+ }
+}
diff --git a/log.go b/log.go
index 4ef7a37..33ccb19 100644
--- a/log.go
+++ b/log.go
@@ -30,6 +30,7 @@ var (
displayLogger = logging.GetSubsystemLogger("display")
wolLogger = logging.GetSubsystemLogger("wol")
usbLogger = logging.GetSubsystemLogger("usb")
+ keysLogger = logging.GetSubsystemLogger("keys")
// external components
ginLogger = logging.GetSubsystemLogger("gin")
)
diff --git a/main.go b/main.go
index 21f14ef..b1f871a 100644
--- a/main.go
+++ b/main.go
@@ -35,6 +35,7 @@ func Main() {
Interface("app_version", appVersionLocal).
Msg("starting KVM")
+ go watchAdcKeysLongPressReset(appCtx)
go runWatchdog()
go confirmCurrentSystem() //A/B system
if isNewEnoughSystem {
@@ -57,6 +58,10 @@ func Main() {
os.Exit(1)
}
+ if err := ApplyFirewallConfig(config.Firewall); err != nil {
+ logger.Warn().Err(err).Msg("failed to apply firewall config")
+ }
+
// Initialize time sync
initTimeSync()
timeSync.Start()
diff --git a/network_mac.go b/network_mac.go
new file mode 100644
index 0000000..09750e0
--- /dev/null
+++ b/network_mac.go
@@ -0,0 +1,20 @@
+package kvm
+
+import (
+ "fmt"
+ "os"
+)
+
+const ethernetMacAddressPath = "/userdata/ethaddr.txt"
+
+func rpcSetEthernetMacAddress(macAddress string) (interface{}, error) {
+ normalized, err := networkState.SetMACAddress(macAddress)
+ if err != nil {
+ return nil, err
+ }
+ if err := os.WriteFile(ethernetMacAddressPath, []byte(normalized+"\n"), 0644); err != nil {
+ return nil, fmt.Errorf("failed to write %s: %w", ethernetMacAddressPath, err)
+ }
+ return networkState.RpcGetNetworkState(), nil
+}
+
diff --git a/ota.go b/ota.go
index d638f1f..817b119 100644
--- a/ota.go
+++ b/ota.go
@@ -89,7 +89,7 @@ var UpdateGiteeSystemZipUrls = []string{
const cdnUpdateBaseURL = "https://cdn.picokvm.top/luckfox_picokvm_firmware/lastest/"
-var builtAppVersion = "0.1.1+dev"
+var builtAppVersion = "0.1.2+dev"
var updateSource = "github"
var customUpdateBaseURL string
@@ -672,13 +672,62 @@ func parseVersionTxt(s string) (appVersion string, systemVersion string, err err
return appVersion, systemVersion, nil
}
-func downloadFile(ctx context.Context, path string, url string, downloadProgress *float32) error {
+func shouldProxyUpdateDownloadURL(u *url.URL) bool {
+ if u == nil {
+ return false
+ }
+ host := strings.ToLower(strings.TrimSpace(u.Hostname()))
+ if host == "" {
+ return false
+ }
+ if host == "github.com" || host == "api.github.com" || host == "codeload.github.com" || host == "raw.githubusercontent.com" {
+ return true
+ }
+ if strings.HasSuffix(host, ".github.com") || strings.HasSuffix(host, ".githubusercontent.com") || strings.HasSuffix(host, ".githubassets.com") {
+ return true
+ }
+ return false
+}
+
+func applyUpdateDownloadProxyPrefix(rawURL string) string {
+ if config == nil {
+ return rawURL
+ }
+ proxy := strings.TrimSpace(config.UpdateDownloadProxy)
+ if proxy == "" {
+ return rawURL
+ }
+ proxy = strings.TrimRight(proxy, "/") + "/"
+ if strings.HasPrefix(rawURL, proxy) {
+ return rawURL
+ }
+ parsed, err := url.Parse(rawURL)
+ if err != nil || parsed == nil {
+ return rawURL
+ }
+ if parsed.Scheme != "http" && parsed.Scheme != "https" {
+ return rawURL
+ }
+ if !shouldProxyUpdateDownloadURL(parsed) {
+ return rawURL
+ }
+ return proxy + rawURL
+}
+
+func downloadFile(
+ ctx context.Context,
+ path string,
+ url string,
+ downloadProgress *float32,
+ downloadSpeedBps *float32,
+) error {
//if _, err := os.Stat(path); err == nil {
// if err := os.Remove(path); err != nil {
// return fmt.Errorf("error removing existing file: %w", err)
// }
//}
- otaLogger.Info().Str("path", path).Str("url", url).Msg("downloading file")
+ finalURL := applyUpdateDownloadProxyPrefix(url)
+ otaLogger.Info().Str("path", path).Str("url", finalURL).Msg("downloading file")
unverifiedPath := path + ".unverified"
if _, err := os.Stat(unverifiedPath); err == nil {
@@ -693,7 +742,7 @@ func downloadFile(ctx context.Context, path string, url string, downloadProgress
}
defer file.Close()
- req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
+ req, err := http.NewRequestWithContext(ctx, "GET", finalURL, nil)
if err != nil {
return fmt.Errorf("error creating request: %w", err)
}
@@ -726,6 +775,19 @@ func downloadFile(ctx context.Context, path string, url string, downloadProgress
var lastProgressBytes int64
lastProgressAt := time.Now()
lastReportedProgress := float32(0)
+ lastSpeedAt := time.Now()
+ var lastSpeedBytes int64
+
+ if downloadProgress != nil {
+ *downloadProgress = 0
+ }
+ if downloadSpeedBps != nil {
+ *downloadSpeedBps = 0
+ }
+ if downloadProgress != nil || downloadSpeedBps != nil {
+ triggerOTAStateUpdate()
+ }
+
buf := make([]byte, 32*1024)
for {
nr, er := resp.Body.Read(buf)
@@ -738,20 +800,40 @@ func downloadFile(ctx context.Context, path string, url string, downloadProgress
if ew != nil {
return fmt.Errorf("error writing to file: %w", ew)
}
- if hasKnownSize && downloadProgress != nil {
- progress := float32(written) / float32(totalSize)
- if progress-lastReportedProgress >= 0.001 || time.Since(lastProgressAt) >= 1*time.Second {
- lastReportedProgress = progress
- *downloadProgress = lastReportedProgress
- triggerOTAStateUpdate()
- lastProgressAt = time.Now()
+ now := time.Now()
+ speedUpdated := false
+ progressUpdated := false
+
+ if downloadSpeedBps != nil {
+ dt := now.Sub(lastSpeedAt)
+ if dt >= 1*time.Second {
+ seconds := float32(dt.Seconds())
+ if seconds <= 0 {
+ *downloadSpeedBps = 0
+ } else {
+ *downloadSpeedBps = float32(written-lastSpeedBytes) / seconds
+ }
+ lastSpeedAt = now
+ lastSpeedBytes = written
+ speedUpdated = true
}
}
+
+ if hasKnownSize && downloadProgress != nil {
+ progress := float32(written) / float32(totalSize)
+ if progress-lastReportedProgress >= 0.001 || now.Sub(lastProgressAt) >= 1*time.Second {
+ lastReportedProgress = progress
+ *downloadProgress = lastReportedProgress
+ lastProgressAt = now
+ progressUpdated = true
+ }
+ }
+
if !hasKnownSize && downloadProgress != nil {
if *downloadProgress <= 0 {
*downloadProgress = 0.01
- triggerOTAStateUpdate()
lastProgressBytes = written
+ progressUpdated = true
} else if written-lastProgressBytes >= 1024*1024 {
next := *downloadProgress + 0.01
if next > 0.99 {
@@ -759,11 +841,15 @@ func downloadFile(ctx context.Context, path string, url string, downloadProgress
}
if next-*downloadProgress >= 0.01 {
*downloadProgress = next
- triggerOTAStateUpdate()
lastProgressBytes = written
+ progressUpdated = true
}
}
}
+
+ if speedUpdated || progressUpdated {
+ triggerOTAStateUpdate()
+ }
}
if er != nil {
if er == io.EOF {
@@ -779,6 +865,14 @@ func downloadFile(ctx context.Context, path string, url string, downloadProgress
if downloadProgress != nil && !hasKnownSize {
*downloadProgress = 1
+ if downloadSpeedBps != nil {
+ *downloadSpeedBps = 0
+ }
+ triggerOTAStateUpdate()
+ }
+
+ if downloadSpeedBps != nil && hasKnownSize {
+ *downloadSpeedBps = 0
triggerOTAStateUpdate()
}
@@ -813,6 +907,7 @@ func prepareSystemUpdateTarFromKvmSystemZip(
zipURL string,
outputTarPath string,
downloadProgress *float32,
+ downloadSpeedBps *float32,
verificationProgress *float32,
scopedLogger *zerolog.Logger,
) error {
@@ -846,10 +941,15 @@ func prepareSystemUpdateTarFromKvmSystemZip(
for attempt := 1; attempt <= maxAttempts; attempt++ {
if downloadProgress != nil {
*downloadProgress = 0
+ }
+ if downloadSpeedBps != nil {
+ *downloadSpeedBps = 0
+ }
+ if downloadProgress != nil || downloadSpeedBps != nil {
triggerOTAStateUpdate()
}
- if err := downloadFile(ctx, zipPath, zipURL, downloadProgress); err != nil {
+ if err := downloadFile(ctx, zipPath, zipURL, downloadProgress, downloadSpeedBps); err != nil {
lastErr = err
} else {
zipUnverifiedPath := zipPath + ".unverified"
@@ -1084,8 +1184,10 @@ type OTAState struct {
AppUpdatePending bool `json:"appUpdatePending"`
SystemUpdatePending bool `json:"systemUpdatePending"`
AppDownloadProgress float32 `json:"appDownloadProgress,omitempty"` //TODO: implement for progress bar
+ AppDownloadSpeedBps float32 `json:"appDownloadSpeedBps"`
AppDownloadFinishedAt *time.Time `json:"appDownloadFinishedAt,omitempty"`
SystemDownloadProgress float32 `json:"systemDownloadProgress,omitempty"` //TODO: implement for progress bar
+ SystemDownloadSpeedBps float32 `json:"systemDownloadSpeedBps"`
SystemDownloadFinishedAt *time.Time `json:"systemDownloadFinishedAt,omitempty"`
AppVerificationProgress float32 `json:"appVerificationProgress,omitempty"`
AppVerifiedAt *time.Time `json:"appVerifiedAt,omitempty"`
@@ -1177,7 +1279,13 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
Str("remote", remote.AppVersion).
Msg("App update available")
- err := downloadFile(ctx, "/userdata/picokvm/bin/kvm_app", remote.AppUrl, &otaState.AppDownloadProgress)
+ err := downloadFile(
+ ctx,
+ "/userdata/picokvm/bin/kvm_app",
+ remote.AppUrl,
+ &otaState.AppDownloadProgress,
+ &otaState.AppDownloadSpeedBps,
+ )
if err != nil {
otaState.Error = fmt.Sprintf("Error downloading app update: %v", err)
scopedLogger.Error().Err(err).Msg("Error downloading app update")
@@ -1227,6 +1335,7 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
remote.SystemUrl,
systemTarPath,
&otaState.SystemDownloadProgress,
+ &otaState.SystemDownloadSpeedBps,
&otaState.SystemVerificationProgress,
&scopedLogger,
)
@@ -1238,7 +1347,13 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
}
} else {
systemZipPath := "/userdata/picokvm/update_system.zip"
- err := downloadFile(ctx, systemZipPath, remote.SystemUrl, &otaState.SystemDownloadProgress)
+ err := downloadFile(
+ ctx,
+ systemZipPath,
+ remote.SystemUrl,
+ &otaState.SystemDownloadProgress,
+ &otaState.SystemDownloadSpeedBps,
+ )
if err != nil {
otaState.Error = fmt.Sprintf("Error downloading system update: %v", err)
scopedLogger.Error().Err(err).Msg("Error downloading system update")
diff --git a/ui/package-lock.json b/ui/package-lock.json
index a227bbc..91c94d7 100644
--- a/ui/package-lock.json
+++ b/ui/package-lock.json
@@ -1790,9 +1790,9 @@
}
},
"node_modules/@rollup/rollup-android-arm-eabi": {
- "version": "4.41.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.41.0.tgz",
- "integrity": "sha512-KxN+zCjOYHGwCl4UCtSfZ6jrq/qi88JDUtiEFk8LELEHq2Egfc/FgW+jItZiOLRuQfb/3xJSgFuNPC9jzggX+A==",
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz",
+ "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==",
"cpu": [
"arm"
],
@@ -1803,9 +1803,9 @@
]
},
"node_modules/@rollup/rollup-android-arm64": {
- "version": "4.41.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.41.0.tgz",
- "integrity": "sha512-yDvqx3lWlcugozax3DItKJI5j05B0d4Kvnjx+5mwiUpWramVvmAByYigMplaoAQ3pvdprGCTCE03eduqE/8mPQ==",
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz",
+ "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==",
"cpu": [
"arm64"
],
@@ -1816,9 +1816,9 @@
]
},
"node_modules/@rollup/rollup-darwin-arm64": {
- "version": "4.41.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.41.0.tgz",
- "integrity": "sha512-2KOU574vD3gzcPSjxO0eyR5iWlnxxtmW1F5CkNOHmMlueKNCQkxR6+ekgWyVnz6zaZihpUNkGxjsYrkTJKhkaw==",
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz",
+ "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==",
"cpu": [
"arm64"
],
@@ -1829,9 +1829,9 @@
]
},
"node_modules/@rollup/rollup-darwin-x64": {
- "version": "4.41.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.41.0.tgz",
- "integrity": "sha512-gE5ACNSxHcEZyP2BA9TuTakfZvULEW4YAOtxl/A/YDbIir/wPKukde0BNPlnBiP88ecaN4BJI2TtAd+HKuZPQQ==",
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz",
+ "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==",
"cpu": [
"x64"
],
@@ -1842,9 +1842,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-arm64": {
- "version": "4.41.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.41.0.tgz",
- "integrity": "sha512-GSxU6r5HnWij7FoSo7cZg3l5GPg4HFLkzsFFh0N/b16q5buW1NAWuCJ+HMtIdUEi6XF0qH+hN0TEd78laRp7Dg==",
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz",
+ "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==",
"cpu": [
"arm64"
],
@@ -1855,9 +1855,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-x64": {
- "version": "4.41.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.41.0.tgz",
- "integrity": "sha512-KGiGKGDg8qLRyOWmk6IeiHJzsN/OYxO6nSbT0Vj4MwjS2XQy/5emsmtoqLAabqrohbgLWJ5GV3s/ljdrIr8Qjg==",
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz",
+ "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==",
"cpu": [
"x64"
],
@@ -1868,9 +1868,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
- "version": "4.41.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.41.0.tgz",
- "integrity": "sha512-46OzWeqEVQyX3N2/QdiU/CMXYDH/lSHpgfBkuhl3igpZiaB3ZIfSjKuOnybFVBQzjsLwkus2mjaESy8H41SzvA==",
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz",
+ "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==",
"cpu": [
"arm"
],
@@ -1881,9 +1881,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
- "version": "4.41.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.41.0.tgz",
- "integrity": "sha512-lfgW3KtQP4YauqdPpcUZHPcqQXmTmH4nYU0cplNeW583CMkAGjtImw4PKli09NFi2iQgChk4e9erkwlfYem6Lg==",
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz",
+ "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==",
"cpu": [
"arm"
],
@@ -1894,9 +1894,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
- "version": "4.41.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.41.0.tgz",
- "integrity": "sha512-nn8mEyzMbdEJzT7cwxgObuwviMx6kPRxzYiOl6o/o+ChQq23gfdlZcUNnt89lPhhz3BYsZ72rp0rxNqBSfqlqw==",
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz",
+ "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==",
"cpu": [
"arm64"
],
@@ -1907,9 +1907,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
- "version": "4.41.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.41.0.tgz",
- "integrity": "sha512-l+QK99je2zUKGd31Gh+45c4pGDAqZSuWQiuRFCdHYC2CSiO47qUWsCcenrI6p22hvHZrDje9QjwSMAFL3iwXwQ==",
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz",
+ "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==",
"cpu": [
"arm64"
],
@@ -1919,10 +1919,10 @@
"linux"
]
},
- "node_modules/@rollup/rollup-linux-loongarch64-gnu": {
- "version": "4.41.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.41.0.tgz",
- "integrity": "sha512-WbnJaxPv1gPIm6S8O/Wg+wfE/OzGSXlBMbOe4ie+zMyykMOeqmgD1BhPxZQuDqwUN+0T/xOFtL2RUWBspnZj3w==",
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz",
+ "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==",
"cpu": [
"loong64"
],
@@ -1932,10 +1932,36 @@
"linux"
]
},
- "node_modules/@rollup/rollup-linux-powerpc64le-gnu": {
- "version": "4.41.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.41.0.tgz",
- "integrity": "sha512-eRDWR5t67/b2g8Q/S8XPi0YdbKcCs4WQ8vklNnUYLaSWF+Cbv2axZsp4jni6/j7eKvMLYCYdcsv8dcU+a6QNFg==",
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz",
+ "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==",
+ "cpu": [
+ "loong64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz",
+ "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz",
+ "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==",
"cpu": [
"ppc64"
],
@@ -1946,9 +1972,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
- "version": "4.41.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.41.0.tgz",
- "integrity": "sha512-TWrZb6GF5jsEKG7T1IHwlLMDRy2f3DPqYldmIhnA2DVqvvhY2Ai184vZGgahRrg8k9UBWoSlHv+suRfTN7Ua4A==",
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz",
+ "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==",
"cpu": [
"riscv64"
],
@@ -1959,9 +1985,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-musl": {
- "version": "4.41.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.41.0.tgz",
- "integrity": "sha512-ieQljaZKuJpmWvd8gW87ZmSFwid6AxMDk5bhONJ57U8zT77zpZ/TPKkU9HpnnFrM4zsgr4kiGuzbIbZTGi7u9A==",
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz",
+ "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==",
"cpu": [
"riscv64"
],
@@ -1972,9 +1998,9 @@
]
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
- "version": "4.41.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.41.0.tgz",
- "integrity": "sha512-/L3pW48SxrWAlVsKCN0dGLB2bi8Nv8pr5S5ocSM+S0XCn5RCVCXqi8GVtHFsOBBCSeR+u9brV2zno5+mg3S4Aw==",
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz",
+ "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==",
"cpu": [
"s390x"
],
@@ -1985,9 +2011,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
- "version": "4.41.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.41.0.tgz",
- "integrity": "sha512-XMLeKjyH8NsEDCRptf6LO8lJk23o9wvB+dJwcXMaH6ZQbbkHu2dbGIUindbMtRN6ux1xKi16iXWu6q9mu7gDhQ==",
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz",
+ "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==",
"cpu": [
"x64"
],
@@ -1998,9 +2024,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
- "version": "4.41.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.41.0.tgz",
- "integrity": "sha512-m/P7LycHZTvSQeXhFmgmdqEiTqSV80zn6xHaQ1JSqwCtD1YGtwEK515Qmy9DcB2HK4dOUVypQxvhVSy06cJPEg==",
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz",
+ "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==",
"cpu": [
"x64"
],
@@ -2010,10 +2036,36 @@
"linux"
]
},
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz",
+ "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz",
+ "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
"node_modules/@rollup/rollup-win32-arm64-msvc": {
- "version": "4.41.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.41.0.tgz",
- "integrity": "sha512-4yodtcOrFHpbomJGVEqZ8fzD4kfBeCbpsUy5Pqk4RluXOdsWdjLnjhiKy2w3qzcASWd04fp52Xz7JKarVJ5BTg==",
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz",
+ "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==",
"cpu": [
"arm64"
],
@@ -2024,9 +2076,9 @@
]
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
- "version": "4.41.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.41.0.tgz",
- "integrity": "sha512-tmazCrAsKzdkXssEc65zIE1oC6xPHwfy9d5Ta25SRCDOZS+I6RypVVShWALNuU9bxIfGA0aqrmzlzoM5wO5SPQ==",
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz",
+ "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==",
"cpu": [
"ia32"
],
@@ -2036,10 +2088,23 @@
"win32"
]
},
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz",
+ "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
"node_modules/@rollup/rollup-win32-x64-msvc": {
- "version": "4.41.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.41.0.tgz",
- "integrity": "sha512-h1J+Yzjo/X+0EAvR2kIXJDuTuyT7drc+t2ALY0nIcGPbTatNOf0VWdhEA2Z4AAjv6X1NJV7SYo5oCTYRJhSlVA==",
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz",
+ "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==",
"cpu": [
"x64"
],
@@ -2926,9 +2991,9 @@
"license": "MIT"
},
"node_modules/@types/estree": {
- "version": "1.0.7",
- "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz",
- "integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==",
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
+ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
"license": "MIT"
},
"node_modules/@types/hoist-non-react-statics": {
@@ -3186,24 +3251,37 @@
"typescript": ">=4.8.4 <5.9.0"
}
},
+ "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
+ "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
- "version": "2.0.2",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
- "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz",
+ "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "balanced-match": "^1.0.0"
+ "balanced-match": "^4.0.2"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
- "version": "9.0.5",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
- "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
+ "version": "9.0.7",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.7.tgz",
+ "integrity": "sha512-MOwgjc8tfrpn5QQEvjijjmDVtMw2oL88ugTevzxQnzRLm6l3fVEF2gzU0kYeYYKD8C66+IdGX6peJ4MyUlUnPg==",
"dev": true,
"license": "ISC",
"dependencies": {
- "brace-expansion": "^2.0.1"
+ "brace-expansion": "^5.0.2"
},
"engines": {
"node": ">=16 || 14 >=14.17"
@@ -3368,9 +3446,9 @@
}
},
"node_modules/ajv": {
- "version": "6.12.6",
- "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
- "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
+ "version": "6.14.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
+ "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.1",
@@ -6780,9 +6858,9 @@
}
},
"node_modules/minimatch": {
- "version": "3.1.2",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
- "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+ "version": "3.1.4",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.4.tgz",
+ "integrity": "sha512-twmL+S8+7yIsE9wsqgzU3E8/LumN3M3QELrBZ20OdmQ9jB2JvW5oZtBEmft84k/Gs5CG9mqtWc6Y9vW+JEzGxw==",
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
@@ -8436,12 +8514,12 @@
}
},
"node_modules/rollup": {
- "version": "4.41.0",
- "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.41.0.tgz",
- "integrity": "sha512-HqMFpUbWlf/tvcxBFNKnJyzc7Lk+XO3FGc3pbNBLqEbOz0gPLRgcrlS3UF4MfUrVlstOaP/q0kM6GVvi+LrLRg==",
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
+ "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
"license": "MIT",
"dependencies": {
- "@types/estree": "1.0.7"
+ "@types/estree": "1.0.8"
},
"bin": {
"rollup": "dist/bin/rollup"
@@ -8451,26 +8529,31 @@
"npm": ">=8.0.0"
},
"optionalDependencies": {
- "@rollup/rollup-android-arm-eabi": "4.41.0",
- "@rollup/rollup-android-arm64": "4.41.0",
- "@rollup/rollup-darwin-arm64": "4.41.0",
- "@rollup/rollup-darwin-x64": "4.41.0",
- "@rollup/rollup-freebsd-arm64": "4.41.0",
- "@rollup/rollup-freebsd-x64": "4.41.0",
- "@rollup/rollup-linux-arm-gnueabihf": "4.41.0",
- "@rollup/rollup-linux-arm-musleabihf": "4.41.0",
- "@rollup/rollup-linux-arm64-gnu": "4.41.0",
- "@rollup/rollup-linux-arm64-musl": "4.41.0",
- "@rollup/rollup-linux-loongarch64-gnu": "4.41.0",
- "@rollup/rollup-linux-powerpc64le-gnu": "4.41.0",
- "@rollup/rollup-linux-riscv64-gnu": "4.41.0",
- "@rollup/rollup-linux-riscv64-musl": "4.41.0",
- "@rollup/rollup-linux-s390x-gnu": "4.41.0",
- "@rollup/rollup-linux-x64-gnu": "4.41.0",
- "@rollup/rollup-linux-x64-musl": "4.41.0",
- "@rollup/rollup-win32-arm64-msvc": "4.41.0",
- "@rollup/rollup-win32-ia32-msvc": "4.41.0",
- "@rollup/rollup-win32-x64-msvc": "4.41.0",
+ "@rollup/rollup-android-arm-eabi": "4.59.0",
+ "@rollup/rollup-android-arm64": "4.59.0",
+ "@rollup/rollup-darwin-arm64": "4.59.0",
+ "@rollup/rollup-darwin-x64": "4.59.0",
+ "@rollup/rollup-freebsd-arm64": "4.59.0",
+ "@rollup/rollup-freebsd-x64": "4.59.0",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.59.0",
+ "@rollup/rollup-linux-arm-musleabihf": "4.59.0",
+ "@rollup/rollup-linux-arm64-gnu": "4.59.0",
+ "@rollup/rollup-linux-arm64-musl": "4.59.0",
+ "@rollup/rollup-linux-loong64-gnu": "4.59.0",
+ "@rollup/rollup-linux-loong64-musl": "4.59.0",
+ "@rollup/rollup-linux-ppc64-gnu": "4.59.0",
+ "@rollup/rollup-linux-ppc64-musl": "4.59.0",
+ "@rollup/rollup-linux-riscv64-gnu": "4.59.0",
+ "@rollup/rollup-linux-riscv64-musl": "4.59.0",
+ "@rollup/rollup-linux-s390x-gnu": "4.59.0",
+ "@rollup/rollup-linux-x64-gnu": "4.59.0",
+ "@rollup/rollup-linux-x64-musl": "4.59.0",
+ "@rollup/rollup-openbsd-x64": "4.59.0",
+ "@rollup/rollup-openharmony-arm64": "4.59.0",
+ "@rollup/rollup-win32-arm64-msvc": "4.59.0",
+ "@rollup/rollup-win32-ia32-msvc": "4.59.0",
+ "@rollup/rollup-win32-x64-gnu": "4.59.0",
+ "@rollup/rollup-win32-x64-msvc": "4.59.0",
"fsevents": "~2.3.2"
}
},
@@ -9104,9 +9187,9 @@
}
},
"node_modules/tar": {
- "version": "7.5.7",
- "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.7.tgz",
- "integrity": "sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ==",
+ "version": "7.5.9",
+ "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.9.tgz",
+ "integrity": "sha512-BTLcK0xsDh2+PUe9F6c2TlRp4zOOBMTkoQHQIWSIzI0R7KG46uEwq4OPk2W7bZcprBMsuaeFsqwYr7pjh6CuHg==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
diff --git a/ui/src/components/FileManager/FileUploader.tsx b/ui/src/components/FileManager/FileUploader.tsx
index 6bbc730..87ed1a1 100644
--- a/ui/src/components/FileManager/FileUploader.tsx
+++ b/ui/src/components/FileManager/FileUploader.tsx
@@ -20,10 +20,12 @@ export function FileUploader({
onBack,
incompleteFileName,
media,
+ accept,
}: {
onBack: () => void;
incompleteFileName?: string;
media?: string;
+ accept?: string;
})
{
const { $at }= useReactAt();
@@ -39,9 +41,25 @@ export function FileUploader({
const [send] = useJsonRpc();
const rtcDataChannelRef = useRef(null);
+ const fileInputRef = useRef(null);
const xhrRef = useRef(null);
+ const validateSelectedFile = (file: File) => {
+ if (!accept) return null;
+ const allowedExts = accept
+ .split(",")
+ .map(s => s.trim().toLowerCase())
+ .filter(s => s.startsWith("."));
+ if (allowedExts.length === 0) return null;
+ const lowerName = file.name.toLowerCase();
+ if (allowedExts.some(ext => lowerName.endsWith(ext))) return null;
+ return $at("Only {{types}} files are supported").replace(
+ "{{types}}",
+ allowedExts.join(", "),
+ );
+ };
+
useEffect(() => {
const ref = rtcDataChannelRef.current;
return () => {
@@ -262,6 +280,13 @@ export function FileUploader({
}
setFileError(null);
+ const validationError = validateSelectedFile(file);
+ if (validationError) {
+ setFileError(validationError);
+ setUploadState("idle");
+ event.target.value = "";
+ return;
+ }
console.log(`File selected: ${file.name}, size: ${file.size} bytes`);
setUploadedFileName(file.name);
setUploadedFileSize(file.size);
@@ -338,7 +363,7 @@ export function FileUploader({
{
if (uploadState === "idle") {
- document.getElementById("file-upload")?.click();
+ fileInputRef.current?.click();
}
}}
className="block select-none"
@@ -369,7 +394,7 @@ export function FileUploader({
{$at("Click here to select {{fileName}} to resume upload").replace("{{fileName}}", formatters.truncateMiddle(incompleteFileName.replace(".incomplete", ""), 30))}
)
- : $at("Click here to upload a new image")
+ : $at("Click here to upload")
}
{/**/}
@@ -435,10 +460,11 @@ export function FileUploader({
{fileError && (
{fileError}
@@ -466,4 +492,4 @@ export function FileUploader({
{isMobile&&
}
);
-}
\ No newline at end of file
+}
diff --git a/ui/src/hooks/stores.ts b/ui/src/hooks/stores.ts
index 2f67877..424f45d 100644
--- a/ui/src/hooks/stores.ts
+++ b/ui/src/hooks/stores.ts
@@ -678,6 +678,7 @@ export interface UpdateState {
appUpdatePending: boolean;
appDownloadProgress: number;
+ appDownloadSpeedBps: number;
appDownloadFinishedAt: string | null;
appVerificationProgress: number;
@@ -690,6 +691,7 @@ export interface UpdateState {
systemUpdatePending: boolean;
systemDownloadProgress: number;
+ systemDownloadSpeedBps: number;
systemDownloadFinishedAt: string | null;
systemVerificationProgress: number;
@@ -732,10 +734,12 @@ export const useUpdateStore = create(set => ({
appUpdatePending: false,
systemUpdatePending: false,
appDownloadProgress: 0,
+ appDownloadSpeedBps: 0,
appDownloadFinishedAt: null,
appVerificationProgress: 0,
appVerifiedAt: null,
systemDownloadProgress: 0,
+ systemDownloadSpeedBps: 0,
systemDownloadFinishedAt: null,
systemVerificationProgress: 0,
systemVerifiedAt: null,
@@ -1143,3 +1147,15 @@ export interface LogEntry {
message: string;
originalArgs: any[];
}
+
+export type BootStorageType = 'emmc' | 'sd' | 'unknown';
+
+interface BootStorageState {
+ bootStorageType: BootStorageType;
+ setBootStorageType: (type: BootStorageType) => void;
+}
+
+export const useBootStorageStore = create(set => ({
+ bootStorageType: 'unknown',
+ setBootStorageType: type => set({ bootStorageType: type }),
+}));
diff --git a/ui/src/hooks/useBootStorage.ts b/ui/src/hooks/useBootStorage.ts
new file mode 100644
index 0000000..dcdfd5f
--- /dev/null
+++ b/ui/src/hooks/useBootStorage.ts
@@ -0,0 +1,30 @@
+import { useEffect, useState } from "react";
+import { useJsonRpc } from "./useJsonRpc";
+import { useBootStorageStore, BootStorageType } from "./stores";
+
+export const useBootStorageType = () => {
+ const [send] = useJsonRpc();
+ const bootStorageType = useBootStorageStore(state => state.bootStorageType);
+ const setBootStorageType = useBootStorageStore(state => state.setBootStorageType);
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ if (bootStorageType !== "unknown") {
+ setLoading(false);
+ return;
+ }
+
+ send("getBootStorageType", {}, res => {
+ setLoading(false);
+ if ("error" in res) {
+ console.error("Failed to get boot storage type:", res.error);
+ setBootStorageType("unknown");
+ return;
+ }
+ const { type } = res.result as { type: BootStorageType };
+ setBootStorageType(type);
+ });
+ }, [send, bootStorageType, setBootStorageType]);
+
+ return { bootStorageType, loading };
+};
diff --git a/ui/src/layout/components_setting/access/AccessContent.tsx b/ui/src/layout/components_setting/access/AccessContent.tsx
index e1013d6..aa946f0 100644
--- a/ui/src/layout/components_setting/access/AccessContent.tsx
+++ b/ui/src/layout/components_setting/access/AccessContent.tsx
@@ -22,6 +22,7 @@ import { useVpnStore, useLocalAuthModalStore } from "@/hooks/stores";
import { LogDialog } from "@components/LogDialog";
import { Dialog } from "@/layout/components_setting/access/auth";
import AutoHeight from "@components/AutoHeight";
+import FirewallSettings from "./FirewallSettings";
export interface TailScaleResponse {
state: string;
@@ -988,6 +989,9 @@ function AccessContent({ setOpenDialog }: { setOpenDialog: (open: boolean) => vo
)}
+
+
+
>
diff --git a/ui/src/layout/components_setting/access/FirewallSettings.tsx b/ui/src/layout/components_setting/access/FirewallSettings.tsx
new file mode 100644
index 0000000..e502d0c
--- /dev/null
+++ b/ui/src/layout/components_setting/access/FirewallSettings.tsx
@@ -0,0 +1,940 @@
+import { useCallback, useEffect, useMemo, useState } from "react";
+import { Button as AntdButton, Checkbox, Input, Modal, Select } from "antd";
+import { useReactAt } from "i18n-auto-extractor/react";
+import { SettingsSectionHeader } from "@components/Settings/SettingsSectionHeader";
+import { isMobile } from "react-device-detect";
+import { SettingsItem } from "@components/Settings/SettingsView";
+import { useJsonRpc } from "@/hooks/useJsonRpc";
+import notifications from "@/notifications";
+import AutoHeight from "@components/AutoHeight";
+import { GridCard } from "@components/Card";
+import { ConfirmDialog } from "@components/ConfirmDialog";
+
+type FirewallChain = "input" | "output" | "forward";
+type FirewallAction = "accept" | "drop" | "reject";
+
+export interface FirewallConfig {
+ base: {
+ inputPolicy: FirewallAction;
+ outputPolicy: FirewallAction;
+ forwardPolicy: FirewallAction;
+ };
+ rules: FirewallRule[];
+ portForwards: FirewallPortRule[];
+}
+
+export interface FirewallRule {
+ chain: FirewallChain;
+ sourceIP: string;
+ sourcePort?: number | null;
+ protocols: string[];
+ destinationIP: string;
+ destinationPort?: number | null;
+ action: FirewallAction;
+ comment: string;
+}
+
+export interface FirewallPortRule {
+ chain?: "output" | "prerouting" | "prerouting_redirect";
+ managed?: boolean;
+ sourcePort: number;
+ protocols: string[];
+ destinationIP: string;
+ destinationPort: number;
+ comment: string;
+}
+
+const defaultFirewallConfig: FirewallConfig = {
+ base: { inputPolicy: "accept", outputPolicy: "accept", forwardPolicy: "accept" },
+ rules: [],
+ portForwards: [],
+};
+
+const actionOptions: { value: FirewallAction; label: string }[] = [
+ { value: "accept", label: "Accept" },
+ { value: "drop", label: "Drop" },
+ { value: "reject", label: "Reject" },
+];
+
+const chainOptions: { value: FirewallChain; label: string }[] = [
+ { value: "input", label: "Input" },
+ { value: "output", label: "Output" },
+ { value: "forward", label: "Forward" },
+];
+
+const commProtocolOptions = [
+ { key: "any", label: "Any" },
+ { key: "tcp", label: "TCP" },
+ { key: "udp", label: "UDP" },
+ { key: "icmp", label: "ICMP" },
+ { key: "igmp", label: "IGMP" },
+];
+
+const portForwardProtocolOptions = [
+ { key: "tcp", label: "TCP" },
+ { key: "udp", label: "UDP" },
+ { key: "sctp", label: "SCTP" },
+ { key: "dccp", label: "DCCP" },
+];
+
+function formatProtocols(protocols: string[]) {
+ if (!protocols?.length) return "-";
+ if (protocols.includes("any")) return "Any";
+ return protocols.map(p => p.toUpperCase()).join(", ");
+}
+
+function actionLabel(action: FirewallAction) {
+ return actionOptions.find(o => o.value === action)?.label ?? action;
+}
+
+function chainLabel(chain: FirewallChain) {
+ return chainOptions.find(o => o.value === chain)?.label ?? chain;
+}
+
+function portForwardChainLabel(chain: FirewallPortRule["chain"]) {
+ switch (chain ?? "prerouting") {
+ case "output":
+ return "OUTPUT";
+ case "prerouting_redirect":
+ return "PREROUTING_REDIRECT";
+ default:
+ return "PREROUTING";
+ }
+}
+
+function formatEndpoint(ip: string, port: number | null | undefined, anyText: string) {
+ const t = (ip || "").trim();
+ if (!t && (port === null || port === undefined)) return anyText;
+ if (!t && port !== null && port !== undefined) return `${anyText}:${port}`;
+ if (t && (port === null || port === undefined)) return t;
+ return `${t}:${port}`;
+}
+
+function normalizePort(v: string) {
+ const t = v.trim();
+ if (t === "") return null;
+ const n = Number(t);
+ if (!Number.isFinite(n)) return null;
+ if (n < 1 || n > 65535) return null;
+ return Math.trunc(n);
+}
+
+function normalizeRuleProtocols(list: string[]) {
+ if (list.includes("any")) return ["any"];
+ return list;
+}
+
+export default function FirewallSettings() {
+ const { $at } = useReactAt();
+ const [send] = useJsonRpc();
+
+ const [activeTab, setActiveTab] = useState<"base" | "rules" | "portForwards">(
+ "base",
+ );
+ const [appliedConfig, setAppliedConfig] = useState(defaultFirewallConfig);
+ const [baseDraft, setBaseDraft] = useState(
+ defaultFirewallConfig.base,
+ );
+ const [loading, setLoading] = useState(false);
+ const [applying, setApplying] = useState(false);
+ const [showBaseSubmitConfirm, setShowBaseSubmitConfirm] = useState(false);
+
+ const [selectedRuleRows, setSelectedRuleRows] = useState>(new Set());
+ const [selectedPortForwardRows, setSelectedPortForwardRows] = useState>(
+ new Set(),
+ );
+
+ const [ruleModalOpen, setRuleModalOpen] = useState(false);
+ const [ruleEditingIndex, setRuleEditingIndex] = useState(null);
+ const [ruleDraft, setRuleDraft] = useState({
+ chain: "input",
+ sourceIP: "",
+ sourcePort: null,
+ protocols: ["any"],
+ destinationIP: "",
+ destinationPort: null,
+ action: "accept",
+ comment: "",
+ });
+ const [ruleSourcePortText, setRuleSourcePortText] = useState("");
+ const [ruleDestinationPortText, setRuleDestinationPortText] = useState("");
+
+ const [pfModalOpen, setPfModalOpen] = useState(false);
+ const [pfEditingIndex, setPfEditingIndex] = useState(null);
+ const [pfDraft, setPfDraft] = useState({
+ chain: "prerouting",
+ sourcePort: 1,
+ protocols: ["tcp"],
+ destinationIP: "",
+ destinationPort: 1,
+ comment: "",
+ });
+ const [pfSourcePortText, setPfSourcePortText] = useState("1");
+ const [pfDestinationPortText, setPfDestinationPortText] = useState("1");
+
+ const fetchConfig = useCallback(() => {
+ setLoading(true);
+ send("getFirewallConfig", {}, resp => {
+ setLoading(false);
+ if ("error" in resp) {
+ notifications.error(
+ `${$at("Failed to get firewall config")}: ${resp.error.data || resp.error.message}`,
+ );
+ return;
+ }
+ const cfg = resp.result as FirewallConfig;
+ setAppliedConfig(cfg);
+ setBaseDraft(cfg.base);
+ setSelectedRuleRows(new Set());
+ setSelectedPortForwardRows(new Set());
+ });
+ }, [send, $at]);
+
+ useEffect(() => {
+ fetchConfig();
+ }, [fetchConfig]);
+
+ const hasBaseChanges = useMemo(() => {
+ return JSON.stringify(appliedConfig.base) !== JSON.stringify(baseDraft);
+ }, [appliedConfig.base, baseDraft]);
+
+ const applyFirewallConfig = useCallback(
+ (
+ nextConfig: FirewallConfig,
+ opts?: { onSuccess?: () => void; successText?: string },
+ ) => {
+ setApplying(true);
+ send("setFirewallConfig", { config: nextConfig }, resp => {
+ setApplying(false);
+ if ("error" in resp) {
+ notifications.error(
+ `${$at("Failed to apply firewall config")}: ${resp.error.data || resp.error.message}`,
+ );
+ return;
+ }
+ setAppliedConfig(nextConfig);
+ if (opts?.successText) notifications.success(opts.successText);
+ if (opts?.onSuccess) opts.onSuccess();
+ });
+ },
+ [send, $at],
+ );
+
+ const handleBaseSubmit = useCallback(() => {
+ const nextConfig: FirewallConfig = { ...appliedConfig, base: baseDraft };
+ applyFirewallConfig(nextConfig, {
+ successText: $at("Firewall config applied"),
+ onSuccess: () => {
+ setShowBaseSubmitConfirm(false);
+ },
+ });
+ }, [appliedConfig, baseDraft, applyFirewallConfig, $at]);
+
+ const requestBaseSubmit = useCallback(() => {
+ if (!hasBaseChanges) return;
+ setShowBaseSubmitConfirm(true);
+ }, [hasBaseChanges]);
+
+ const openAddRule = () => {
+ setRuleEditingIndex(null);
+ setRuleDraft({
+ chain: "input",
+ sourceIP: "",
+ sourcePort: null,
+ protocols: ["any"],
+ destinationIP: "",
+ destinationPort: null,
+ action: "accept",
+ comment: "",
+ });
+ setRuleSourcePortText("");
+ setRuleDestinationPortText("");
+ setRuleModalOpen(true);
+ };
+
+ const openEditRule = (idx: number) => {
+ const current = appliedConfig.rules[idx];
+ if (!current) return;
+ setRuleEditingIndex(idx);
+ setRuleDraft({ ...current });
+ setRuleSourcePortText(current.sourcePort ? String(current.sourcePort) : "");
+ setRuleDestinationPortText(current.destinationPort ? String(current.destinationPort) : "");
+ setRuleModalOpen(true);
+ };
+
+ const saveRuleDraft = () => {
+ const next: FirewallRule = {
+ ...ruleDraft,
+ protocols: normalizeRuleProtocols(ruleDraft.protocols),
+ sourcePort: normalizePort(ruleSourcePortText),
+ destinationPort: normalizePort(ruleDestinationPortText),
+ sourceIP: ruleDraft.sourceIP.trim(),
+ destinationIP: ruleDraft.destinationIP.trim(),
+ comment: ruleDraft.comment.trim(),
+ };
+
+ if (!next.protocols.length) {
+ notifications.error($at("Please select protocol"));
+ return;
+ }
+
+ if (!next.chain || !next.action) {
+ notifications.error($at("Missing required fields"));
+ return;
+ }
+
+ const rules = [...appliedConfig.rules];
+ if (ruleEditingIndex === null) {
+ rules.push(next);
+ } else {
+ rules[ruleEditingIndex] = next;
+ }
+ const nextConfig: FirewallConfig = {
+ ...appliedConfig,
+ rules,
+ };
+ applyFirewallConfig(nextConfig, {
+ successText: $at("Firewall config applied"),
+ onSuccess: () => {
+ setRuleModalOpen(false);
+ setSelectedRuleRows(new Set());
+ },
+ });
+ };
+
+ const deleteSelectedRules = () => {
+ const idxs = [...selectedRuleRows.values()].sort((a, b) => a - b);
+ if (!idxs.length) return;
+ const nextRules = appliedConfig.rules.filter((_, i) => !selectedRuleRows.has(i));
+ const nextConfig: FirewallConfig = {
+ ...appliedConfig,
+ rules: nextRules,
+ };
+ applyFirewallConfig(nextConfig, {
+ successText: $at("Firewall config applied"),
+ onSuccess: () => {
+ setSelectedRuleRows(new Set());
+ },
+ });
+ };
+
+ const openAddPortForward = () => {
+ setPfEditingIndex(null);
+ setPfDraft({
+ chain: "prerouting",
+ sourcePort: 1,
+ protocols: ["tcp"],
+ destinationIP: "",
+ destinationPort: 1,
+ comment: "",
+ });
+ setPfSourcePortText("1");
+ setPfDestinationPortText("1");
+ setPfModalOpen(true);
+ };
+
+ const openEditPortForward = (idx: number) => {
+ const current = appliedConfig.portForwards[idx];
+ if (!current) return;
+ if (current.managed === false) return;
+ setPfEditingIndex(idx);
+ const inferredChain =
+ current.chain ??
+ (current.destinationIP?.trim() === "0.0.0.0" || current.destinationIP?.trim() === "127.0.0.1"
+ ? "output"
+ : "prerouting");
+ setPfDraft({
+ ...current,
+ chain: inferredChain,
+ destinationIP:
+ inferredChain === "output" || inferredChain === "prerouting_redirect"
+ ? "0.0.0.0"
+ : current.destinationIP?.trim() === "0.0.0.0" || current.destinationIP?.trim() === "127.0.0.1"
+ ? ""
+ : current.destinationIP,
+ });
+ setPfSourcePortText(String(current.sourcePort));
+ setPfDestinationPortText(String(current.destinationPort));
+ setPfModalOpen(true);
+ };
+
+ const savePortForwardDraft = () => {
+ const srcPort = normalizePort(pfSourcePortText);
+ const dstPort = normalizePort(pfDestinationPortText);
+ if (!srcPort || !dstPort) {
+ notifications.error($at("Invalid port"));
+ return;
+ }
+ const next: FirewallPortRule = {
+ ...pfDraft,
+ sourcePort: srcPort,
+ destinationPort: dstPort,
+ destinationIP:
+ (pfDraft.chain ?? "prerouting") === "output" ||
+ (pfDraft.chain ?? "prerouting") === "prerouting_redirect"
+ ? "0.0.0.0"
+ : pfDraft.destinationIP.trim(),
+ protocols: normalizeRuleProtocols(pfDraft.protocols).filter(p => p !== "any"),
+ comment: pfDraft.comment.trim(),
+ };
+ const pfChain = next.chain ?? "prerouting";
+ if (pfChain === "prerouting" && ["0.0.0.0", "127.0.0.1"].includes(next.destinationIP.trim())) {
+ notifications.error($at("For PREROUTING, Destination IP must be a real host IP"));
+ return;
+ }
+
+ if (pfChain === "prerouting" && !next.destinationIP) {
+ notifications.error($at("Destination IP is required"));
+ return;
+ }
+ if (!next.protocols.length) {
+ notifications.error($at("Please select protocol"));
+ return;
+ }
+
+ const items = [...appliedConfig.portForwards];
+ if (pfEditingIndex === null) {
+ items.push(next);
+ } else {
+ items[pfEditingIndex] = next;
+ }
+ const nextConfig: FirewallConfig = {
+ ...appliedConfig,
+ portForwards: items,
+ };
+ applyFirewallConfig(nextConfig, {
+ successText: $at("Firewall config applied"),
+ onSuccess: () => {
+ setPfModalOpen(false);
+ setSelectedPortForwardRows(new Set());
+ },
+ });
+ };
+
+ const deleteSelectedPortForwards = () => {
+ const idxs = [...selectedPortForwardRows.values()].sort((a, b) => a - b);
+ if (!idxs.length) return;
+ const nextItems = appliedConfig.portForwards.filter((r, i) => {
+ if (!selectedPortForwardRows.has(i)) return true;
+ return r.managed === false;
+ });
+ const nextConfig: FirewallConfig = {
+ ...appliedConfig,
+ portForwards: nextItems,
+ };
+ applyFirewallConfig(nextConfig, {
+ successText: $at("Firewall config applied"),
+ onSuccess: () => {
+ setSelectedPortForwardRows(new Set());
+ },
+ });
+ };
+
+ return (
+
+
+
+
+
+ {[
+ { id: "base", label: $at("Basic") },
+ { id: "rules", label: $at("Communication Rules") },
+ { id: "portForwards", label: $at("Port Forwarding") },
+ ].map(tab => (
+ setActiveTab(tab.id as typeof activeTab)}
+ className={`
+ flex-1 min-w-[120px] px-6 py-3 text-sm font-medium transition-all duration-200 border-y border-r first:border-l first:rounded-l-lg last:rounded-r-lg flex items-center justify-center gap-2
+ ${
+ activeTab === tab.id
+ ? "!bg-[rgba(22,152,217,1)] dark:!bg-[rgba(45,106,229,1))] !text-white border-[rgba(22,152,217,1)] dark:border-[rgba(45,106,229,1)]"
+ : "bg-transparent text-slate-600 dark:text-slate-400 border-slate-200 dark:border-slate-700 hover:border-[rgba(22,152,217,1)] dark:hover:border-[rgba(45,106,229,1)] hover:text-[rgba(22,152,217,1)] dark:hover:text-[rgba(45,106,229,1)]"
+ }
+ `}
+ >
+ {tab.label}
+
+ ))}
+
+
+
+
+ {activeTab === "base" && (
+
+
+
+
+
+
+ {$at("Input")}
+
+
+
+ setBaseDraft({
+ ...baseDraft,
+ inputPolicy: v as FirewallAction,
+ })
+ }
+ options={actionOptions}
+ />
+
+
+ {$at("Output")}
+
+
+
+ setBaseDraft({
+ ...baseDraft,
+ outputPolicy: v as FirewallAction,
+ })
+ }
+ options={actionOptions}
+ />
+
+
+ {$at("Forward")}
+
+
+
+ setBaseDraft({
+ ...baseDraft,
+ forwardPolicy: v as FirewallAction,
+ })
+ }
+ options={actionOptions}
+ />
+
+
+
+
+ {$at("Refresh")}
+
+
+ {$at("Submit")}
+
+
+
+
+
+
+ )}
+
+ {activeTab === "rules" && (
+
+
+
+
+
+
+
+ {$at("Add")}
+
+
+ {$at("Delete")}
+
+
+
+
+
+
+
+
+
+ {$at("Chain")}
+ {$at("Source")}
+ {$at("Protocol")}
+ {$at("Destination")}
+ {$at("Action")}
+ {$at("Description")}
+ {$at("Operation")}
+
+
+
+ {appliedConfig.rules.length === 0 ? (
+
+
+ {$at("No rules available")}
+
+
+ ) : (
+ appliedConfig.rules.map((r, idx) => (
+
+
+ {
+ const next = new Set(selectedRuleRows);
+ if (e.target.checked) next.add(idx);
+ else next.delete(idx);
+ setSelectedRuleRows(next);
+ }}
+ />
+
+ {chainLabel(r.chain)}
+
+ {formatEndpoint(r.sourceIP, r.sourcePort, $at("Any"))}
+
+ {formatProtocols(r.protocols)}
+
+ {formatEndpoint(r.destinationIP, r.destinationPort, $at("Any"))}
+
+ {actionLabel(r.action)}
+ {r.comment || "-"}
+
+ openEditRule(idx)}>
+ {$at("Edit")}
+
+
+
+ ))
+ )}
+
+
+
+
+
+
+
+ )}
+
+ {activeTab === "portForwards" && (
+
+
+
+
+
+
+
+ {$at("Add")}
+
+
+ {$at("Delete")}
+
+
+
+
+
+
+
+
+
+ {$at("Chain")}
+ {$at("Source")}
+ {$at("Protocol")}
+ {$at("Destination")}
+ {$at("Description")}
+ {$at("Operation")}
+
+
+
+ {appliedConfig.portForwards.length === 0 ? (
+
+
+ {$at("No data available")}
+
+
+ ) : (
+ appliedConfig.portForwards.map((r, idx) => (
+
+
+ {
+ const next = new Set(selectedPortForwardRows);
+ if (e.target.checked) next.add(idx);
+ else next.delete(idx);
+ setSelectedPortForwardRows(next);
+ }}
+ />
+
+ {portForwardChainLabel(r.chain)}
+
+ {formatEndpoint("", r.sourcePort, $at("Any"))}
+
+ {formatProtocols(r.protocols)}
+
+ {formatEndpoint(r.destinationIP, r.destinationPort, $at("Any"))}
+
+ {r.comment || "-"}
+
+ openEditPortForward(idx)}>
+ {$at("Edit")}
+
+
+
+ ))
+ )}
+
+
+
+
+
+
+
+ )}
+
+
+
setRuleModalOpen(false)}
+ onOk={saveRuleDraft}
+ confirmLoading={applying}
+ title={$at(ruleEditingIndex === null ? "Add Rule" : "Edit Rule")}
+ okText={$at("OK")}
+ cancelText={$at("Cancel")}
+ destroyOnClose
+ >
+
+
+
{$at("Chain")}
+
setRuleDraft({ ...ruleDraft, chain: v as FirewallChain })}
+ options={chainOptions}
+ />
+
+
+
+
{$at("Source IP")}
+
setRuleDraft({ ...ruleDraft, sourceIP: e.target.value })}
+ />
+
+
+
+
{$at("Source Port")}
+
setRuleSourcePortText(e.target.value)}
+ inputMode="numeric"
+ />
+
+
+
+
{$at("Protocol")}
+
+ {commProtocolOptions.map(p => (
+ {
+ const checked = e.target.checked;
+ const next = new Set(ruleDraft.protocols);
+ if (checked) next.add(p.key);
+ else next.delete(p.key);
+ const arr = [...next.values()];
+ setRuleDraft({ ...ruleDraft, protocols: normalizeRuleProtocols(arr) });
+ }}
+ >
+ {p.label}
+
+ ))}
+
+
+
+
+
{$at("Destination IP")}
+
setRuleDraft({ ...ruleDraft, destinationIP: e.target.value })}
+ />
+
+
+
+
{$at("Destination Port")}
+
setRuleDestinationPortText(e.target.value)}
+ inputMode="numeric"
+ />
+
+
+
+
{$at("Action")}
+
setRuleDraft({ ...ruleDraft, action: v as FirewallAction })}
+ options={actionOptions}
+ />
+
+
+
+
{$at("Description")}
+
setRuleDraft({ ...ruleDraft, comment: e.target.value })}
+ />
+
+
+
+
+
setPfModalOpen(false)}
+ onOk={savePortForwardDraft}
+ confirmLoading={applying}
+ title={$at(pfEditingIndex === null ? "Add Rule" : "Edit Rule")}
+ okText={$at("OK")}
+ cancelText={$at("Cancel")}
+ destroyOnClose
+ >
+
+
+
+ * {$at("Chain")}
+
+
{
+ const c = v as "output" | "prerouting" | "prerouting_redirect";
+ const curDst = pfDraft.destinationIP.trim();
+ const forceLocal = c === "output" || c === "prerouting_redirect";
+ setPfDraft({
+ ...pfDraft,
+ chain: c,
+ destinationIP: forceLocal
+ ? "0.0.0.0"
+ : curDst === "0.0.0.0" || curDst === "127.0.0.1"
+ ? ""
+ : pfDraft.destinationIP,
+ });
+ }}
+ options={[
+ { value: "prerouting", label: "PREROUTING" },
+ { value: "prerouting_redirect", label: "PREROUTING_REDIRECT" },
+ { value: "output", label: "OUTPUT" },
+ ]}
+ />
+
+
+
+
+ * {$at("Source Port")}
+
+
setPfSourcePortText(e.target.value)}
+ inputMode="numeric"
+ />
+
+
+
+
{$at("Protocol")}
+
+ {portForwardProtocolOptions.map(p => (
+ {
+ const checked = e.target.checked;
+ const next = new Set(pfDraft.protocols);
+ if (checked) next.add(p.key);
+ else next.delete(p.key);
+ setPfDraft({ ...pfDraft, protocols: [...next.values()] });
+ }}
+ >
+ {p.label}
+
+ ))}
+
+
+
+
+
+ * {$at("Destination IP")}
+
+
setPfDraft({ ...pfDraft, destinationIP: e.target.value })}
+ disabled={(pfDraft.chain ?? "prerouting") !== "prerouting"}
+ />
+
+
+
+
+ * {$at("Destination Port")}
+
+
setPfDestinationPortText(e.target.value)}
+ inputMode="numeric"
+ />
+
+
+
+
{$at("Description")}
+
setPfDraft({ ...pfDraft, comment: e.target.value })}
+ />
+
+
+
+
+
{
+ setShowBaseSubmitConfirm(false);
+ }}
+ title={$at("Submit Firewall Policies?")}
+ description={
+ <>
+
+ {$at(
+ "Warning: Adjusting some policies may cause network address loss, leading to device unavailability.",
+ )}
+
+ >
+ }
+ variant="warning"
+ cancelText={$at("Cancel")}
+ confirmText={$at("Submit")}
+ onConfirm={handleBaseSubmit}
+ />
+
+ );
+}
diff --git a/ui/src/layout/components_setting/advanced/AdvancedContent.tsx b/ui/src/layout/components_setting/advanced/AdvancedContent.tsx
index b1e2fa7..31c9724 100644
--- a/ui/src/layout/components_setting/advanced/AdvancedContent.tsx
+++ b/ui/src/layout/components_setting/advanced/AdvancedContent.tsx
@@ -19,8 +19,14 @@ export default function SettingsAdvanced() {
const [sshKey, setSSHKey] = useState("");
const setDeveloperMode = useSettingsStore(state => state.setDeveloperMode);
const [usbEmulationEnabled, setUsbEmulationEnabled] = useState(false);
+ const [usbEnhancedDetectionEnabled, setUsbEnhancedDetectionEnabled] =
+ useState(true);
const [showLoopbackWarning, setShowLoopbackWarning] = useState(false);
const [showRebootConfirm, setShowRebootConfirm] = useState(false);
+ const [showConfigEdit, setShowConfigEdit] = useState(false);
+ const [showConfigSavedReboot, setShowConfigSavedReboot] = useState(false);
+ const [configContent, setConfigContent] = useState("");
+ const [isSavingConfig, setIsSavingConfig] = useState(false);
const [localLoopbackOnly, setLocalLoopbackOnly] = useState(false);
const settings = useSettingsStore();
@@ -38,6 +44,11 @@ export default function SettingsAdvanced() {
setUsbEmulationEnabled(resp.result as boolean);
});
+ send("getUsbEnhancedDetection", {}, resp => {
+ if ("error" in resp) return;
+ setUsbEnhancedDetectionEnabled(resp.result as boolean);
+ });
+
send("getLocalLoopbackOnly", {}, resp => {
if ("error" in resp) return;
setLocalLoopbackOnly(resp.result as boolean);
@@ -67,6 +78,24 @@ export default function SettingsAdvanced() {
[getUsbEmulationState, send],
);
+ const handleUsbEnhancedDetectionToggle = useCallback(
+ (enabled: boolean) => {
+ send("setUsbEnhancedDetection", { enabled }, resp => {
+ if ("error" in resp) {
+ notifications.error(
+ `Failed to ${enabled ? "enable" : "disable"} USB enhanced detection: ${resp.error.data || "Unknown error"}`,
+ );
+ return;
+ }
+ setUsbEnhancedDetectionEnabled(enabled);
+ notifications.success(
+ enabled ? "USB enhanced detection enabled" : "USB enhanced detection disabled",
+ );
+ });
+ },
+ [send],
+ );
+
const handleResetConfig = useCallback(() => {
send("resetConfig", {}, resp => {
if ("error" in resp) {
@@ -133,6 +162,35 @@ export default function SettingsAdvanced() {
setShowLoopbackWarning(false);
}, [applyLoopbackOnlyMode, setShowLoopbackWarning]);
+ const handleOpenConfigEditor = useCallback(() => {
+ send("getConfigRaw", {}, resp => {
+ if ("error" in resp) {
+ notifications.error(
+ `Failed to load configuration: ${resp.error.data || "Unknown error"}`,
+ );
+ return;
+ }
+ setConfigContent(resp.result as string);
+ setShowConfigEdit(true);
+ });
+ }, [send]);
+
+ const handleSaveConfig = useCallback(() => {
+ setIsSavingConfig(true);
+ send("setConfigRaw", { configStr: configContent }, resp => {
+ setIsSavingConfig(false);
+ if ("error" in resp) {
+ notifications.error(
+ `Failed to save configuration: ${resp.error.data || "Unknown error"}`,
+ );
+ return;
+ }
+ notifications.success("Configuration saved successfully");
+ setShowConfigEdit(false);
+ setShowConfigSavedReboot(true);
+ });
+ }, [send, configContent]);
+
return (
+
+ handleUsbEnhancedDetectionToggle(e.target.checked)}
+ />
+
+
-
+
+
+ {$at("Edit")}
+
+
-
{
@@ -333,6 +411,61 @@ export default function SettingsAdvanced() {
});
}}
/>
+
+ setShowConfigEdit(false)}
+ title={$at("Edit Configuration")}
+ description={
+
+
+ {$at("Edit the raw configuration JSON. Be careful when making changes as invalid JSON can cause system issues.")}
+
+
+ }
+ variant="info"
+ cancelText={$at("Cancel")}
+ confirmText={isSavingConfig ? `${$at("Saving")}...` : $at("Save")}
+ onConfirm={handleSaveConfig}
+ isConfirming={isSavingConfig}
+ />
+
+ setShowConfigSavedReboot(false)}
+ title={$at("Configuration Saved")}
+ description={
+ <>
+
+ {$at("Configuration has been saved successfully. Some changes may require a system restart to take effect.")}
+
+
+ {$at("Would you like to restart the system now?")}
+
+ >
+ }
+ variant="info"
+ cancelText={$at("Later")}
+ confirmText={$at("Restart Now")}
+ onConfirm={() => {
+ setShowConfigSavedReboot(false);
+ send("reboot", { force: false }, resp => {
+ if ("error" in resp) {
+ notifications.error(
+ `Failed to reboot: ${resp.error.data || "Unknown error"}`,
+ );
+ return;
+ }
+ notifications.success("System rebooting...");
+ });
+ }}
+ />
);
}
diff --git a/ui/src/layout/components_setting/network/NetworkContent.tsx b/ui/src/layout/components_setting/network/NetworkContent.tsx
index e3d1be5..61fa08d 100644
--- a/ui/src/layout/components_setting/network/NetworkContent.tsx
+++ b/ui/src/layout/components_setting/network/NetworkContent.tsx
@@ -85,6 +85,10 @@ export default function SettingsNetwork() {
const [networkSettings, setNetworkSettings] =
useState(defaultNetworkSettings);
+ const [macAddressInput, setMacAddressInput] = useState("");
+ const macAddressTouched = useRef(false);
+ const initialMacAddress = useRef("");
+
// We use this to determine whether the settings have changed
const firstNetworkSettings = useRef(undefined);
// We use this to indicate whether saved settings differ from initial (effective) settings
@@ -165,8 +169,37 @@ export default function SettingsNetwork() {
});
}, [send, setNetworkState]);
+ const normalizeMacAddress = useCallback((value: string) => {
+ return value.trim().toLowerCase();
+ }, []);
+
+ const isValidMacAddress = useCallback((value: string) => {
+ const v = normalizeMacAddress(value);
+ return /^([0-9a-f]{2}:){5}[0-9a-f]{2}$/.test(v);
+ }, [normalizeMacAddress]);
+
const setNetworkSettingsRemote = useCallback(
(settings: NetworkSettings) => {
+ const currentMac = (networkState?.mac_address || "").toLowerCase();
+ const newMac = normalizeMacAddress(macAddressInput);
+ const macChanged = newMac !== currentMac;
+
+ if (macChanged) {
+ if (!isValidMacAddress(macAddressInput)) {
+ notifications.error("Please enter a valid MAC address");
+ return;
+ }
+ setPendingMacAddress(newMac);
+ setShowMacChangeConfirm(true);
+ } else {
+ saveNetworkSettings(settings);
+ }
+ },
+ [networkState?.mac_address, macAddressInput, isValidMacAddress, normalizeMacAddress],
+ );
+
+ const saveNetworkSettings = useCallback(
+ (settings: NetworkSettings, onSaved?: () => void) => {
setNetworkSettingsLoaded(false);
send("setNetworkSettings", { settings }, resp => {
if ("error" in resp) {
@@ -177,12 +210,13 @@ export default function SettingsNetwork() {
setNetworkSettingsLoaded(true);
return;
}
- // We need to update the firstNetworkSettings ref to the new settings so we can use it to determine if the settings have changed
firstNetworkSettings.current = resp.result as NetworkSettings;
setNetworkSettings(resp.result as NetworkSettings);
+ macAddressTouched.current = false;
getNetworkState();
setNetworkSettingsLoaded(true);
notifications.success("Network settings saved");
+ onSaved?.();
});
},
[getNetworkState, send],
@@ -203,6 +237,14 @@ export default function SettingsNetwork() {
getNetworkSettings();
}, [getNetworkState, getNetworkSettings]);
+ useEffect(() => {
+ if (networkState?.mac_address && initialMacAddress.current === "") {
+ const normalized = networkState.mac_address.toLowerCase();
+ setMacAddressInput(normalized);
+ initialMacAddress.current = normalized;
+ }
+ }, [networkState?.mac_address]);
+
const handleIpv4ModeChange = (value: IPv4Mode | string) => {
const newMode = value as IPv4Mode;
const updatedSettings: NetworkSettings = { ...networkSettings, ipv4_mode: newMode };
@@ -290,6 +332,8 @@ export default function SettingsNetwork() {
const [showRequestAddrConfirm, setShowRequestAddrConfirm] = useState(false);
const [showApplyStaticConfirm, setShowApplyStaticConfirm] = useState(false);
const [showIpv4RestartConfirm, setShowIpv4RestartConfirm] = useState(false);
+ const [showMacChangeConfirm, setShowMacChangeConfirm] = useState(false);
+ const [pendingMacAddress, setPendingMacAddress] = useState("");
const [pendingIpv4Mode, setPendingIpv4Mode] = useState(null);
const [ipv4StaticDnsText, setIpv4StaticDnsText] = useState("");
@@ -336,19 +380,13 @@ export default function SettingsNetwork() {
>
{
+ macAddressTouched.current = true;
+ setMacAddressInput(e.target.value);
+ }}
className={isMobile ? "!w-full !h-[36px]" : "!w-[35%] !h-[36px]"}
/>
- {/* */}
-
@@ -502,8 +540,9 @@ export default function SettingsNetwork() {
setNetworkSettingsRemote(networkSettings)}
className={isMobile ? "w-full" : ""}
@@ -797,6 +836,31 @@ export default function SettingsNetwork() {
}
}}
/>
+ setShowMacChangeConfirm(false)}
+ title={$at("Change MAC Address?")}
+ description={$at("Changing the MAC address may cause the device IP to be reassigned and changed.")}
+ variant="warning"
+ confirmText={$at("Confirm")}
+ cancelText={$at("Cancel")}
+ onConfirm={() => {
+ setShowMacChangeConfirm(false);
+ send("setEthernetMacAddress", { macAddress: pendingMacAddress }, resp => {
+ if ("error" in resp) {
+ notifications.error(
+ "Failed to apply MAC address: " +
+ (resp.error.data ? resp.error.data : resp.error.message),
+ );
+ return;
+ }
+ setNetworkState(resp.result as NetworkState);
+ saveNetworkSettings(networkSettings, () => {
+ initialMacAddress.current = pendingMacAddress;
+ });
+ });
+ }}
+ />
>
);
}
diff --git a/ui/src/layout/components_setting/version/VersionContent.tsx b/ui/src/layout/components_setting/version/VersionContent.tsx
index 211ac04..f4291a9 100644
--- a/ui/src/layout/components_setting/version/VersionContent.tsx
+++ b/ui/src/layout/components_setting/version/VersionContent.tsx
@@ -6,6 +6,7 @@ import { CheckCircleIcon } from "@heroicons/react/20/solid";
import { isMobile } from "react-device-detect";
import { useJsonRpc } from "@/hooks/useJsonRpc";
+import { useBootStorageType } from "@/hooks/useBootStorage";
import { SettingsPageHeader } from "@components/Settings/SettingsPageheader";
import { SettingsItem } from "@components/Settings/SettingsView";
import Card from "@components/Card";
@@ -14,6 +15,7 @@ import { Button } from "@components/Button";
import { InputFieldWithLabel } from "@components/InputField";
import { UpdateState, useDeviceStore, useUpdateStore } from "@/hooks/stores";
import notifications from "@/notifications";
+import { formatters } from "@/utils";
export interface SystemVersionInfo {
local: { appVersion: string; systemVersion: string };
@@ -33,10 +35,13 @@ export default function SettingsVersion() {
const [autoUpdate, setAutoUpdate] = useState(true);
const { $at } = useReactAt();
const { setModalView, otaState } = useUpdateStore();
+ const { bootStorageType } = useBootStorageType();
+ const isBootFromSD = bootStorageType === "sd";
const [isUpdateDialogOpen, setIsUpdateDialogOpen] = useState(false);
const updatePanelRef = useRef(null);
const [updateSource, setUpdateSource] = useState("github");
const [customUpdateBaseURL, setCustomUpdateBaseURL] = useState("");
+ const [updateDownloadProxy, setUpdateDownloadProxy] = useState("");
const currentVersions = useDeviceStore(state => {
const { appVersion, systemVersion } = state;
@@ -58,6 +63,13 @@ export default function SettingsVersion() {
});
}, [send]);
+ useEffect(() => {
+ send("getUpdateDownloadProxy", {}, resp => {
+ if ("error" in resp) return;
+ setUpdateDownloadProxy(resp.result as string);
+ });
+ }, [send]);
+
const handleAutoUpdateChange = (enabled: boolean) => {
send("setAutoUpdateState", { enabled }, resp => {
if ("error" in resp) {
@@ -96,6 +108,18 @@ export default function SettingsVersion() {
});
}, [customUpdateBaseURL, send]);
+ const applyUpdateDownloadProxy = useCallback(() => {
+ send("setUpdateDownloadProxy", { proxy: updateDownloadProxy }, resp => {
+ if ("error" in resp) {
+ notifications.error(
+ `Failed to save update download proxy: ${resp.error.data || "Unknown error"}`,
+ );
+ return;
+ }
+ notifications.success("Update download proxy applied");
+ });
+ }, [send, updateDownloadProxy]);
+
const closeUpdateDialog = useCallback(() => {
setIsUpdateDialogOpen(false);
}, []);
@@ -181,19 +205,23 @@ export default function SettingsVersion() {
}
/>
-
+ {!isBootFromSD && (
+ <>
+
-
-
- {$at("Check for Updates")}
-
-
+
+
+ {$at("Check for Updates")}
+
+
+ >
+ )}
-
+
)}
@@ -278,9 +313,17 @@ function UpdateSourceSettings({
function UpdateContent({
onClose,
onConfirmUpdate,
+ updateSource,
+ updateDownloadProxy,
+ onUpdateDownloadProxyChange,
+ onSaveUpdateDownloadProxy,
}: {
onClose: () => void;
onConfirmUpdate: () => void;
+ updateSource: string;
+ updateDownloadProxy: string;
+ onUpdateDownloadProxyChange: (proxy: string) => void;
+ onSaveUpdateDownloadProxy: () => void;
}) {
const [versionInfo, setVersionInfo] = useState(null);
const { modalView, setModalView, otaState } = useUpdateStore();
@@ -324,6 +367,10 @@ function UpdateContent({
onConfirmUpdate={onConfirmUpdate}
onClose={onClose}
versionInfo={versionInfo!}
+ updateSource={updateSource}
+ updateDownloadProxy={updateDownloadProxy}
+ onUpdateDownloadProxyChange={onUpdateDownloadProxyChange}
+ onSaveUpdateDownloadProxy={onSaveUpdateDownloadProxy}
/>
)}
@@ -482,11 +529,14 @@ function UpdatingDeviceState({
const downloadFinishedAt = otaState[`${type}DownloadFinishedAt`];
const verfiedAt = otaState[`${type}VerifiedAt`];
const updatedAt = otaState[`${type}UpdatedAt`];
+ const downloadSpeedBps = (otaState as any)[`${type}DownloadSpeedBps`] as number | undefined;
+ const formattedSpeed =
+ downloadSpeedBps && downloadSpeedBps > 0 ? `${formatters.bytes(downloadSpeedBps, 1)}/s` : null;
if (!otaState.metadataFetchedAt) {
return "Fetching update information...";
} else if (!downloadFinishedAt) {
- return `Downloading ${type} update...`;
+ return formattedSpeed ? `Downloading ${type} update... (${formattedSpeed})` : `Downloading ${type} update...`;
} else if (!verfiedAt) {
return `Verifying ${type} update...`;
} else if (!updatedAt) {
@@ -651,10 +701,18 @@ function UpdateAvailableState({
versionInfo,
onConfirmUpdate,
onClose,
+ updateSource,
+ updateDownloadProxy,
+ onUpdateDownloadProxyChange,
+ onSaveUpdateDownloadProxy,
}: {
versionInfo: SystemVersionInfo;
onConfirmUpdate: () => void;
onClose: () => void;
+ updateSource: string;
+ updateDownloadProxy: string;
+ onUpdateDownloadProxyChange: (proxy: string) => void;
+ onSaveUpdateDownloadProxy: () => void;
}) {
const { $at } = useReactAt();
return (
@@ -681,6 +739,21 @@ function UpdateAvailableState({
) : null}
+ {updateSource === "github" && (
+
+
onUpdateDownloadProxyChange(e.target.value)}
+ placeholder="https://gh-proxy.com/"
+ />
+
+ {$at("Apply")}
+
+
+ )}
+
diff --git a/ui/src/layout/components_side/SharedFolders/FileManager.tsx b/ui/src/layout/components_side/SharedFolders/FileManager.tsx
index 2ba661c..3a7f522 100644
--- a/ui/src/layout/components_side/SharedFolders/FileManager.tsx
+++ b/ui/src/layout/components_side/SharedFolders/FileManager.tsx
@@ -65,6 +65,7 @@ interface StorageFilePageProps {
showSDManagement?: boolean;
onResetSDStorage?: () => void;
onUnmountSDStorage?: () => void;
+ onFormatSDStorage?: () => void;
onMountSDStorage?: () => void;
}
@@ -77,6 +78,7 @@ export const FileManager: React.FC = ({
showSDManagement = false,
onResetSDStorage,
onUnmountSDStorage,
+ onFormatSDStorage,
}) => {
const { $at } = useReactAt();
@@ -212,6 +214,16 @@ export const FileManager: React.FC = ({
}
}, [onResetSDStorage, syncStorage]);
+ const handleFormatWrapper = useCallback(async () => {
+ if (onFormatSDStorage) {
+ setLoading(true);
+ await onFormatSDStorage();
+ setSDMountStatus(null);
+ syncStorage();
+ setLoading(false);
+ }
+ }, [onFormatSDStorage, syncStorage]);
+
if (mediaType === "sd" && sdMountStatus && sdMountStatus !== "ok") {
return (
@@ -237,6 +249,19 @@ export const FileManager: React.FC
= ({
? $at("Please insert an SD card and try again.")
: $at("Please format the SD card and try again.")}
+ {sdMountStatus !== "none" && (
+
+
+ {$at("Format MicroSD Card")}
+
+
+ )}
@@ -291,6 +316,7 @@ export const FileManager: React.FC
= ({
showSDManagement={showSDManagement}
onNewImageClick={handleNewImageClick}
onUnmountSDStorage={handleUnmountWrapper}
+ onFormatSDStorage={handleFormatWrapper}
syncStorage={syncStorage}
/>
@@ -476,6 +502,7 @@ interface ActionButtonsSectionProps {
showSDManagement?: boolean;
onNewImageClick: (incompleteFileName?: string) => void;
onUnmountSDStorage?: () => void;
+ onFormatSDStorage?: () => void;
syncStorage: () => void;
}
@@ -484,15 +511,22 @@ const ActionButtonsSection: React.FC = ({
loading,
showSDManagement,
onUnmountSDStorage,
+ onFormatSDStorage,
}) => {
const { $at } = useReactAt();
if (mediaType === "sd" && showSDManagement) {
return (
-
-
+
{$at("Format MicroSD Card")}
= ({
);
}
-};
\ No newline at end of file
+};
diff --git a/ui/src/layout/components_side/SharedFolders/SDFilePage.tsx b/ui/src/layout/components_side/SharedFolders/SDFilePage.tsx
index c474f04..961cf02 100644
--- a/ui/src/layout/components_side/SharedFolders/SDFilePage.tsx
+++ b/ui/src/layout/components_side/SharedFolders/SDFilePage.tsx
@@ -1,10 +1,12 @@
import { useState } from "react";
+import { useReactAt } from "i18n-auto-extractor/react";
import { FileManager } from "@/layout/components_side/SharedFolders/FileManager";
import notifications from "@/notifications";
import { useJsonRpc } from "@/hooks/useJsonRpc";
export default function SDFilePage() {
+ const { $at } = useReactAt();
const [send] = useJsonRpc();
const [loading, setLoading] = useState(false);
@@ -34,6 +36,23 @@ export default function SDFilePage() {
setLoading(false);
};
+ const handleFormatSDStorage = async () => {
+ if (!window.confirm($at("Formatting the SD card will erase all data. Continue?"))) {
+ return;
+ }
+ setLoading(true);
+ send("formatSDStorage", { confirm: true }, res => {
+ if ("error" in res) {
+ notifications.error(res.error.data || res.error.message);
+ setLoading(false);
+ return;
+ }
+ notifications.success($at("SD card formatted successfully"));
+ });
+ await new Promise(resolve => setTimeout(resolve, 2000));
+ setLoading(false);
+ };
+
return (
);
-}
\ No newline at end of file
+}
diff --git a/ui/src/layout/components_side/SharedFolders/index.tsx b/ui/src/layout/components_side/SharedFolders/index.tsx
index 1926549..fdf3df2 100644
--- a/ui/src/layout/components_side/SharedFolders/index.tsx
+++ b/ui/src/layout/components_side/SharedFolders/index.tsx
@@ -3,9 +3,16 @@ import React from "react";
import SideTabs from "@components/Sidebar/SideTabs";
import DeviceFilePage from "@/layout/components_side/SharedFolders/DeviceFilePage";
import SDFilePage from "@/layout/components_side/SharedFolders/SDFilePage";
+import { useBootStorageType } from "@/hooks/useBootStorage";
const SharedFolders: React.FC = () => {
+ const { bootStorageType } = useBootStorageType();
+ const isBootFromSD = bootStorageType === "sd";
+
+ if (isBootFromSD) {
+ return ;
+ }
return (
{
+ const lower = filename.toLowerCase();
+ return lower.endsWith(".img") || lower.endsWith(".iso");
+};
+
const LoadingOverlay: React.FC = () => {
const { $at } = useReactAt();
@@ -89,7 +94,6 @@ export default function ImageManager({
const { $at } = useReactAt();
const [send] = useJsonRpc();
- // 状态管理
const [storageFiles, setStorageFiles] = useState([]);
const [selectedFile, setSelectedFile] = useState(null);
const [usbMode, setUsbMode] = useState("CDROM");
@@ -103,7 +107,6 @@ export default function ImageManager({
const [uploadFile, setUploadFile] = useState(null);
const filesPerPage = 5;
- // 计算属性
const percentageUsed = useMemo(() => {
if (!storageSpace) return 0;
return Number(
@@ -162,13 +165,30 @@ export default function ImageManager({
setLoading(false);
};
- // 数据获取函数
+ const handleFormatSDStorage = async () => {
+ if (!window.confirm($at("Formatting the SD card will erase all data. Continue?"))) {
+ return;
+ }
+ setLoading(true);
+ send("formatSDStorage", { confirm: true }, res => {
+ if ("error" in res) {
+ notifications.error(res.error.data || res.error.message);
+ setLoading(false);
+ return;
+ }
+ notifications.success($at("SD card formatted successfully"));
+ setSDMountStatus(null);
+ checkSDStatus();
+ });
+ await new Promise(resolve => setTimeout(resolve, 2000));
+ setLoading(false);
+ };
+
const syncStorage = useCallback(() => {
if (storageType === 'sd' && sdMountStatus !== 'ok') {
return;
}
- // 获取存储文件列表
send(listFilesApi, {}, res => {
if ("error" in res) {
notifications.error(`Error listing storage files: ${res.error}`);
@@ -180,10 +200,11 @@ export default function ImageManager({
size: formatters.bytes(file.size),
createdAt: formatters.date(new Date(file?.createdAt)),
}));
- setStorageFiles(formattedFiles);
+ const mountableFiles = formattedFiles.filter(f => isMountableVirtualMediaFile(f.name));
+ setStorageFiles(mountableFiles);
+ setSelectedFile(prev => (prev && mountableFiles.some(f => f.name === prev) ? prev : null));
});
- // 获取存储空间信息
send(getSpaceApi, {}, res => {
if ("error" in res) {
notifications.error(`Error getting storage space: ${res.error}`);
@@ -192,7 +213,6 @@ export default function ImageManager({
setStorageSpace(res.result as StorageSpace);
});
- // 获取自动挂载设置
if (showAutoMount && getAutoMountApi) {
send(getAutoMountApi, {}, resp => {
if ("error" in resp) {
@@ -205,7 +225,6 @@ export default function ImageManager({
}
}, [send, listFilesApi, getSpaceApi, showAutoMount, getAutoMountApi, storageType, sdMountStatus]);
- // 初始化数据
useEffect(() => {
if (storageType === 'sd') {
checkSDStatus();
@@ -220,7 +239,6 @@ export default function ImageManager({
}
}, [sdMountStatus, syncStorage]);
- // 事件处理函数
const handleDeleteFile = useCallback((file: StorageFile) => {
if (window.confirm($at("Are you sure you want to delete " + file.name + "?"))) {
send(deleteFileApi, { filename: file.name }, res => {
@@ -235,9 +253,10 @@ export default function ImageManager({
const handleSelectFile = useCallback((file: StorageFile) => {
setSelectedFile(file.name);
- if (file.name.endsWith(".iso")) {
+ const lower = file.name.toLowerCase();
+ if (lower.endsWith(".iso")) {
setUsbMode("CDROM");
- } else if (file.name.endsWith(".img")) {
+ } else if (lower.endsWith(".img")) {
setUsbMode("Disk");
}
}, []);
@@ -263,7 +282,6 @@ export default function ImageManager({
return;
}
- // 挂载成功
syncRemoteVirtualMediaState()
setMountInProgress(false);
if (onMountSuccess) {
@@ -327,6 +345,19 @@ export default function ImageManager({
? $at("Please insert an SD card and try again.")
: $at("Please format the SD card and try again.")}
+ {sdMountStatus !== "none" && (
+
+
+ {$at("Format MicroSD Card")}
+
+
+ )}
@@ -343,7 +374,6 @@ export default function ImageManager({
title={$at("Mount from KVM Storage")}
description={$at("Select the image you want to mount from the KVM storage")}
/>
- {/* 自动挂载设置 */}
{showAutoMount && (
}
- {/* 文件列表 */}
@@ -402,7 +431,6 @@ export default function ImageManager({
/>
))}
- {/* 分页控件 */}
{storageFiles.length > filesPerPage && (
@@ -438,7 +466,6 @@ export default function ImageManager({
- {/* 操作按钮 */}
{storageFiles.length > 0 && (
@@ -456,7 +483,6 @@ export default function ImageManager({
)}
- {/* 存储空间信息 */}
-
- {/* 自定义操作区域 */}
+
{unmountApi && storageType === 'sd' && (
-
+
{$at("Format MicroSD Card")}
) : (
)}
diff --git a/ui/src/layout/components_side/VirtualMediaSource/index.tsx b/ui/src/layout/components_side/VirtualMediaSource/index.tsx
index 6552704..9e0e85e 100644
--- a/ui/src/layout/components_side/VirtualMediaSource/index.tsx
+++ b/ui/src/layout/components_side/VirtualMediaSource/index.tsx
@@ -4,16 +4,17 @@ import SideTabs from "@components/Sidebar/SideTabs";
import DevicePage from "@/layout/components_side/VirtualMediaSource/DevicePage";
import SDPage from "@/layout/components_side/VirtualMediaSource/SDPage";
import UnMountPage from "@/layout/components_side/VirtualMediaSource/UnMount";
+import { useBootStorageType } from "@/hooks/useBootStorage";
-
-////* KVM MicroSD Mount */
-// width: 143px;
-// height: 11px;
-// display: flex;
-// flex-direction: row;
-// align-items: center;
-// 主组件
const VirtualMediaSource: React.FC = () => {
+ const { bootStorageType } = useBootStorageType();
+ const isBootFromSD = bootStorageType === "sd";
+
+ if (isBootFromSD) {
+ return (
+
} />
+ );
+ }
return (
{
defaultActiveKey="1"
/>
)}/>
-
);
};
diff --git a/ui/src/layout/core/desktop/DesktopPC.tsx b/ui/src/layout/core/desktop/DesktopPC.tsx
index 4d6b69f..466f9a6 100644
--- a/ui/src/layout/core/desktop/DesktopPC.tsx
+++ b/ui/src/layout/core/desktop/DesktopPC.tsx
@@ -12,6 +12,7 @@ import { cx } from "@/cva.config";
import { useVideoEffects } from "@/layout/core/desktop/hooks/useVideoEffects";
import { useVideoStream } from "@/layout/core/desktop/hooks/useVideoStream";
import { usePointerLock } from "@/layout/core/desktop/hooks/usePointerLock";
+import { useFullscreen } from "@/layout/core/desktop/hooks/useFullscreen";
import { useKeyboardEvents } from "@/layout/core/desktop/hooks/useKeyboardEvents";
import { useMouseEvents } from "@/layout/core/desktop/hooks/useMouseEvents";
import { useVideoOverlays } from "@/layout/core/desktop/hooks/useVideoOverlays";
@@ -25,7 +26,7 @@ import { useUiStore, useHidStore, useSettingsStore } from "@/hooks/stores";
import { useTouchZoom } from "@/layout/core/desktop/hooks/useTouchZoom";
import { usePasteHandler } from "@/layout/core/desktop/hooks/usePasteHandler";
-export default function PCDesktop() {
+export default function PCDesktop({ isFullscreen }: { isFullscreen?: number }) {
const videoElm = useRef(null);
const audioElm = useRef(null);
const containerRef = useRef(null);
@@ -55,6 +56,7 @@ export default function PCDesktop() {
const videoEffects = useVideoEffects();
const videoStream = useVideoStream(videoElm as React.RefObject, audioElm as React.RefObject);
const pointerLock = usePointerLock(videoElm as React.RefObject);
+ useFullscreen(videoElm as React.RefObject, pointerLock, isFullscreen);
const touchZoom = useTouchZoom(zoomContainerRef as React.RefObject);
const { handleGlobalPaste } = usePasteHandler(pasteCaptureRef as React.RefObject);
diff --git a/ui/src/layout/core/desktop/index.tsx b/ui/src/layout/core/desktop/index.tsx
index c9e9766..55dde88 100644
--- a/ui/src/layout/core/desktop/index.tsx
+++ b/ui/src/layout/core/desktop/index.tsx
@@ -9,5 +9,5 @@ export default function Desktop({ isFullscreen }: { isFullscreen?: number }) {
if(isMobile){
return
}
- return ;
-}
\ No newline at end of file
+ return ;
+}
diff --git a/ui/src/locales/en.json b/ui/src/locales/en.json
index 068bcf8..c83e7db 100644
--- a/ui/src/locales/en.json
+++ b/ui/src/locales/en.json
@@ -14,10 +14,11 @@
"9aab28af69": "Variation in packet delay, affecting video smoothness.",
"75316ccce3": "Frame per second",
"75a4e60fe7": "Number of video frames displayed per second.",
+ "867f8b3151": "Only {{types}} files are supported",
"13c27e178b": "Please select the file {{fileName}} to continue the upload.",
"8282fb0974": "Resume Upload",
"411f67b4c6": "Click here to select {{fileName}} to resume upload",
- "5d2e347cdb": "Click here to upload a new image",
+ "6e8daad8ab": "Click here to upload",
"ddeb0eac69": "Do not support directories",
"3f1c5b0049": "Uploading",
"f287042190": "Uploading...",
@@ -250,6 +251,43 @@
"c420b0d8f0": "Enter Cloudflare Tunnel Token",
"1bbabcdef3": "Edit frpc.toml",
"9c088a303a": "Enter frpc configuration",
+ "87ed72fc20": "Failed to get firewall config",
+ "a25ea1e1ee": "Failed to apply firewall config",
+ "bcbc6c71a1": "Firewall config applied",
+ "f4b0e93291": "Please select protocol",
+ "7687f1154f": "Missing required fields",
+ "a133cae395": "Invalid port",
+ "d10b3069c2": "For PREROUTING, Destination IP must be a real host IP",
+ "0834bb6c87": "Destination IP is required",
+ "c5381dc540": "Firewall",
+ "8256c0c407": "Manage the firewall rules of the device",
+ "972e73b7a8": "Basic",
+ "7218073b8c": "Communication Rules",
+ "23fed496dd": "Port Forwarding",
+ "324118a672": "Input",
+ "29c2c02a36": "Output",
+ "67d2f6740a": "Forward",
+ "63a6a88c06": "Refresh",
+ "a4d3b161ce": "Submit",
+ "ec211f7c20": "Add",
+ "5320550175": "Chain",
+ "f31bbdd1b3": "Source",
+ "888a77f5ac": "Protocol",
+ "12007e1d59": "Destination",
+ "004bf6c9a4": "Action",
+ "b5a7adde1a": "Description",
+ "2a78ed7645": "Operation",
+ "095c375025": "No rules available",
+ "ed36a1ef76": "Any",
+ "7dce122004": "Edit",
+ "efba20a02e": "No data available",
+ "e0aa021e21": "OK",
+ "7d367dab8b": "Source IP",
+ "c050956ed2": "Source Port",
+ "94386968c2": "Destination IP",
+ "64ae5dd047": "Destination Port",
+ "fbb3878eb7": "Submit Firewall Policies?",
+ "1c17a7e15b": "Warning: Adjusting some policies may cause network address loss, leading to device unavailability.",
"867cee98fd": "Passwords do not match",
"9864ff9420": "Please enter your old password",
"14a714ab22": "Please enter a new password",
@@ -292,6 +330,8 @@
"7a941a0f87": "Update SSH Key",
"fdad65b7be": "Force HTTP Transmission",
"e033b5e2a3": "Force using HTTP for video streaming instead of WebRTC",
+ "bda22ca687": "USB detection enhancement",
+ "f7df928057": "The DISC state is also checked during USB status retrieval",
"021fa314ef": "USB Emulation",
"5c43d74dbd": "Control the USB emulation state",
"f6c8ddbadf": "Disable USB Emulation",
@@ -302,12 +342,21 @@
"f5ddf02991": "Reboot System",
"1dbbf194af": "Restart the device system",
"1de72c4fc6": "Reboot",
+ "c887e5a479": "Edit Configuration",
+ "90b21a0536": "Edit the raw configuration file directly",
"f43c0398a4": "Reset Configuration",
"0031dbef48": "Reset configuration to default. This will log you out.Some configuration changes will take effect after restart system.",
"0d784092e8": "Reset Config",
"a776e925bf": "Reboot System?",
"1f070051ff": "Are you sure you want to reboot the system?",
"f1a79f466e": "The device will restart and you will be disconnected from the web interface.",
+ "919ff9ff77": "Edit the raw configuration JSON. Be careful when making changes as invalid JSON can cause system issues.",
+ "4e11db406c": "Saving",
+ "f24a0236a3": "Configuration Saved",
+ "b032420c4b": "Configuration has been saved successfully. Some changes may require a system restart to take effect.",
+ "c8850fa947": "Would you like to restart the system now?",
+ "61057a0c84": "Later",
+ "2d9c2140c5": "Restart Now",
"0db377921f": "General",
"3845ee1693": "Configure device settings and update preferences",
"d721757161": "Theme",
@@ -372,6 +421,8 @@
"a30194c638": "This will save the requested IPv4 address. Changes take effect after a restart.",
"cc172c234b": "Change IPv4 Mode?",
"a344a29861": "IPv4 mode changes will take effect after a restart.",
+ "0ddd0ae8fd": "Change MAC Address?",
+ "d0158ce3c1": "Changing the MAC address may cause the device IP to be reassigned and changed.",
"34b6cd7517": "Version",
"a1937e8ac1": "Check the versions of the system and applications",
"04a115bdd8": "AppVersion",
@@ -396,6 +447,7 @@
"7c81553358": "Check Again",
"217b416896": "Update available",
"f00af8c98f": "A new update is available to enhance system performance and improve compatibility. We recommend updating to ensure everything runs smoothly.",
+ "8977c3f0b7": "Download Proxy Prefix",
"99b1054c0f": "Update Now",
"7f3cd4480d": "Do it later",
"cffae9918d": "Update Error",
@@ -413,7 +465,6 @@
"6b2f64058e": "Are you sure you want to delete this macro? This action cannot be undone.",
"ffbb410a55": "Deleting",
"3dd2e50646": "Delay Only",
- "7dce122004": "Edit",
"5fb63579fc": "Copy",
"fc2839fcdf": "+ Add new macro",
"05fd7d5b9c": "Are you sure you want to delete",
@@ -436,6 +487,7 @@
"21d104a54f": "Processing...",
"9844086d90": "No SD Card Detected",
"b56e598918": "SD Card Mount Failed",
+ "16eb8ed6c8": "Format MicroSD Card",
"a63d5e0260": "Manage Shared Folders in KVM MicroSD Card",
"1d50425f88": "Manage Shared Folders in KVM Storage",
"74ff4bad28": "No files found",
@@ -446,6 +498,8 @@
"8bf8854beb": "of",
"53e61336bb": "results",
"d1f5a81904": "Unmount MicroSD Card",
+ "827048afc2": "Formatting the SD card will erase all data. Continue?",
+ "5874cf46ff": "SD card formatted successfully",
"7dc25305d4": "Encodec Type",
"41d263a988": "Stream Quality",
"41f5283941": "NPU Application",
diff --git a/ui/src/locales/zh.json b/ui/src/locales/zh.json
index 98b1308..1e7ad6b 100644
--- a/ui/src/locales/zh.json
+++ b/ui/src/locales/zh.json
@@ -14,10 +14,11 @@
"9aab28af69": "数据包延迟的变化,影响视频流畅度。",
"75316ccce3": "帧率",
"75a4e60fe7": "每秒显示的视频帧数。",
+ "867f8b3151": "只支持 {{types}} 文件",
"13c27e178b": "请选择 {{fileName}} 继续上传",
"8282fb0974": "重新上传",
"411f67b4c6": "点击这里选择 {{fileName}} 重新上传",
- "5d2e347cdb": "点击这里上传新镜像",
+ "6e8daad8ab": "点击这里上传",
"ddeb0eac69": "不支持目录",
"3f1c5b0049": "上传中",
"f287042190": "正在上传...",
@@ -250,6 +251,43 @@
"c420b0d8f0": "输入 Cloudflare 通道令牌",
"1bbabcdef3": "编辑 frpc.toml",
"9c088a303a": "输入 frpc 配置",
+ "87ed72fc20": "获取防火墙配置失败",
+ "a25ea1e1ee": "应用防火墙配置失败",
+ "bcbc6c71a1": "防火墙配置已应用",
+ "f4b0e93291": "请选择协议",
+ "7687f1154f": "缺少必填字段",
+ "a133cae395": "无效端口",
+ "d10b3069c2": "对于 PREROUTING,目标 IP 必须是真实主机 IP",
+ "0834bb6c87": "目标 IP 是必填项",
+ "c5381dc540": "防火墙",
+ "8256c0c407": "管理设备的防火墙规则",
+ "972e73b7a8": "基础",
+ "7218073b8c": "通讯规则",
+ "23fed496dd": "端口转发",
+ "324118a672": "输入",
+ "29c2c02a36": "输出",
+ "67d2f6740a": "转发",
+ "63a6a88c06": "刷新",
+ "a4d3b161ce": "提交",
+ "ec211f7c20": "添加",
+ "5320550175": "链",
+ "f31bbdd1b3": "源",
+ "888a77f5ac": "协议",
+ "12007e1d59": "目标",
+ "004bf6c9a4": "操作",
+ "b5a7adde1a": "描述",
+ "2a78ed7645": "操作",
+ "095c375025": "没有可用规则",
+ "ed36a1ef76": "任何",
+ "7dce122004": "编辑",
+ "efba20a02e": "没有可用数据",
+ "e0aa021e21": "确定",
+ "7d367dab8b": "源 IP",
+ "c050956ed2": "源端口",
+ "94386968c2": "目标 IP",
+ "64ae5dd047": "目标端口",
+ "fbb3878eb7": "提交防火墙策略?",
+ "1c17a7e15b": "警告:调整某些策略可能会导致网络地址丢失,导致设备不可用。",
"867cee98fd": "密码不一致",
"9864ff9420": "请输入旧密码",
"14a714ab22": "请输入新密码",
@@ -292,6 +330,8 @@
"7a941a0f87": "更新 SSH 密钥",
"fdad65b7be": "强制 HTTP 传输",
"e033b5e2a3": "强制使用 HTTP 传输数据替代 WebRTC",
+ "bda22ca687": "USB 检测增强",
+ "f7df928057": "在获取 USB 状态时也会检查 DISC 状态",
"021fa314ef": "USB 复用",
"5c43d74dbd": "控制 USB 复用状态",
"f6c8ddbadf": "禁用 USB 复用",
@@ -302,12 +342,21 @@
"f5ddf02991": "重启系统",
"1dbbf194af": "重启设备系统",
"1de72c4fc6": "重启",
+ "c887e5a479": "编辑配置",
+ "90b21a0536": "编辑原始配置文件",
"f43c0398a4": "重置配置",
"0031dbef48": "重置配置,这将使你退出登录。部分配置重启后生效。",
"0d784092e8": "重置配置",
"a776e925bf": "重启系统?",
"1f070051ff": "你确定重启系统吗?",
"f1a79f466e": "设备将重启,你将从 Web 界面断开连接。",
+ "919ff9ff77": "编辑原始配置 JSON。小心修改,无效 JSON 可能导致系统问题。",
+ "4e11db406c": "保存中",
+ "f24a0236a3": "配置已保存",
+ "b032420c4b": "配置已成功保存。某些更改可能需要系统重启才能生效。",
+ "c8850fa947": "是否现在重启系统?",
+ "61057a0c84": "稍后",
+ "2d9c2140c5": "立刻重启",
"0db377921f": "常规",
"3845ee1693": "查看设备的版本信息",
"d721757161": "主题",
@@ -372,6 +421,8 @@
"a30194c638": "这将保存请求的 IPv4 地址。更改将在重启后生效。",
"cc172c234b": "更改 IPv4 模式?",
"a344a29861": "IPv4 模式更改将在重启后生效。",
+ "0ddd0ae8fd": "更改 MAC 地址?",
+ "d0158ce3c1": "更改 MAC 地址可能会导致设备 IP 重新分配和更改。",
"34b6cd7517": "版本",
"a1937e8ac1": "检查系统和应用程序的版本",
"04a115bdd8": "应用版本",
@@ -396,6 +447,7 @@
"7c81553358": "再次检查",
"217b416896": "更新可用",
"f00af8c98f": "新的更新可用,以增强系统性能和提高兼容性。我们建议更新以确保一切正常运行。",
+ "8977c3f0b7": "下载代理加速前缀",
"99b1054c0f": "立即更新",
"7f3cd4480d": "稍后更新",
"cffae9918d": "更新错误",
@@ -413,7 +465,6 @@
"6b2f64058e": "确定要删除此宏吗?此操作无法撤销。",
"ffbb410a55": "删除中",
"3dd2e50646": "仅延迟",
- "7dce122004": "编辑",
"5fb63579fc": "复制",
"fc2839fcdf": "+ 添加新宏",
"05fd7d5b9c": "您确定要删除",
@@ -436,6 +487,7 @@
"21d104a54f": "处理中...",
"9844086d90": "未检测到 SD 卡",
"b56e598918": "SD 卡挂载失败",
+ "16eb8ed6c8": "格式化 MicroSD 卡",
"a63d5e0260": "管理 KVM MicroSD Card 的共享文件夹",
"1d50425f88": "管理 KVM 存储中的共享文件夹",
"74ff4bad28": "未找到文件",
@@ -446,6 +498,8 @@
"8bf8854beb": "共",
"53e61336bb": "条结果",
"d1f5a81904": "卸载 MicroSD 卡",
+ "827048afc2": "格式化 SD 卡会清除所有数据。是否继续?",
+ "5874cf46ff": "SD 卡格式化成功",
"7dc25305d4": "视频编码类型",
"41d263a988": "视频质量",
"41f5283941": "NPU 应用",
diff --git a/usb.go b/usb.go
index 4f530f3..73ce0a2 100644
--- a/usb.go
+++ b/usb.go
@@ -130,7 +130,7 @@ func rpcGetKeyboardLedState() (state usbgadget.KeyboardState) {
var usbState = "unknown"
func rpcGetUSBState() (state string) {
- return gadget.GetUsbState()
+ return gadget.GetUsbState(config.UsbEnhancedDetection)
}
func triggerUSBStateUpdate() {
@@ -144,7 +144,7 @@ func triggerUSBStateUpdate() {
}
func checkUSBState() {
- newState := gadget.GetUsbState()
+ newState := gadget.GetUsbState(config.UsbEnhancedDetection)
if newState == usbState {
return
}
diff --git a/usb_mass_storage.go b/usb_mass_storage.go
index 605a12b..cc1c85f 100644
--- a/usb_mass_storage.go
+++ b/usb_mass_storage.go
@@ -1,6 +1,7 @@
package kvm
import (
+ "bytes"
"encoding/json"
"errors"
"fmt"
@@ -781,6 +782,107 @@ func rpcUnmountSDStorage() error {
return nil
}
+func rpcFormatSDStorage(confirm bool) error {
+ if !confirm {
+ return fmt.Errorf("format not confirmed")
+ }
+ if _, err := os.Stat("/dev/mmcblk1"); err != nil {
+ if os.IsNotExist(err) {
+ return fmt.Errorf("sd device not found: /dev/mmcblk1")
+ }
+ return fmt.Errorf("failed to stat sd device: %w", err)
+ }
+
+ if err := updateMtp(false); err != nil {
+ logger.Error().Err(err).Msg("failed to update mtp before formatting sd")
+ }
+
+ if out, err := exec.Command("mount").Output(); err == nil {
+ if strings.Contains(string(out), " on /mnt/sdcard") {
+ if umOut, umErr := exec.Command("umount", "/mnt/sdcard").CombinedOutput(); umErr != nil {
+ return fmt.Errorf("failed to unmount sdcard: %w: %s", umErr, strings.TrimSpace(string(umOut)))
+ }
+ }
+ }
+
+ if err := os.MkdirAll(SDImagesFolder, 0755); err != nil {
+ return fmt.Errorf("failed to ensure mount point: %w", err)
+ }
+
+ if _, err := os.Stat("/dev/mmcblk1p1"); os.IsNotExist(err) {
+ var lastErr error
+ if _, err := exec.LookPath("sfdisk"); err == nil {
+ sfdiskInput := "label: dos\nunit: sectors\n\n2048,,c,*\n"
+ cmd := exec.Command("sfdisk", "/dev/mmcblk1")
+ cmd.Stdin = bytes.NewBufferString(sfdiskInput)
+ partOut, partErr := cmd.CombinedOutput()
+ if partErr != nil {
+ lastErr = fmt.Errorf("sfdisk failed: %w: %s", partErr, strings.TrimSpace(string(partOut)))
+ } else {
+ lastErr = nil
+ }
+ } else if _, err := exec.LookPath("fdisk"); err == nil {
+ fdiskScript := "o\nn\np\n1\n2048\n\nt\n1\nc\na\n1\nw\n"
+ cmd := exec.Command("fdisk", "/dev/mmcblk1")
+ cmd.Stdin = bytes.NewBufferString(fdiskScript)
+ partOut, partErr := cmd.CombinedOutput()
+ if partErr != nil {
+ lastErr = fmt.Errorf("fdisk failed: %w: %s", partErr, strings.TrimSpace(string(partOut)))
+ } else {
+ lastErr = nil
+ }
+ } else if _, err := exec.LookPath("parted"); err == nil {
+ partedOut, partedErr := exec.Command("parted", "-s", "/dev/mmcblk1", "mklabel", "msdos", "mkpart", "primary", "fat32", "1MiB", "100%").CombinedOutput()
+ if partedErr != nil {
+ lastErr = fmt.Errorf("parted failed: %w: %s", partedErr, strings.TrimSpace(string(partedOut)))
+ } else {
+ lastErr = nil
+ }
+ } else {
+ return fmt.Errorf("no partitioning tool found (need sfdisk, fdisk, or parted)")
+ }
+
+ if lastErr != nil {
+ return fmt.Errorf("failed to create sd partition: %w", lastErr)
+ }
+
+ if _, err := exec.LookPath("partprobe"); err == nil {
+ if _, err := exec.Command("partprobe", "/dev/mmcblk1").CombinedOutput(); err != nil {
+ time.Sleep(800 * time.Millisecond)
+ } else {
+ time.Sleep(300 * time.Millisecond)
+ }
+ } else {
+ time.Sleep(800 * time.Millisecond)
+ }
+ }
+
+ if _, err := os.Stat("/dev/mmcblk1p1"); err != nil {
+ if os.IsNotExist(err) {
+ return fmt.Errorf("sd partition not found after partitioning: /dev/mmcblk1p1")
+ }
+ return fmt.Errorf("failed to stat sd partition: %w", err)
+ }
+
+ mkfsOut, mkfsErr := exec.Command("mkfs.vfat", "-F", "32", "-n", "PICOKVM", "/dev/mmcblk1p1").CombinedOutput()
+ if mkfsErr != nil {
+ return fmt.Errorf("failed to format sdcard: %w: %s", mkfsErr, strings.TrimSpace(string(mkfsOut)))
+ }
+
+ mountOut, mountErr := exec.Command("mount", "/dev/mmcblk1p1", SDImagesFolder).CombinedOutput()
+ if mountErr != nil {
+ return fmt.Errorf("failed to mount sdcard after format: %w: %s", mountErr, strings.TrimSpace(string(mountOut)))
+ }
+
+ SyncConfigSD(false)
+
+ if err := updateMtp(true); err != nil {
+ return fmt.Errorf("failed to update mtp after formatting sd: %w", err)
+ }
+
+ return nil
+}
+
func rpcListSDStorageFiles() (*StorageFiles, error) {
files, err := os.ReadDir(SDImagesFolder)
if err != nil {
@@ -842,6 +944,7 @@ const (
type SDMountStatusResponse struct {
Status SDMountStatus `json:"status"`
+ Reason string `json:"reason,omitempty"`
}
func rpcGetSDMountStatus() (*SDMountStatusResponse, error) {
@@ -849,9 +952,13 @@ func rpcGetSDMountStatus() (*SDMountStatusResponse, error) {
return &SDMountStatusResponse{Status: SDMountNone}, nil
}
+ if _, err := os.Stat("/dev/mmcblk1p1"); os.IsNotExist(err) {
+ return &SDMountStatusResponse{Status: SDMountFail, Reason: "no_partition"}, nil
+ }
+
output, err := exec.Command("mount").Output()
if err != nil {
- return &SDMountStatusResponse{Status: SDMountFail}, fmt.Errorf("failed to check mount status: %v", err)
+ return &SDMountStatusResponse{Status: SDMountFail, Reason: "check_mount_failed"}, fmt.Errorf("failed to check mount status: %v", err)
}
if strings.Contains(string(output), "/dev/mmcblk1p1 on /mnt/sdcard") {
@@ -860,19 +967,19 @@ func rpcGetSDMountStatus() (*SDMountStatusResponse, error) {
err = exec.Command("mount", "/dev/mmcblk1p1", "/mnt/sdcard").Run()
if err != nil {
- return &SDMountStatusResponse{Status: SDMountFail}, fmt.Errorf("failed to mount SD card: %v", err)
+ return &SDMountStatusResponse{Status: SDMountFail, Reason: "mount_failed"}, fmt.Errorf("failed to mount SD card: %v", err)
}
output, err = exec.Command("mount").Output()
if err != nil {
- return &SDMountStatusResponse{Status: SDMountFail}, fmt.Errorf("failed to check mount status after mounting: %v", err)
+ return &SDMountStatusResponse{Status: SDMountFail, Reason: "check_mount_after_failed"}, fmt.Errorf("failed to check mount status after mounting: %v", err)
}
if strings.Contains(string(output), "/dev/mmcblk1p1 on /mnt/sdcard") {
return &SDMountStatusResponse{Status: SDMountOK}, nil
}
- return &SDMountStatusResponse{Status: SDMountFail}, nil
+ return &SDMountStatusResponse{Status: SDMountFail, Reason: "mount_unknown"}, nil
}
type SDStorageFileUpload struct {
@@ -984,21 +1091,13 @@ usb_max_packet_size 0x200
return os.WriteFile(umtprdConfPath, []byte(conf), 0644)
}
-func updateMtpWithSDStatus() error {
- resp, _ := rpcGetSDMountStatus()
- if resp.Status == SDMountOK {
- if err := writeUmtprdConfFile(true); err != nil {
- logger.Error().Err(err).Msg("failed to write umtprd conf file")
- }
- } else {
- if err := writeUmtprdConfFile(false); err != nil {
- logger.Error().Err(err).Msg("failed to write umtprd conf file")
- }
+func updateMtp(withSD bool) error {
+ if err := writeUmtprdConfFile(withSD); err != nil {
+ logger.Error().Err(err).Msg("failed to write umtprd conf file")
}
-
if config.UsbDevices.Mtp {
- if err := gadget.UnbindUDCToDWC3(); err != nil {
- logger.Error().Err(err).Msg("failed to unbind UDC")
+ if err := gadget.UnbindUDC(); err != nil {
+ logger.Error().Err(err).Msg("failed to unbind gadget from UDC")
}
if out, err := exec.Command("pgrep", "-x", "umtprd").Output(); err == nil && len(out) > 0 {
@@ -1013,10 +1112,29 @@ func updateMtpWithSDStatus() error {
return fmt.Errorf("failed to exec binary: %w", err)
}
- if err := rpcSetUsbDevices(*config.UsbDevices); err != nil {
- return fmt.Errorf("failed to set usb devices: %w", err)
+ var lastErr error
+ for attempt := 0; attempt < 6; attempt++ {
+ if err := rpcSetUsbDevices(*config.UsbDevices); err == nil {
+ lastErr = nil
+ break
+ } else {
+ lastErr = err
+ logger.Warn().
+ Int("attempt", attempt+1).
+ Err(err).
+ Msg("failed to re-apply usb devices after mtp update, retrying")
+ time.Sleep(time.Duration(300*(attempt+1)) * time.Millisecond)
+ }
+ }
+ if lastErr != nil {
+ return fmt.Errorf("failed to set usb devices after mtp update: %w", lastErr)
}
}
return nil
}
+
+func updateMtpWithSDStatus() error {
+ resp, _ := rpcGetSDMountStatus()
+ return updateMtp(resp.Status == SDMountOK)
+}