diff --git a/internal/server/at_guard_test.go b/internal/server/at_guard_test.go index 914826d..05acb8f 100644 --- a/internal/server/at_guard_test.go +++ b/internal/server/at_guard_test.go @@ -31,12 +31,42 @@ func TestValidateATCommandBlocksTrafficMessagingAndDialActions(t *testing.T) { "AT+CSQ;+CMSS=7", "AT+CSQ;D12345;", } { - if err := validateATCommand(command); err == nil { + if err := validateATCommand(command, false); err == nil { t.Errorf("validateATCommand(%q) permitted a guarded mutation", command) } } } +func TestValidateATCommandForceBypassesGuard(t *testing.T) { + t.Parallel() + for _, command := range []string{ + "AT+CGATT=1", + "AT+CFUN=1", + "AT+CGACT=1,1", + "AT+CUSD=1,\"*100#\"", + "ATD12345;", + } { + if err := validateATCommand(command, true); err != nil { + t.Errorf("validateATCommand(%q, true): %v", command, err) + } + } +} + +func TestValidateATCommandForceKeepsSyntaxChecks(t *testing.T) { + t.Parallel() + for _, command := range []string{ + "A", + "", + "AT\r", + "AT\n", + string(make([]byte, 513)), + } { + if err := validateATCommand(command, true); err == nil { + t.Errorf("validateATCommand(%q, true) skipped syntax check", command) + } + } +} + func TestValidateATCommandAllowsReadOnlyStatusQueries(t *testing.T) { t.Parallel() for _, command := range []string{ @@ -48,7 +78,7 @@ func TestValidateATCommandAllowsReadOnlyStatusQueries(t *testing.T) { "AT+CIMI", "AT+CCID", } { - if err := validateATCommand(command); err != nil { + if err := validateATCommand(command, false); err != nil { t.Errorf("validateATCommand(%q): %v", command, err) } } diff --git a/internal/server/device_api.go b/internal/server/device_api.go index 2bf6578..56a08dc 100644 --- a/internal/server/device_api.go +++ b/internal/server/device_api.go @@ -1045,13 +1045,14 @@ func (s *Server) handleAT(w http.ResponseWriter, r *http.Request, id string) boo var request struct { Command string `json:"cmd"` TimeoutMs int `json:"timeout_ms"` + Force bool `json:"force"` } if err := s.decodeJSON(w, r, &request); err != nil { writeError(w, http.StatusBadRequest, "invalid_request", err.Error()) return true } command := strings.TrimSpace(request.Command) - if err := validateATCommand(command); err != nil { + if err := validateATCommand(command, request.Force); err != nil { writeError(w, http.StatusBadRequest, "unsafe_at_command", err.Error()) return true } @@ -1100,7 +1101,7 @@ func (s *Server) handleAT(w http.ResponseWriter, r *http.Request, id string) boo return true } -func validateATCommand(command string) error { +func validateATCommand(command string, force bool) error { upper := strings.ToUpper(command) if len(command) < 2 || len(command) > 512 || !strings.HasPrefix(upper, "AT") { return errors.New("AT command must start with AT and contain at most 512 characters") @@ -1108,6 +1109,9 @@ func validateATCommand(command string) error { if strings.ContainsAny(command, "\r\n\x00") { return errors.New("AT command must contain exactly one line") } + if force { + return nil + } canonical := strings.NewReplacer(" ", "", "\t", "").Replace(upper) for _, blocked := range []string{ `+QCFG="USBNET"`, diff --git a/internal/server/telegram_bot.go b/internal/server/telegram_bot.go index 21a74de..14a6858 100644 --- a/internal/server/telegram_bot.go +++ b/internal/server/telegram_bot.go @@ -1981,7 +1981,7 @@ func (bot *telegramBot) handleATCommand(ctx context.Context, config telegramRunt func (bot *telegramBot) executeATCommand(ctx context.Context, deviceID, command string) (string, error) { command = strings.TrimSpace(command) - if err := validateATCommand(command); err != nil { + if err := validateATCommand(command, false); err != nil { return "", err } _, _, physicalID, err := bot.device(deviceID) diff --git a/web/src/components/devices/DeviceAtTab.tsx b/web/src/components/devices/DeviceAtTab.tsx index 77b3809..2d36ae1 100644 --- a/web/src/components/devices/DeviceAtTab.tsx +++ b/web/src/components/devices/DeviceAtTab.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; import { WindowConsoleRegular, WarningRegular } from "@fluentui/react-icons"; import { api } from "../../api"; -import { Button, Input, Select } from "../ui"; +import { Button, Input, Select, Switch } from "../ui"; import { AT_COMMAND_GROUPS } from "./atCommands"; import { AtLogEntry, AtTypingBubble, type AtLogItem } from "./AtLogEntry"; import { useI18n } from "../../lib/i18n"; @@ -19,6 +19,7 @@ export function DeviceAtTab({ deviceId, backendMode, atPort, running }: DeviceAt const [template, setTemplate] = useState(""); const [timeoutMs, setTimeoutMs] = useState(10000); const [sending, setSending] = useState(false); + const [force, setForce] = useState(false); const [log, setLog] = useState([]); const hasAtPort = String(atPort || "").trim().length > 0; @@ -40,7 +41,7 @@ export function DeviceAtTab({ deviceId, backendMode, atPort, running }: DeviceAt try { const res = await api<{ ok?: boolean; response?: string; result?: string }>(`/devices/${deviceId}/actions/at`, { method: "POST", - body: { cmd: command, timeoutMs: timeoutMs || 10000 }, + body: { cmd: command, timeoutMs: timeoutMs || 10000, force }, }); setLog((prev) => [ ...prev, @@ -120,6 +121,13 @@ export function DeviceAtTab({ deviceId, backendMode, atPort, running }: DeviceAt +
+
+ + {t("强制模式允许发送默认被拦截的 AT 指令(如切网、拨号、短信、USSD),误操作可能导致断网或费用扣除。")} +
+ +
) : (
diff --git a/web/src/lib/i18n-en.ts b/web/src/lib/i18n-en.ts index 7dcee34..d4aab1b 100644 --- a/web/src/lib/i18n-en.ts +++ b/web/src/lib/i18n-en.ts @@ -685,6 +685,8 @@ export const EN_DICT: Record = { "AT 终端暂不可用": "AT terminal unavailable", "AT=串口 / QMI=纯 QMI": "AT=serial / QMI=pure QMI", "AT=传统串口 / QMI=纯 QMI": "AT=legacy serial / QMI=pure QMI", + "强制发送 AT 指令": "Force-send AT command", + "强制模式允许发送默认被拦截的 AT 指令(如切网、拨号、短信、USSD),误操作可能导致断网或费用扣除。": "Force mode allows sending AT commands that are normally blocked (e.g. mode switching, dialing, SMS, USSD). Mistakes may disconnect the network or incur charges.", "E911地址": "E911 Address", "E911地址设置页面打开失败": "Failed to open the E911 address setup page", "IMEI 绑定": "IMEI Binding",