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 => ( + + ))} +
+
+ +
+ {activeTab === "base" && ( + + +
+
+
+
+ {$at("Input")} +
+
+ + setBaseDraft({ + ...baseDraft, + outputPolicy: v as FirewallAction, + }) + } + options={actionOptions} + /> +
+
+ {$at("Forward")} +
+
+ 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, 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")} +
+ 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.")} +

+