11 Commits
Author SHA1 Message Date
MengMengCode 609a591045 feat: enhance email validation to prevent injection attacks and improve related tests 2026-08-11 19:41:06 +08:00
MengMengCode 461054615b feat: implement automatic task recovery logic and enhance related tests 2026-08-11 19:32:22 +08:00
MengMengCode 020fb619a9 feat: enhance security by preventing exposure of sensitive credentials in logs and errors 2026-08-11 19:07:58 +08:00
MengMengCode 3cc73f1885 feat: implement eSIM notification handling and email message construction
- Add eSIM notification management in `esim_notifications.go` with functions to retrieve, list, and remove notifications.
- Implement parsing logic for pending notifications and notification metadata.
- Create tests for eSIM notification parsing and request handling in `esim_notifications_test.go`.
- Introduce email message construction in `email_message.go` to securely format and send emails.
- Add tests for email message encoding and validation in `email_message_test.go`.
- Enhance the CardPolicyAPN component to manage APN configurations, including adding, editing, and deleting custom APNs.
- Implement UI for displaying and managing APN settings with appropriate validation and user feedback.
2026-08-11 18:58:32 +08:00
MengMengCode 48fc4c5ab5 support offline flag 2026-08-11 15:59:47 +08:00
MengMengCode 707ca3c124 feat: enhance URL validation and email address parsing; refactor related components 2026-08-11 02:02:55 +08:00
MengMengCode a09f9af646 feat: update schema version and refactor proxy binding logic
- Increment schema version from 11 to 12.
- Modify ProxyResolver to use ICCID for device proxy binding resolution.
- Update tests to reflect changes in proxy binding logic using ICCID.
- Enhance DeviceBindingsDialog to manage eSIM profile bindings instead of device bindings.
- Update UI components and translations to reflect the new profile binding terminology.
- Implement pagination for automatic task runs in AutomaticTasksPage.
- Create a new Pagination component for better navigation in lists.
2026-08-11 01:23:04 +08:00
MengMengCode 928ba7746e update 2026-08-11 00:22:54 +08:00
MengMengCode 21f210d219 update readme 2026-08-10 23:05:41 +08:00
MengMengCode 8a260e86f1 Implement automatic task management with CRUD operations and UI integration
- Added `automatic_tasks.go` and `automatic_tasks_test.go` for backend logic and testing of automatic tasks.
- Created `automatic_tasks_test.go` to validate task claiming and deletion behavior.
- Developed `AutomaticTasksPage.tsx` for frontend management of automatic tasks, including task creation, editing, and execution.
- Integrated device and eSIM profile selection for task configuration.
- Implemented automatic task scheduling and retry logic in the backend.
2026-08-10 22:15:54 +08:00
MengMengCode 5b8d1a86e8 feat: enhance Telegram call handling with VoWiFi and cellular support 2026-08-10 06:07:56 +08:00
396 changed files with 31295 additions and 1068 deletions
+8
View File
@@ -305,6 +305,14 @@ cd web && npm run build
- [Linux.do](https://linux.do) — An inspiring tech community
- [iniwex5](https://github.com/iniwex5) - Style and Functionality Guidelines
## Buy me a coffee
| Network | Address |
| ------- | ------- |
| USDT-TRON (TRC20) | `TQQAbboBoU8h5xX4YCA1rqWJU2WjK3seSg` |
| USDT-BSC (BEP20) | `0xdbfcd4a462550d6ff06d09cbd89026c6b145d9c4` |
| USDT-Polygon | `0xdbfcd4a462550d6ff06d09cbd89026c6b145d9c4` |
## License
See [LICENSE](LICENSE).
+273 -39
View File
@@ -13,6 +13,7 @@ import (
"os/signal"
"path/filepath"
"strings"
"sync"
"syscall"
"time"
@@ -193,6 +194,7 @@ func run(logger *slog.Logger, logs *loghub.Hub) error {
if err := provisionDiscoveredDevices(startupContext, database, deviceManager); err != nil {
logger.Warn("automatic first-run device provisioning failed", "error", err)
}
configureDeviceBackends(startupContext, logger, database, deviceManager)
restoreDefaultCellularRadios(startupContext, logger, database, deviceManager)
defer func() {
stopContext, cancel := context.WithTimeout(context.Background(), 5*time.Second)
@@ -222,6 +224,7 @@ func run(logger *slog.Logger, logs *loghub.Hub) error {
if err != nil {
return fmt.Errorf("configure VoWiFi runtime: %w", err)
}
go reconcileCardPolicies(pollContext, logger, database, deviceManager, vowifiManager)
defer func() {
stopContext, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
@@ -254,6 +257,7 @@ func run(logger *slog.Logger, logs *loghub.Hub) error {
go handler.StartSMSSyncLoop(pollContext, 15*time.Second)
handler.StartTelegramBot(pollContext)
handler.StartSMSNotificationDispatchers(pollContext)
handler.StartAutomaticTasks(pollContext)
serverConfig := func(handler http.Handler) *http.Server {
return &http.Server{
@@ -341,11 +345,32 @@ func run(logger *slog.Logger, logs *loghub.Hub) error {
return nil
}
// restoreDefaultCellularRadios repairs an interrupted VoWiFi teardown. CFUN=4
// survives process restarts, while the in-memory radio checkpoint does not. If
// VoWiFi is disabled and the current SIM has no explicit airplane policy, the
// automatic/default policy is cellular service and the modem must return to
// CFUN=1.
func configureDeviceBackends(
ctx context.Context,
logger *slog.Logger,
database *store.Store,
manager *device.Manager,
) {
configs, err := database.ListDevices(ctx)
if err != nil {
logger.Warn("configure device backends: list devices", "error", err)
return
}
mapper := integration.ATMapper{Store: database, Devices: manager}
for _, config := range configs {
entry, mapErr := mapper.Get(config.ID)
if mapErr != nil {
continue
}
if err := manager.SetBackend(entry.ID, config.DeviceBackend); err != nil {
logger.Warn("configure device backend", "device_id", config.ID, "backend", config.DeviceBackend, "error", err)
}
}
}
// restoreDefaultCellularRadios applies an explicitly saved cellular policy
// after restart. Missing policies remain RF-off and are claimed by the safe
// default policy; there is no automatic cellular fallback.
func restoreDefaultCellularRadios(
ctx context.Context,
logger *slog.Logger,
@@ -367,11 +392,16 @@ func restoreDefaultCellularRadios(
continue
}
iccid := strings.TrimSpace(entry.Snapshot.ICCID)
if iccid == "" {
continue
}
if iccid != "" {
policy, policyErr := database.CardPolicy(ctx, iccid)
switch {
case policyErr == nil && policy.AirplaneEnabled:
continue
case errors.Is(policyErr, store.ErrNotFound):
continue
case policyErr != nil && !errors.Is(policyErr, store.ErrNotFound):
logger.Warn("startup cellular recovery: read card policy", "device_id", config.ID, "error", policyErr)
continue
@@ -408,13 +438,31 @@ func restoreConfiguredCellularData(
if err != nil {
continue
}
networkRequest := device.NetworkRequest{
Enabled: true, APN: config.APN, IPVersion: "IPV4V6", Backend: config.DeviceBackend,
}
if entry.Snapshot != nil {
iccid := strings.TrimSpace(entry.Snapshot.ICCID)
if policy, policyErr := database.CardPolicy(ctx, iccid); policyErr == nil {
networkRequest.APN = policy.APN
if policy.IPVersion != "" {
networkRequest.IPVersion = policy.IPVersion
}
if profile, profileErr := database.CardAPNProfileByAPN(ctx, iccid, policy.APN, policy.IPVersion); profileErr == nil {
networkRequest.Username = profile.Username
networkRequest.Password = profile.Password
networkRequest.Authentication = profile.AuthType
if entry.Snapshot.RegistrationStatus == 5 && profile.RoamingIPVersion != "" {
networkRequest.IPVersion = profile.RoamingIPVersion
}
}
}
}
dataContext, cancel := context.WithTimeout(ctx, 60*time.Second)
_, err = manager.SetNetwork(dataContext, entry.ID, device.NetworkRequest{
Enabled: true, APN: config.APN, IPVersion: "IPV4V6",
})
_, err = manager.SetNetwork(dataContext, entry.ID, networkRequest)
cancel()
if err != nil {
logger.Warn("startup cellular data recovery failed", "device_id", config.ID, "error", err)
logger.Warn("startup cellular data recovery failed", "device_id", config.ID)
continue
}
logger.Info("restored protected cellular data route", "device_id", config.ID, "interface", config.Interface)
@@ -439,10 +487,10 @@ func disableAllDeveloperCellularData(
continue
}
disableContext, cancel := context.WithTimeout(ctx, 30*time.Second)
_, err = manager.SetNetwork(disableContext, entry.ID, device.NetworkRequest{Enabled: false})
_, err = manager.SetNetwork(disableContext, entry.ID, device.NetworkRequest{Enabled: false, Backend: config.DeviceBackend})
cancel()
if err != nil && ctx.Err() == nil {
logger.Warn("developer cleanup: stop cellular data", "device_id", config.ID, "error", err)
logger.Warn("developer cleanup: stop cellular data", "device_id", config.ID)
}
}
}
@@ -497,6 +545,13 @@ func configureVoWiFiRuntime(
// The test deployment is deliberately non-cellular. VoWiFi teardown
// may restore CFUN, but it must never reactivate a PDP context.
RestoreCellularData: false,
// VoWiFi is always fail-closed with respect to cellular RF. Its teardown
// leaves CFUN=4; only the explicit airplane-mode-off endpoint restores
// CFUN=1.
PureAirplanePolicy: func(deviceID string) bool {
deviceConfig, configErr := database.Device(context.Background(), deviceID)
return configErr == nil && deviceConfig.VoWiFiEnabled
},
})
if err != nil {
return nil, err
@@ -528,6 +583,15 @@ func configureVoWiFiRuntime(
return nil, fmt.Errorf("register device %q VoWiFi runtime: %w", deviceConfig.ID, err)
}
if deviceConfig.VoWiFiEnabled {
if entry, mapErr := mapper.Get(deviceConfig.ID); mapErr == nil {
flightContext, cancelFlight := context.WithTimeout(ctx, 10*time.Second)
_, flightErr := deviceManager.SetFlight(flightContext, entry.ID, true)
cancelFlight()
if flightErr != nil {
_ = manager.Close(context.Background())
return nil, fmt.Errorf("protect device %q before VoWiFi startup: %w", deviceConfig.ID, flightErr)
}
}
if _, err := manager.RequestEnabled(deviceConfig.ID, true); err != nil {
_ = manager.Close(context.Background())
return nil, fmt.Errorf("start device %q VoWiFi policy: %w", deviceConfig.ID, err)
@@ -694,7 +758,7 @@ func provisionDiscoveredDevices(
ESIMTransport: backend,
NetworkEnabled: false,
SMSEnabled: true,
VoWiFiEnabled: false,
VoWiFiEnabled: true,
}); err != nil {
return err
}
@@ -754,20 +818,42 @@ func pollDeviceSnapshots(
logger.Debug("periodic modem discovery failed", "error", err)
return
}
for _, entry := range manager.List() {
// Hotplug can replace the physical discovery ID. Rebind each configured
// device's selected QMI/AT control plane before collecting its snapshot.
configureDeviceBackends(ctx, logger, database, manager)
entries := manager.List()
// Each physical modem owns its own operation lock. Refresh them in
// parallel so a slow or wedged EC20 on one hub port cannot delay signal
// and identity updates for every other modem by 30 seconds at a time.
var refreshGroup sync.WaitGroup
refreshSlots := make(chan struct{}, 4)
for _, entry := range entries {
if !entry.Discovered {
continue
}
refreshContext, cancelRefresh := context.WithTimeout(ctx, 30*time.Second)
snapshot, err := manager.Refresh(refreshContext, entry.ID)
cancelRefresh()
if err != nil && ctx.Err() == nil {
logger.Warn("modem snapshot refresh failed", "device_id", entry.ID, "error", err)
}
if err == nil && ctx.Err() == nil {
enforceCardRegion(ctx, logger, database, manager, entry.ID, &snapshot)
}
entry := entry
refreshGroup.Add(1)
go func() {
defer refreshGroup.Done()
select {
case refreshSlots <- struct{}{}:
defer func() { <-refreshSlots }()
case <-ctx.Done():
return
}
refreshContext, cancelRefresh := context.WithTimeout(ctx, 30*time.Second)
snapshot, refreshErr := manager.Refresh(refreshContext, entry.ID)
cancelRefresh()
if refreshErr != nil && ctx.Err() == nil {
logger.Warn("modem snapshot refresh failed", "device_id", entry.ID, "error", refreshErr)
}
if refreshErr == nil && ctx.Err() == nil {
enforceCardRegion(ctx, logger, database, manager, entry.ID, &snapshot)
enforceDefaultSafeCardPolicy(ctx, logger, database, manager, entry.ID, &snapshot)
}
}()
}
refreshGroup.Wait()
}
refresh()
ticker := time.NewTicker(30 * time.Second)
@@ -782,6 +868,166 @@ func pollDeviceSnapshots(
}
}
// enforceDefaultSafeCardPolicy handles a newly inserted physical SIM or a
// profile that has never had a policy. RF is turned off before the default is
// persisted; the VoWiFi runtime reconciler then starts service asynchronously.
func enforceDefaultSafeCardPolicy(
ctx context.Context,
logger *slog.Logger,
database *store.Store,
manager *device.Manager,
physicalID string,
snapshot *device.Snapshot,
) {
if snapshot == nil || !snapshot.SIMReady || strings.TrimSpace(snapshot.ICCID) == "" ||
device.RegionBlockReason(snapshot.IMSI) != "" {
return
}
iccid := strings.TrimSpace(snapshot.ICCID)
if _, err := database.CardPolicy(ctx, iccid); err == nil {
return
} else if !errors.Is(err, store.ErrNotFound) {
logger.Warn("default card policy: read policy", "iccid", iccid, "error", err)
return
}
flightContext, cancel := context.WithTimeout(ctx, 10*time.Second)
_, err := manager.SetFlight(flightContext, physicalID, true)
cancel()
if err != nil {
logger.Warn("default card policy: failed to establish airplane mode", "device_id", physicalID, "iccid", iccid, "error", err)
return
}
if err := database.UpsertCardPolicy(ctx, store.CardPolicy{
ICCID: iccid, VoWiFiEnabled: true, AirplaneEnabled: true,
IPVersion: "IPV4V6", Source: "default",
}); err != nil {
logger.Warn("default card policy: persist policy", "iccid", iccid, "error", err)
return
}
mapper := integration.ATMapper{Store: database, Devices: manager}
configs, err := database.ListDevices(ctx)
if err != nil {
return
}
for _, config := range configs {
entry, mapErr := mapper.Get(config.ID)
if mapErr != nil || entry.ID != physicalID {
continue
}
config.NetworkEnabled = false
config.VoWiFiEnabled = true
if err := database.UpsertDevice(ctx, config); err != nil {
logger.Warn("default card policy: update device policy", "device_id", config.ID, "error", err)
}
break
}
logger.Info("new SIM protected by default VoWiFi/airplane policy", "device_id", physicalID, "iccid", iccid)
}
func reconcileCardPolicies(
ctx context.Context,
logger *slog.Logger,
database *store.Store,
manager *device.Manager,
vowifiManager *vowifiruntime.Manager,
) {
reconcile := func() {
policies, policyListErr := database.ListCardPolicies(ctx)
if policyListErr == nil {
for _, policy := range policies {
if !policy.VoWiFiEnabled || (policy.AirplaneEnabled && !policy.NetworkEnabled) {
continue
}
policy.AirplaneEnabled = true
policy.NetworkEnabled = false
if err := database.UpsertCardPolicy(ctx, policy); err != nil {
logger.Warn("reconcile card policy: normalize stored RF-safe VoWiFi policy", "iccid", policy.ICCID, "error", err)
}
}
}
configs, err := database.ListDevices(ctx)
if err != nil {
return
}
mapper := integration.ATMapper{Store: database, Devices: manager}
for _, config := range configs {
entry, mapErr := mapper.Get(config.ID)
if mapErr != nil || entry.Snapshot == nil {
continue
}
iccid := strings.TrimSpace(entry.Snapshot.ICCID)
if iccid == "" {
continue
}
policy, policyErr := database.CardPolicy(ctx, iccid)
if policyErr != nil {
continue
}
if policy.VoWiFiEnabled && (!policy.AirplaneEnabled || policy.NetworkEnabled) {
policy.AirplaneEnabled = true
policy.NetworkEnabled = false
if err := database.UpsertCardPolicy(ctx, policy); err != nil {
logger.Warn("reconcile card policy: normalize RF-safe VoWiFi policy", "device_id", config.ID, "iccid", iccid, "error", err)
continue
}
}
deviceChanged := false
if config.VoWiFiEnabled != policy.VoWiFiEnabled || (policy.VoWiFiEnabled && config.NetworkEnabled) {
config.VoWiFiEnabled = policy.VoWiFiEnabled
if policy.VoWiFiEnabled {
config.NetworkEnabled = false
}
deviceChanged = true
}
if config.APN != strings.TrimSpace(policy.APN) {
config.APN = strings.TrimSpace(policy.APN)
deviceChanged = true
}
if deviceChanged {
if err := database.UpsertDevice(ctx, config); err != nil {
logger.Warn("reconcile card policy: update device", "device_id", config.ID, "error", err)
continue
}
}
state, stateErr := vowifiManager.State(config.ID)
if policy.VoWiFiEnabled {
if !entry.Snapshot.FlightMode {
flightContext, cancel := context.WithTimeout(ctx, 10*time.Second)
_, _ = manager.SetFlight(flightContext, entry.ID, true)
cancel()
}
switch {
case stateErr != nil || !state.Enabled:
_, _ = vowifiManager.RequestEnabled(config.ID, true)
case state.ICCID != "" && !strings.EqualFold(strings.TrimSpace(state.ICCID), iccid):
_, _ = vowifiManager.RequestReconnect(config.ID)
}
continue
}
if stateErr == nil && state.Enabled {
_, _ = vowifiManager.RequestEnabled(config.ID, false)
continue
}
if policy.AirplaneEnabled != entry.Snapshot.FlightMode {
flightContext, cancel := context.WithTimeout(ctx, 10*time.Second)
_, _ = manager.SetFlight(flightContext, entry.ID, policy.AirplaneEnabled)
cancel()
}
}
}
reconcile()
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
reconcile()
}
}
}
// cardPolicySourceRegionBlock marks a card policy that was written automatically
// because the inserted SIM belongs to a region the product does not serve. It
// doubles as the persistent record that the radio was forced off by us, so the
@@ -848,10 +1094,10 @@ func enforceCardRegion(
liftCardRegionBlock(ctx, logger, database, manager, id, snapshot)
}
// liftCardRegionBlock reverses an automatic region block once the current SIM
// is positively confirmed to be allowed. It restores the radio only when an
// outstanding auto-forced block exists, so it never overrides a flight mode the
// user enabled deliberately.
// liftCardRegionBlock removes the regional marker once an allowed SIM is
// confirmed. It deliberately does not restore RF: the replacement SIM is
// picked up by enforceDefaultSafeCardPolicy and remains in airplane/VoWiFi
// mode until an explicit user action.
func liftCardRegionBlock(
ctx context.Context,
logger *slog.Logger,
@@ -876,18 +1122,6 @@ func liftCardRegionBlock(
if len(outstanding) == 0 {
return
}
if snapshot.FlightMode {
flightContext, cancelFlight := context.WithTimeout(ctx, 30*time.Second)
_, err := manager.SetFlight(flightContext, id, false)
cancelFlight()
if err != nil && ctx.Err() == nil {
logger.Warn(
"region block: failed to restore radio",
"device_id", id, "error", err,
)
return
}
}
for _, policy := range outstanding {
if err := database.DeleteCardPolicy(ctx, policy.ICCID); err != nil && ctx.Err() == nil {
logger.Warn(
@@ -897,7 +1131,7 @@ func liftCardRegionBlock(
}
}
logger.Info(
"region block lifted; SIM is allowed",
"region marker removed; allowed SIM remains RF protected",
"device_id", id, "iccid", snapshot.ICCID, "imsi", snapshot.IMSI,
)
}
+1 -5
View File
@@ -152,11 +152,7 @@ func TestEnforceCardRegionSkipsRadioWhenAlreadyOff(t *testing.T) {
}
func TestEnforceCardRegionLiftsBlockForAllowedSIM(t *testing.T) {
client := &fakeModemClient{steps: []fakeStep{
{command: "AT+CFUN?", lines: []string{"+CFUN: 4"}},
{command: "AT+CFUN=1"},
{command: "AT+CFUN?", lines: []string{"+CFUN: 1"}},
}}
client := &fakeModemClient{}
manager := newRegionTestManager(t, client)
database := newRegionTestStore(t)
+33
View File
@@ -25,8 +25,11 @@ func Enabled(ctx context.Context, database *store.Store) bool {
const (
EnabledSettingKey = "developer.enabled"
DeviceLimitSettingKey = "developer.device_limit"
SMSHourlyLimitKey = "developer.sms_hourly_limit"
DefaultDeviceLimit = 5
MaxDeviceLimit = 128
DefaultSMSHourlyLimit = 10
MaxSMSHourlyLimit = 1000
)
func DeviceLimit(ctx context.Context, database *store.Store, enabled bool) int {
@@ -57,6 +60,33 @@ func SetDeviceLimit(ctx context.Context, database *store.Store, limit int) error
return database.UpsertAppSetting(ctx, store.AppSetting{Key: DeviceLimitSettingKey, Value: value})
}
// SMSHourlyLimit is enforced regardless of developer mode. Developer mode
// only controls whether administrators can see and modify this value.
func SMSHourlyLimit(ctx context.Context, database *store.Store) int {
setting, err := database.AppSetting(ctx, SMSHourlyLimitKey)
if err != nil {
return DefaultSMSHourlyLimit
}
var document struct {
Limit int `json:"limit"`
}
if json.Unmarshal(setting.Value, &document) != nil || document.Limit < 1 || document.Limit > MaxSMSHourlyLimit {
return DefaultSMSHourlyLimit
}
return document.Limit
}
func SetSMSHourlyLimit(ctx context.Context, database *store.Store, limit int) error {
if limit < 1 || limit > MaxSMSHourlyLimit {
return fmt.Errorf("SMS hourly limit must be between 1 and %d", MaxSMSHourlyLimit)
}
value, err := json.Marshal(map[string]int{"limit": limit})
if err != nil {
return err
}
return database.UpsertAppSetting(ctx, store.AppSetting{Key: SMSHourlyLimitKey, Value: value})
}
// ResetExperimental restores every mutable developer-only setting. It is
// called both by `vocat develop off` and at startup whenever developer mode is
// disabled, so stale database values cannot silently remain active.
@@ -72,6 +102,9 @@ func ResetExperimental(ctx context.Context, database *store.Store) error {
if err := SetDeviceLimit(ctx, database, DefaultDeviceLimit); err != nil {
resetErrors = append(resetErrors, fmt.Errorf("reset device limit: %w", err))
}
if err := SetSMSHourlyLimit(ctx, database, DefaultSMSHourlyLimit); err != nil {
resetErrors = append(resetErrors, fmt.Errorf("reset SMS hourly limit: %w", err))
}
if err := database.DeleteAppSetting(ctx, exportproxy.SettingKey); err != nil && !errors.Is(err, store.ErrNotFound) {
resetErrors = append(resetErrors, fmt.Errorf("delete export proxy configurations: %w", err))
}
+24
View File
@@ -22,6 +22,9 @@ func TestResetExperimentalRestoresDefaults(t *testing.T) {
if err := SetDeviceLimit(ctx, database, 24); err != nil {
t.Fatal(err)
}
if err := SetSMSHourlyLimit(ctx, database, 42); err != nil {
t.Fatal(err)
}
enabled, _ := json.Marshal(map[string]bool{"enabled": true})
if err := database.UpsertAppSetting(ctx, store.AppSetting{Key: httpsmode.SettingKey, Value: enabled}); err != nil {
t.Fatal(err)
@@ -41,6 +44,9 @@ func TestResetExperimentalRestoresDefaults(t *testing.T) {
if limit := DeviceLimit(ctx, database, true); limit != DefaultDeviceLimit {
t.Fatalf("device limit = %d, want %d", limit, DefaultDeviceLimit)
}
if limit := SMSHourlyLimit(ctx, database); limit != DefaultSMSHourlyLimit {
t.Fatalf("SMS hourly limit = %d, want %d", limit, DefaultSMSHourlyLimit)
}
setting, err := database.AppSetting(ctx, httpsmode.SettingKey)
if err != nil {
t.Fatal(err)
@@ -75,3 +81,21 @@ func TestSetDeviceLimitValidatesRange(t *testing.T) {
t.Fatal("out-of-range device limit was accepted")
}
}
func TestSetSMSHourlyLimitValidatesRange(t *testing.T) {
ctx := context.Background()
database, err := store.Open(ctx, filepath.Join(t.TempDir(), "vocat.db"))
if err != nil {
t.Fatal(err)
}
defer database.Close()
if SetSMSHourlyLimit(ctx, database, 0) == nil || SetSMSHourlyLimit(ctx, database, MaxSMSHourlyLimit+1) == nil {
t.Fatal("out-of-range SMS hourly limit was accepted")
}
if err := SetSMSHourlyLimit(ctx, database, 25); err != nil {
t.Fatal(err)
}
if got := SMSHourlyLimit(ctx, database); got != 25 {
t.Fatalf("SMS hourly limit = %d, want 25", got)
}
}
+22
View File
@@ -42,3 +42,25 @@ func CarrierForPLMN(plmn string) (name, countryCode string, ok bool) {
}
return name, countryCode, true
}
// CarrierForIMSI resolves the home PLMN carried by an IMSI. MNCs may contain
// either two or three digits, so prefer an exact six-digit database match and
// then fall back to the five-digit form. This avoids treating the first three
// subscriber digits as a three-digit MNC for networks such as 234-33.
func CarrierForIMSI(imsi string) (plmn, name, countryCode string, ok bool) {
imsi = strings.TrimSpace(imsi)
if !decimalDigits(imsi, 5, 20) {
return "", "", "", false
}
for _, length := range []int{6, 5} {
if len(imsi) < length {
continue
}
candidate := imsi[:length]
carrier, country, found := CarrierForPLMN(candidate)
if found {
return candidate, carrier, country, true
}
}
return "", "", "", false
}
+89 -8
View File
@@ -13,6 +13,40 @@ import (
var apnPattern = regexp.MustCompile(`^[A-Za-z0-9](?:[A-Za-z0-9._-]{0,98}[A-Za-z0-9])?$`)
// ValidAPN reports whether value can safely be used as a modem PDP-context APN.
// An empty value is valid and means that the modem/operator default should be used.
func ValidAPN(value string) bool {
value = strings.TrimSpace(value)
return value == "" || apnPattern.MatchString(value)
}
func validNetworkCredential(value string) bool {
if len(value) > 128 || strings.ContainsAny(value, "\r\n\x00\"") {
return false
}
for _, character := range value {
if character < 0x20 || character == 0x7f {
return false
}
}
return true
}
func normalizeNetworkAuthentication(value string) string {
switch strings.ToUpper(strings.TrimSpace(value)) {
case "", "NONE":
return "NONE"
case "PAP":
return "PAP"
case "CHAP":
return "CHAP"
case "PAP_OR_CHAP":
return "PAP_OR_CHAP"
default:
return ""
}
}
func (manager *Manager) SetNetwork(
ctx context.Context,
id string,
@@ -23,9 +57,16 @@ func (manager *Manager) SetNetwork(
return NetworkResult{}, err
}
apn := strings.TrimSpace(request.APN)
if request.Enabled && apn != "" && !apnPattern.MatchString(apn) {
if request.Enabled && !ValidAPN(apn) {
return NetworkResult{}, ErrInvalidNetworkAPN
}
if !validNetworkCredential(request.Username) || !validNetworkCredential(request.Password) {
return NetworkResult{}, errors.New("APN username or password contains unsupported characters")
}
authentication := normalizeNetworkAuthentication(request.Authentication)
if authentication == "" {
return NetworkResult{}, errors.New("authentication type must be NONE, PAP, CHAP, or PAP_OR_CHAP")
}
ipVersion := normalizeIPVersion(request.IPVersion)
if ipVersion == "" {
return NetworkResult{}, errors.New("IP version must be IP, IPV6, or IPV4V6")
@@ -43,8 +84,29 @@ func (manager *Manager) SetNetwork(
}
}
candidate := manager.candidateFor(state)
if candidate.QMIControl != "" && candidate.NetworkInterface != "" {
return setQMINetwork(ctx, candidate, request.Enabled, apn, ipVersion)
backend := strings.ToLower(strings.TrimSpace(request.Backend))
if backend == "" {
if candidate.QMIControl != "" && candidate.NetworkInterface != "" {
backend = "qmi"
} else {
backend = "at"
}
}
if backend != "at" && backend != "qmi" {
return NetworkResult{}, fmt.Errorf("unsupported cellular data backend %q", request.Backend)
}
if backend == "qmi" {
if candidate.QMIControl == "" || candidate.NetworkInterface == "" {
return NetworkResult{}, fmt.Errorf("%w: QMI control device and network interface are required", ErrDataBackendUnavailable)
}
result, err := setQMINetwork(ctx, candidate, request.Enabled, apn, ipVersion, request.Username, request.Password, authentication)
if err != nil && (request.Username != "" || request.Password != "") {
// qmi-network output is outside our control and may echo values read
// from its temporary profile. Do not return that output when the
// profile contains credentials.
return NetworkResult{}, errors.New("authenticated QMI cellular data operation failed")
}
return result, err
}
client, err := manager.clientLocked(ctx, state, candidate)
@@ -53,13 +115,32 @@ func (manager *Manager) SetNetwork(
return NetworkResult{}, err
}
if request.Enabled {
commands := []string{
fmt.Sprintf(`AT+CGDCONT=1,"%s","%s"`, ipVersion, apn),
"AT+CGATT=1",
"AT+CGACT=1,1",
type networkCommand struct {
value string
sensitive bool
}
commands := []networkCommand{
{value: fmt.Sprintf(`AT+CGDCONT=1,"%s","%s"`, ipVersion, apn)},
}
if authentication != "NONE" {
authCode := map[string]int{"PAP": 1, "CHAP": 2, "PAP_OR_CHAP": 3}[authentication]
commands = append(commands, networkCommand{
value: fmt.Sprintf(`AT+CGAUTH=1,%d,"%s","%s"`, authCode, request.Username, request.Password),
sensitive: true,
})
}
commands = append(commands,
networkCommand{value: "AT+CGATT=1"},
networkCommand{value: "AT+CGACT=1,1"},
)
for _, command := range commands {
if _, err := manager.command(ctx, client, command); err != nil {
var err error
if command.sensitive {
_, err = manager.sensitiveCommand(ctx, client, command.value)
} else {
_, err = manager.command(ctx, client, command.value)
}
if err != nil {
manager.setResult(id, state, nil, err)
return NetworkResult{}, err
}
+16
View File
@@ -23,6 +23,9 @@ func setQMINetwork(
enabled bool,
apn string,
ipVersion string,
username string,
password string,
authentication string,
) (NetworkResult, error) {
qmiNetwork, err := exec.LookPath("qmi-network")
if err != nil {
@@ -39,6 +42,15 @@ func setQMINetwork(
if apn != "" {
profileText = "APN=" + apn + "\n" + profileText
}
if username != "" {
profileText += "APN_USER=" + shellProfileValue(username) + "\n"
}
if password != "" {
profileText += "APN_PASS=" + shellProfileValue(password) + "\n"
}
if authentication != "" && authentication != "NONE" {
profileText += "APN_AUTH=" + shellProfileValue(strings.ToLower(authentication)) + "\n"
}
if _, err := fmt.Fprint(profile, profileText); err != nil {
_ = profile.Close()
return NetworkResult{}, fmt.Errorf("write temporary QMI profile: %w", err)
@@ -110,6 +122,10 @@ func setQMINetwork(
}, nil
}
func shellProfileValue(value string) string {
return "'" + strings.ReplaceAll(value, "'", `'"'"'`) + "'"
}
// exportProxyRouteIdentity must stay in sync with the Export Proxy plugin's
// Linux socket mark. Unmarked host traffic never sees the cellular default
// route; only plugin sockets carrying this mark are policy-routed to it.
+3
View File
@@ -15,6 +15,9 @@ func setQMINetwork(
bool,
string,
string,
string,
string,
string,
) (NetworkResult, error) {
return NetworkResult{}, fmt.Errorf("%w: QMI control is supported only on Linux", ErrDataBackendUnavailable)
}
+49
View File
@@ -3,7 +3,10 @@ package device
import (
"context"
"errors"
"strings"
"testing"
"vocat/internal/modem"
)
func TestSetNetworkATBackendActivatesAndDeactivatesPDP(t *testing.T) {
@@ -35,6 +38,52 @@ func TestSetNetworkATBackendActivatesAndDeactivatesPDP(t *testing.T) {
client.assertDone(t)
}
func TestSetNetworkATBackendAppliesPAPCredentials(t *testing.T) {
client := &transcriptClient{steps: []clientStep{
{command: `AT+CGDCONT=1,"IPV4V6","giffgaff.com"`, response: okResponse()},
{command: `AT+CGAUTH=1,1,"gg","p"`, response: okResponse()},
{command: "AT+CGATT=1", response: okResponse()},
{command: "AT+CGACT=1,1", response: okResponse()},
}}
manager, id := newStartedTestManager(t, client)
if _, err := manager.SetNetwork(context.Background(), id, NetworkRequest{
Enabled: true, APN: "giffgaff.com", IPVersion: "IPV4V6",
Username: "gg", Password: "p", Authentication: "PAP",
}); err != nil {
t.Fatalf("enable authenticated network: %v", err)
}
client.assertDone(t)
}
func TestSetNetworkDoesNotExposeAPNCredentialsInErrorsOrState(t *testing.T) {
const username = "private-user"
const password = "private-password"
command := `AT+CGAUTH=1,1,"` + username + `","` + password + `"`
client := &transcriptClient{steps: []clientStep{
{command: `AT+CGDCONT=1,"IPV4V6","giffgaff.com"`, response: okResponse()},
{command: command, err: &modem.CommandError{Command: command, Final: "ERROR"}},
}}
manager, id := newStartedTestManager(t, client)
_, err := manager.SetNetwork(context.Background(), id, NetworkRequest{
Enabled: true, APN: "giffgaff.com", IPVersion: "IPV4V6",
Username: username, Password: password, Authentication: "PAP",
})
if err == nil {
t.Fatal("SetNetwork() error = nil")
}
if strings.Contains(err.Error(), username) || strings.Contains(err.Error(), password) || strings.Contains(err.Error(), "AT+CGAUTH") {
t.Fatalf("SetNetwork() exposed credentials: %q", err)
}
entry, getErr := manager.Get(id)
if getErr != nil {
t.Fatal(getErr)
}
if strings.Contains(entry.LastError, username) || strings.Contains(entry.LastError, password) || strings.Contains(entry.LastError, "AT+CGAUTH") {
t.Fatalf("device state exposed credentials: %q", entry.LastError)
}
client.assertDone(t)
}
func TestSetNetworkRejectsUnsafeAPNBeforeOpeningModem(t *testing.T) {
client := &transcriptClient{}
manager, id := newStartedTestManager(t, client)
+86 -49
View File
@@ -3,14 +3,18 @@ package device
import (
"bytes"
"context"
"crypto/tls"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"strings"
"time"
"vocat/internal/netguard"
)
// es9pClient speaks SGP.22 ES9+ — JSON over HTTPS — to one SM-DP+. It is the
@@ -24,25 +28,33 @@ import (
// header.functionExecutionStatus (with statusCodeData.message holding the
// human-readable failure, e.g. "The matchingID is not found").
type es9pClient struct {
smdp string
http *http.Client
smdp string
endpoint *url.URL
http *http.Client
}
func newES9PClient(smdp string) *es9pClient {
// The eUICC — not the host — is the root of trust for RSP: during
// AuthenticateServer the card verifies the SM-DP+'s CERT.DPauth.SIG against
// its embedded CI root, so a rogue/TLS-MitM server cannot forge a signature
// the card will accept. The host TLS layer is transport only, and a minimal
// embedded box may ship no CA bundle (this is exactly what broke on the test
// machine), so we don't anchor host TLS to system roots. InsecureSkipVerify
// is safe here specifically because the card does the authoritative check.
transport := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec // eUICC is the RSP trust anchor
var smdpAddressPattern = regexp.MustCompile(`^(?:[A-Za-z0-9](?:[A-Za-z0-9.-]{0,251}[A-Za-z0-9])?|\[[0-9A-Fa-f:.]+\])(?::[0-9]{1,5})?$`)
func newES9PClient(ctx context.Context, smdp string) (*es9pClient, error) {
smdp = strings.TrimSpace(smdp)
if !smdpAddressPattern.MatchString(smdp) {
return nil, errors.New("esim: SM-DP+ address must be a hostname with an optional port")
}
candidate, err := url.Parse("https://" + smdp)
if err != nil || candidate.Hostname() == "" || candidate.User != nil ||
(candidate.Path != "" && candidate.Path != "/") || candidate.RawQuery != "" || candidate.Fragment != "" {
return nil, errors.New("esim: SM-DP+ address must be a hostname with an optional port")
}
candidate.Path = ""
validated, err := netguard.ValidatePublicURL(ctx, candidate.String(), true)
if err != nil {
return nil, fmt.Errorf("esim: unsafe SM-DP+ address: %w", err)
}
return &es9pClient{
smdp: strings.TrimSpace(smdp),
http: &http.Client{Timeout: 90 * time.Second, Transport: transport},
}
smdp: validated.Host,
endpoint: validated,
http: netguard.NewPublicHTTPClient(90*time.Second, true),
}, nil
}
// es9pError is a failed ES9+ functionExecutionStatus. Message is the SM-DP+'s
@@ -80,12 +92,13 @@ type es9pStatusCodeData struct {
// is decided the way lpac decides it: a non-success execution status, or a
// missing required output field, yields an es9pError carrying the SM-DP+ message.
func (c *es9pClient) call(ctx context.Context, function string, request map[string]string, requiredOut ...string) (map[string]json.RawMessage, error) {
url := "https://" + c.smdp + "/gsma/rsp2/es9plus/" + function
endpoint := *c.endpoint
endpoint.Path = "/gsma/rsp2/es9plus/" + function
body, err := json.Marshal(request)
if err != nil {
return nil, err
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), bytes.NewReader(body))
if err != nil {
return nil, err
}
@@ -149,31 +162,31 @@ func es9pErrFromStatus(function, status string, scd *es9pStatusCodeData) error {
// human-readable failure when the SM-DP+ omits statusCodeData.message. Table
// mirrors lpac's euicc/es9p_errors.c.
var es9pErrorTable = map[[2]string]string{
{"8.1", "4.8"}: "eUICC does not have sufficient space for this Profile",
{"8.1", "6.1"}: "eUICC signature is invalid or serverChallenge is invalid",
{"8.1.1", "2.2"}: "EID is missing in the context of this order",
{"8.1.1", "3.1"}: "a different EID is already associated with this ICCID",
{"8.1.1", "3.8"}: "EID doesn't match the expected value",
{"8.1.2", "6.1"}: "EUM Certificate is invalid",
{"8.1.2", "6.3"}: "EUM Certificate has expired",
{"8.1.3", "6.1"}: "eUICC Certificate is invalid",
{"8.1.3", "6.3"}: "eUICC Certificate has expired",
{"8.2", "1.2"}: "Profile has not yet been released",
{"8.2", "3.7"}: "BPP is not available for a new binding",
{"8.2.5", "3.7"}: "No more Profile available for the requested Profile Type",
{"8.2.5", "4.3"}: "No eligible Profile for this eUICC/Device",
{"8.2.6", "3.1"}: "a different MatchingID is associated with this ICCID",
{"8.2.6", "3.3"}: "Conflicting MatchingID value",
{"8.2.6", "3.8"}: "MatchingID (AC_Token or EventID) is refused",
{"8.2.7", "2.2"}: "Confirmation Code is missing",
{"8.2.7", "3.8"}: "Confirmation Code is refused",
{"8.2.7", "6.4"}: "maximum number of retries for the Confirmation Code exceeded",
{"8.8.1", "3.8"}: "Invalid SM-DP+ Address",
{"8.8.4", "3.7"}: "The SM-DP+ has no CERT.DPauth.ECDSA signed by one of the CI Public Key supported by the eUICC",
{"8.8.5", "4.1"}: "The Download order has expired",
{"8.8.5", "6.4"}: "maximum number of retries for the Profile download order exceeded",
{"8.10.1", "3.9"}: "The RSP session identified by the TransactionID is unknown",
{"8.11.1", "3.9"}: "Unknown CI Public Key. The CI used by the EUM Certificate is not a trusted root.",
{"8.1", "4.8"}: "eUICC does not have sufficient space for this Profile",
{"8.1", "6.1"}: "eUICC signature is invalid or serverChallenge is invalid",
{"8.1.1", "2.2"}: "EID is missing in the context of this order",
{"8.1.1", "3.1"}: "a different EID is already associated with this ICCID",
{"8.1.1", "3.8"}: "EID doesn't match the expected value",
{"8.1.2", "6.1"}: "EUM Certificate is invalid",
{"8.1.2", "6.3"}: "EUM Certificate has expired",
{"8.1.3", "6.1"}: "eUICC Certificate is invalid",
{"8.1.3", "6.3"}: "eUICC Certificate has expired",
{"8.2", "1.2"}: "Profile has not yet been released",
{"8.2", "3.7"}: "BPP is not available for a new binding",
{"8.2.5", "3.7"}: "No more Profile available for the requested Profile Type",
{"8.2.5", "4.3"}: "No eligible Profile for this eUICC/Device",
{"8.2.6", "3.1"}: "a different MatchingID is associated with this ICCID",
{"8.2.6", "3.3"}: "Conflicting MatchingID value",
{"8.2.6", "3.8"}: "MatchingID (AC_Token or EventID) is refused",
{"8.2.7", "2.2"}: "Confirmation Code is missing",
{"8.2.7", "3.8"}: "Confirmation Code is refused",
{"8.2.7", "6.4"}: "maximum number of retries for the Confirmation Code exceeded",
{"8.8.1", "3.8"}: "Invalid SM-DP+ Address",
{"8.8.4", "3.7"}: "The SM-DP+ has no CERT.DPauth.ECDSA signed by one of the CI Public Key supported by the eUICC",
{"8.8.5", "4.1"}: "The Download order has expired",
{"8.8.5", "6.4"}: "maximum number of retries for the Profile download order exceeded",
{"8.10.1", "3.9"}: "The RSP session identified by the TransactionID is unknown",
{"8.11.1", "3.9"}: "Unknown CI Public Key. The CI used by the EUM Certificate is not a trusted root.",
}
func es9pErrorMessage(subjectCode, reasonCode string) string {
@@ -254,10 +267,10 @@ func (c *es9pClient) initiateAuthentication(ctx context.Context, euiccChallenge,
// es9pAuthenticateResult carries the profile metadata and the SM-DP+ download
// authorization needed for PrepareDownload.
type es9pAuthenticateResult struct {
TransactionID string
TransactionID string
ProfileMetadata []byte
SmdpSigned2 []byte
SmdpSignature2 []byte
SmdpSigned2 []byte
SmdpSignature2 []byte
SmdpCertificate []byte
}
@@ -287,7 +300,7 @@ func (c *es9pClient) authenticateClient(ctx context.Context, transactionID strin
func (c *es9pClient) getBoundProfilePackage(ctx context.Context, transactionID string, prepareDownloadResponse []byte) ([]byte, error) {
root, err := c.call(ctx, "getBoundProfilePackage", map[string]string{
"transactionId": transactionID,
"transactionId": transactionID,
"prepareDownloadResponse": es9pBase64Encode(prepareDownloadResponse),
}, "boundProfilePackage")
if err != nil {
@@ -300,10 +313,34 @@ func (c *es9pClient) getBoundProfilePackage(ctx context.Context, transactionID s
// for the download case). It is best-effort: the profile is already installed, so
// a notification failure is reported by the caller as a warning, not a failure.
func (c *es9pClient) handleNotification(ctx context.Context, pendingNotification []byte) error {
_, err := c.call(ctx, "handleNotification", map[string]string{
endpoint := *c.endpoint
endpoint.Path = "/gsma/rsp2/es9plus/handleNotification"
body, err := json.Marshal(map[string]string{
"pendingNotification": es9pBase64Encode(pendingNotification),
})
return err
if err != nil {
return err
}
request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), bytes.NewReader(body))
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("User-Agent", "gsma-rsp-lpad")
request.Header.Set("X-Admin-Protocol", "gsma/rsp/v2.2.2")
response, err := c.http.Do(request)
if err != nil {
return fmt.Errorf("es9p handleNotification: %w", err)
}
defer response.Body.Close()
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 1<<20))
// SGP.22 defines HandleNotification as a notification-handler function:
// success is an empty HTTP 204 response, not the JSON envelope returned by
// ordinary ES9+ request-response functions.
if response.StatusCode != http.StatusNoContent {
return fmt.Errorf("es9p handleNotification: receiver returned HTTP %d", response.StatusCode)
}
return nil
}
// cancelSession aborts an in-flight download so the SM-DP+ releases the
+56 -3
View File
@@ -7,6 +7,7 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
)
@@ -16,9 +17,15 @@ func newTestES9P(t *testing.T, handler http.HandlerFunc) *es9pClient {
t.Helper()
server := httptest.NewTLSServer(handler)
t.Cleanup(server.Close)
client := newES9PClient(strings.TrimPrefix(server.URL, "https://"))
client.http = server.Client()
return client
endpoint, err := url.Parse(server.URL)
if err != nil {
t.Fatal(err)
}
return &es9pClient{
smdp: strings.TrimPrefix(server.URL, "https://"),
endpoint: endpoint,
http: server.Client(),
}
}
func successEnvelope(fields map[string]any) map[string]any {
@@ -33,6 +40,21 @@ func successEnvelope(fields map[string]any) map[string]any {
func b64(value []byte) string { return base64.StdEncoding.EncodeToString(value) }
func TestNewES9PClientRejectsUnsafeAddress(t *testing.T) {
for _, address := range []string{
"https://rsp.example.com",
"127.0.0.1",
"169.254.169.254",
"rsp.example.com/unexpected/path",
"user:[email protected]",
"rsp.example.com\r\nX-Injected: yes",
} {
if _, err := newES9PClient(context.Background(), address); err == nil {
t.Errorf("newES9PClient(%q) accepted an unsafe address", address)
}
}
}
func TestInitiateAuthenticationSuccess(t *testing.T) {
signed1 := []byte{0x30, 0x03, 0x80, 0x01, 0x09}
client := newTestES9P(t, func(w http.ResponseWriter, r *http.Request) {
@@ -141,3 +163,34 @@ func TestGetBoundProfilePackageSuccess(t *testing.T) {
t.Fatalf("bpp = %X, want %X", got, pkg)
}
}
func TestHandleNotificationRequiresHTTP204(t *testing.T) {
pending := []byte{0xBF, 0x37, 0x00}
client := newTestES9P(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/gsma/rsp2/es9plus/handleNotification" {
t.Errorf("path = %s", r.URL.Path)
}
if r.Header.Get("X-Admin-Protocol") != "gsma/rsp/v2.2.2" {
t.Errorf("X-Admin-Protocol = %q", r.Header.Get("X-Admin-Protocol"))
}
var request map[string]string
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
t.Errorf("decode request: %v", err)
}
decoded, err := base64.StdEncoding.DecodeString(request["pendingNotification"])
if err != nil || !bytes.Equal(decoded, pending) {
t.Errorf("pendingNotification = %q (%X), err=%v", request["pendingNotification"], decoded, err)
}
w.WriteHeader(http.StatusNoContent)
})
if err := client.handleNotification(context.Background(), pending); err != nil {
t.Fatalf("handleNotification: %v", err)
}
client = newTestES9P(t, func(w http.ResponseWriter, _ *http.Request) {
_ = json.NewEncoder(w).Encode(successEnvelope(nil))
})
if err := client.handleNotification(context.Background(), pending); err == nil || !strings.Contains(err.Error(), "HTTP 200") {
t.Fatalf("HTTP 200 error = %v", err)
}
}
+59 -23
View File
@@ -26,6 +26,12 @@ import (
// isdRAID is the standard ISD-R AID that hosts the LPA functions (ES10).
const isdRAID = "A0000005591010FFFFFFFF8900000100"
// xesimISDRAID is the alternate ISD-R application exposed by XeSIM cards.
// It implements the same ES10 interface, but is not selectable through the
// standard ...0100 AID. Selecting it is a read-only capability probe; profile
// state is never changed during discovery.
const xesimISDRAID = "A0000005591010FFFFFFFF8900000177"
// eSTK multi-SE products expose each eUICC storage through its own vendor
// ISD-R AID. The standard GSMA AID aliases one of them, so probing only that
// AID silently hides the second storage.
@@ -296,20 +302,32 @@ func (manager *Manager) openEuiccOnceAID(ctx context.Context, id, aidHex string)
return channel, nil
}
// discoverEuiccAIDs detects eSTK multi-SE cards without changing any profile
// state. The vendor product applet is selected only as a read-only capability
// probe; when present, both vendor ISD-R AIDs are tried. Per OpenEUICC's eSTK
// integration, the generic GSMA AID is not appended after an eSTK SE opens,
// because it aliases one of the same storages.
// discoverEuiccAIDs detects eSTK multi-SE and alternate-ISD-R cards without
// changing any profile state. The vendor product applet and candidate ISD-R
// applications are selected only as read-only capability probes. Per
// OpenEUICC's eSTK integration, generic AIDs are not appended after an eSTK SE
// opens, because the standard AID aliases one of the same storages.
func (manager *Manager) discoverEuiccAIDs(ctx context.Context, id string) []string {
product, err := manager.openEuiccAID(ctx, id, estkProductAID)
if err != nil {
return []string{isdRAID}
if err == nil {
product.close(context.Background())
var found []string
for _, aid := range []string{estkSE0AID, estkSE1AID} {
channel, err := manager.openEuiccAID(ctx, id, aid)
if err != nil {
continue
}
channel.close(context.Background())
found = append(found, aid)
}
if len(found) > 0 {
return found
}
}
product.close(context.Background())
var found []string
for _, aid := range []string{estkSE0AID, estkSE1AID} {
for _, aid := range []string{isdRAID, xesimISDRAID} {
channel, err := manager.openEuiccAID(ctx, id, aid)
if err != nil {
continue
@@ -317,10 +335,12 @@ func (manager *Manager) discoverEuiccAIDs(ctx context.Context, id string) []stri
channel.close(context.Background())
found = append(found, aid)
}
if len(found) == 0 {
return []string{isdRAID}
if len(found) > 0 {
return found
}
return found
// Preserve the old error path for a physical SIM with no eUICC. The caller
// retries the standard AID once and returns ErrNoEUICC to the HTTP layer.
return []string{isdRAID}
}
func isTransientEuiccCME(err error) bool {
@@ -531,18 +551,27 @@ func (manager *Manager) ESIMListProfiles(ctx context.Context, id string) (EsimIn
}
return EsimInfo{}, errESIMRecovering
}
channel, err := manager.openEuicc(ctx, id)
if err != nil {
return EsimInfo{}, err
var lastErr error
for _, aid := range manager.discoverEuiccAIDs(ctx, id) {
channel, err := manager.openEuiccAID(ctx, id, aid)
if err != nil {
lastErr = err
continue
}
payload, err := channel.es10(ctx, []byte{0xBF, 0x2D, 0x00}) // GetProfilesInfo
channel.close(context.Background())
if err != nil {
lastErr = err
continue
}
info := EsimInfo{AID: aid, Profiles: parseProfilesInfo(payload)}
manager.cacheESIMInfo(id, info)
return info, nil
}
defer channel.close(context.Background())
payload, err := channel.es10(ctx, []byte{0xBF, 0x2D, 0x00}) // GetProfilesInfo
if err != nil {
return EsimInfo{}, err
if lastErr != nil {
return EsimInfo{}, lastErr
}
info := EsimInfo{Profiles: parseProfilesInfo(payload)}
manager.cacheESIMInfo(id, info)
return info, nil
return EsimInfo{}, ErrNoEUICC
}
// ESIMSwitchProfile enables one profile by ICCID via ES10c EnableProfile.
@@ -774,7 +803,14 @@ func (manager *Manager) refreshAfterProfileSwitch(id string) {
time.Sleep(settle)
for attempt := 0; attempt < attempts; attempt++ {
ctx, cancel := context.WithTimeout(context.Background(), manager.commandTimeout*4)
_, err := manager.Refresh(ctx, id)
_, _ = manager.Discover(ctx)
_, flightErr := manager.SetFlight(ctx, id, true)
var err error
if flightErr == nil {
_, err = manager.Refresh(ctx, id)
} else {
err = flightErr
}
cancel()
if err == nil {
return
+13 -1
View File
@@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"strings"
"time"
)
var (
@@ -101,13 +102,24 @@ func (manager *Manager) ESIMDeleteProfile(ctx context.Context, id, iccid, aidHex
}
deleted := &EsimDeleteResult{}
var warnings []string
if info2, infoErr := channel.getEUICCInfo2(ctx); infoErr == nil {
if freeAfter, afterKnown := euiccFreeNVRAM(info2); beforeKnown && afterKnown && freeAfter >= freeBefore {
deleted.SpaceDelta = int64(freeAfter - freeBefore)
}
} else {
deleted.Warning = "Profile was deleted, but reclaimed storage could not be read"
warnings = append(warnings, "Profile 已删除,但无法读取释放的存储空间")
}
// DeleteProfile creates a signed notification only when the Profile metadata
// configured a receiver. Flush all retained notifications so earlier events
// for the same receiver cannot be overtaken by this delete event.
notifyContext, cancelNotify := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Minute)
notifyErr := channel.deliverPendingNotifications(notifyContext)
cancelNotify()
if notifyErr != nil {
warnings = append(warnings, "Profile 已删除,但运营商通知发送失败;通知已保留在 eUICC,可稍后重发")
}
deleted.Warning = strings.Join(warnings, "")
manager.removeCachedProfile(id, strings.TrimSpace(iccid))
return deleted, nil
}
+39 -17
View File
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"strings"
"time"
)
// EsimDownloadParams are the SPA download form fields, mapped from the
@@ -74,7 +75,10 @@ func (manager *Manager) ESIMDownloadProfile(ctx context.Context, id string, para
return nil, err
}
client := newES9PClient(smdp)
client, err := newES9PClient(ctx, smdp)
if err != nil {
return nil, err
}
report("auth_client", "正在向 SM-DP+ 进行客户端身份认证...", 30)
init, err := client.initiateAuthentication(ctx, challenge, info1)
@@ -127,15 +131,24 @@ func (manager *Manager) ESIMDownloadProfile(ctx context.Context, id string, para
if err != nil {
return nil, err
}
iccid, err := installationResult(installResponse)
if err != nil {
return nil, err
}
report("notify", "正在向运营商发送下载通知...", 90)
iccid, installErr := installationResult(installResponse)
warning := ""
if err := client.handleNotification(ctx, installResponse); err != nil {
warning = "Profile 已安装,但下载通知发送失败"
notification, notificationErr := parsePendingNotification(installResponse)
if notificationErr == nil {
// Loading the final BPP segment is the commit point. Finish the operator
// acknowledgement even if the browser closes its SSE connection now.
notifyContext, cancelNotify := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Minute)
notificationErr = channel.deliverNotification(notifyContext, notification)
cancelNotify()
}
if notificationErr != nil {
warning = "Profile 安装结果已保留在 eUICC,但向运营商上报失败,可在当前通知列表中重发"
}
// Error installation results must be reported too. Return the card-side
// installation failure only after making that best-effort ES9+ attempt.
if installErr != nil {
return nil, installErr
}
freeAfter := freeBefore
@@ -217,17 +230,26 @@ type EsimChipInfo struct {
func (manager *Manager) ESIMChipInfo(ctx context.Context, id string) (*EsimChipInfo, error) {
manager.esimMu.Lock()
defer manager.esimMu.Unlock()
channel, err := manager.openEuicc(ctx, id)
if err != nil {
return nil, err
}
defer channel.close(context.Background())
info, err := readEsimChipInfo(ctx, channel, isdRAID)
if err != nil {
return nil, err
var lastErr error
for _, aid := range manager.discoverEuiccAIDs(ctx, id) {
channel, err := manager.openEuiccAID(ctx, id, aid)
if err != nil {
lastErr = err
continue
}
info, err := readEsimChipInfo(ctx, channel, aid)
channel.close(context.Background())
if err != nil {
lastErr = err
continue
}
return &info, nil
}
return &info, nil
if lastErr != nil {
return nil, lastErr
}
return nil, ErrNoEUICC
}
func readEsimChipInfo(ctx context.Context, channel *euiccChannel, aidHex string) (EsimChipInfo, error) {
+354
View File
@@ -0,0 +1,354 @@
package device
import (
"context"
"encoding/hex"
"errors"
"fmt"
"sort"
"strings"
)
// EsimNotification is one notification retained by an eUICC until its receiver
// acknowledges it through ES9+.HandleNotification.
type EsimNotification struct {
SequenceNumber uint64 `json:"sequenceNumber"`
Event string `json:"event,omitempty"`
ICCID string `json:"iccid,omitempty"`
Address string `json:"address,omitempty"`
AIDHex string `json:"aidHex,omitempty"`
CanRetry bool `json:"canRetry"`
raw []byte
}
func encodePositiveInteger(value uint64) []byte {
if value == 0 {
return []byte{0}
}
encoded := make([]byte, 8)
for index := len(encoded) - 1; index >= 0; index-- {
encoded[index] = byte(value & 0xff)
value >>= 8
}
for len(encoded) > 1 && encoded[0] == 0 {
encoded = encoded[1:]
}
if encoded[0]&0x80 != 0 {
encoded = append([]byte{0}, encoded...)
}
return encoded
}
func decodePositiveInteger(encoded []byte) (uint64, bool) {
if len(encoded) == 0 || len(encoded) > 9 || encoded[0]&0x80 != 0 {
return 0, false
}
if len(encoded) == 9 {
if encoded[0] != 0 {
return 0, false
}
encoded = encoded[1:]
}
var value uint64
for _, octet := range encoded {
value = value<<8 | uint64(octet)
}
return value, true
}
func buildRetrieveNotificationsRequest(sequenceNumber *uint64) []byte {
if sequenceNumber == nil {
return derConstruct(0xBF2B)
}
return derConstruct(0xBF2B, derEncode(0x80, encodePositiveInteger(*sequenceNumber)))
}
func buildListNotificationsRequest() []byte {
return derConstruct(0xBF28)
}
func buildRemoveNotificationRequest(sequenceNumber uint64) []byte {
return derConstruct(0xBF30, derEncode(0x80, encodePositiveInteger(sequenceNumber)))
}
func notificationEventName(bitString []byte) string {
if len(bitString) < 2 || bitString[0] > 7 {
return ""
}
bitCount := (len(bitString)-1)*8 - int(bitString[0])
for bit := 0; bit < bitCount; bit++ {
if bitString[1+bit/8]&(0x80>>uint(bit%8)) == 0 {
continue
}
switch bit {
case 0:
return "install"
case 1, 4:
return "enable"
case 2, 5:
return "disable"
case 3, 6:
return "delete"
case 7:
return "rpm"
default:
return fmt.Sprintf("event-%d", bit)
}
}
return ""
}
func notificationFromMetadata(metadata *derNode) (EsimNotification, error) {
sequenceNumber, ok := decodePositiveInteger(derValue(metadata.children, 0x80))
if !ok {
return EsimNotification{}, errors.New("esim: pending notification has an invalid sequence number")
}
address := strings.TrimSpace(string(derValue(metadata.children, 0x0C)))
if address == "" {
return EsimNotification{}, errors.New("esim: pending notification has no receiver address")
}
return EsimNotification{
SequenceNumber: sequenceNumber,
Event: notificationEventName(derValue(metadata.children, 0x81)),
ICCID: decodeICCID(derValue(metadata.children, 0x5A)),
Address: address,
CanRetry: true,
}, nil
}
func parsePendingNotification(raw []byte) (EsimNotification, error) {
metadataNodes := derFindAll(derParse(raw), 0xBF2F)
if len(metadataNodes) == 0 {
return EsimNotification{}, errors.New("esim: pending notification has no metadata")
}
notification, err := notificationFromMetadata(metadataNodes[0])
if err != nil {
return EsimNotification{}, err
}
notification.raw = append([]byte(nil), raw...)
return notification, nil
}
func parseNotificationMetadataList(payload []byte) ([]EsimNotification, error) {
tag, headerLength, totalLength, err := derElementAt(payload, 0)
if err != nil || tag != 0xBF28 || totalLength != len(payload) {
return nil, fmt.Errorf("esim: unexpected ListNotification response %s", strings.ToUpper(hex.EncodeToString(payload)))
}
value := payload[headerLength:totalLength]
responseNodes := derParse(value)
if len(responseNodes) == 1 && (responseNodes[0].tag == 0x81 || responseNodes[0].tag == 0x80 || responseNodes[0].tag == 0x02) {
return nil, fmt.Errorf("esim: eUICC could not list notifications (result %X)", responseNodes[0].value)
}
metadataNodes := derFindAll(responseNodes, 0xBF2F)
notifications := make([]EsimNotification, 0, len(metadataNodes))
for _, metadata := range metadataNodes {
notification, parseErr := notificationFromMetadata(metadata)
if parseErr != nil {
return nil, parseErr
}
notifications = append(notifications, notification)
}
sort.SliceStable(notifications, func(left, right int) bool {
if notifications[left].Address == notifications[right].Address {
return notifications[left].SequenceNumber < notifications[right].SequenceNumber
}
return notifications[left].Address < notifications[right].Address
})
return notifications, nil
}
func parsePendingNotifications(payload []byte) ([]EsimNotification, error) {
tag, headerLength, totalLength, err := derElementAt(payload, 0)
if err != nil || tag != 0xBF2B || totalLength != len(payload) {
return nil, fmt.Errorf("esim: unexpected RetrieveNotificationsList response %s", strings.ToUpper(hex.EncodeToString(payload)))
}
value := payload[headerLength:totalLength]
responseNodes := derParse(value)
if len(responseNodes) == 1 && (responseNodes[0].tag == 0x81 || responseNodes[0].tag == 0x80 || responseNodes[0].tag == 0x02) {
errorCode := responseNodes[0].value
return nil, fmt.Errorf("esim: eUICC could not retrieve notifications (result %X)", errorCode)
}
// The notificationList CHOICE alternative is encoded as context tag A0 by
// AUTOMATIC TAGS on newer eUICCs. Older cards are also seen returning the
// SEQUENCE OF contents directly. Accept both without including the list
// wrapper in the PendingNotification sent to ES9+.
if len(responseNodes) == 1 && responseNodes[0].tag == 0xA0 {
value = responseNodes[0].value
} else if len(responseNodes) == 1 && responseNodes[0].tag == 0x30 && firstChild(responseNodes[0].children, 0xBF2F) == nil {
value = responseNodes[0].value
}
var notifications []EsimNotification
for offset := 0; offset < len(value); {
_, _, elementLength, elementErr := derElementAt(value, offset)
if elementErr != nil {
return nil, elementErr
}
raw := value[offset : offset+elementLength]
notification, parseErr := parsePendingNotification(raw)
if parseErr != nil {
return nil, parseErr
}
notifications = append(notifications, notification)
offset += elementLength
}
sort.SliceStable(notifications, func(left, right int) bool {
if notifications[left].Address == notifications[right].Address {
return notifications[left].SequenceNumber < notifications[right].SequenceNumber
}
return notifications[left].Address < notifications[right].Address
})
return notifications, nil
}
func removeNotificationResult(payload []byte) error {
roots := derParse(payload)
if len(roots) != 1 || roots[0].tag != 0xBF30 {
return fmt.Errorf("esim: unexpected RemoveNotificationFromList response %s", strings.ToUpper(hex.EncodeToString(payload)))
}
result := derValue(roots[0].children, 0x80)
if len(result) == 0 {
result = derValue(roots[0].children, 0x02)
}
if len(result) != 1 {
return fmt.Errorf("esim: malformed RemoveNotificationFromList response %s", strings.ToUpper(hex.EncodeToString(payload)))
}
switch result[0] {
case 0, 1: // ok, or already removed after an earlier acknowledged retry
return nil
default:
return fmt.Errorf("esim: eUICC could not remove notification (result %d)", result[0])
}
}
func (channel *euiccChannel) retrieveNotifications(ctx context.Context, sequenceNumber *uint64) ([]EsimNotification, error) {
payload, err := channel.es10(ctx, buildRetrieveNotificationsRequest(sequenceNumber))
if err != nil {
return nil, err
}
return parsePendingNotifications(payload)
}
func (channel *euiccChannel) listNotifications(ctx context.Context) ([]EsimNotification, error) {
payload, err := channel.es10(ctx, buildListNotificationsRequest())
if err != nil {
return nil, err
}
return parseNotificationMetadataList(payload)
}
func (channel *euiccChannel) removeNotification(ctx context.Context, sequenceNumber uint64) error {
payload, err := channel.es10(ctx, buildRemoveNotificationRequest(sequenceNumber))
if err != nil {
return err
}
return removeNotificationResult(payload)
}
func (channel *euiccChannel) deliverNotification(ctx context.Context, notification EsimNotification) error {
client, err := newES9PClient(ctx, notification.Address)
if err != nil {
return err
}
if err := client.handleNotification(ctx, notification.raw); err != nil {
return err
}
if err := channel.removeNotification(ctx, notification.SequenceNumber); err != nil {
return fmt.Errorf("notification acknowledged but could not be removed from eUICC: %w", err)
}
return nil
}
// deliverPendingNotifications sends each receiver's notifications oldest first.
// A failed item stops only that receiver's group so a later sequence number can
// never overtake it and make the older notification stale.
func (channel *euiccChannel) deliverPendingNotifications(ctx context.Context) error {
notifications, err := channel.listNotifications(ctx)
if err != nil {
return err
}
blockedAddresses := make(map[string]bool)
var failures []error
for _, notification := range notifications {
if blockedAddresses[notification.Address] {
continue
}
pending, retrieveErr := channel.retrieveNotifications(ctx, &notification.SequenceNumber)
if retrieveErr == nil {
retrieveErr = fmt.Errorf("esim: notification %d was not returned by eUICC", notification.SequenceNumber)
for _, candidate := range pending {
if candidate.SequenceNumber == notification.SequenceNumber {
retrieveErr = channel.deliverNotification(ctx, candidate)
break
}
}
}
if retrieveErr != nil {
blockedAddresses[notification.Address] = true
failures = append(failures, fmt.Errorf("notification %d to %s: %w", notification.SequenceNumber, notification.Address, retrieveErr))
}
}
return errors.Join(failures...)
}
// ESIMNotifications returns the notifications retained across every eUICC
// storage exposed by the physical card.
func (manager *Manager) ESIMNotifications(ctx context.Context, id string) ([]EsimNotification, error) {
manager.esimMu.Lock()
defer manager.esimMu.Unlock()
if err := manager.waitForESIMRecovery(ctx, id); err != nil {
return nil, err
}
var all []EsimNotification
var lastErr error
succeeded := false
for _, aid := range manager.discoverEuiccAIDs(ctx, id) {
channel, err := manager.openEuiccAID(ctx, id, aid)
if err != nil {
lastErr = err
continue
}
notifications, retrieveErr := channel.listNotifications(ctx)
channel.close(context.Background())
if retrieveErr != nil {
lastErr = retrieveErr
continue
}
succeeded = true
for index := range notifications {
notifications[index].AIDHex = aid
}
all = append(all, notifications...)
}
if !succeeded && lastErr != nil {
return nil, lastErr
}
return all, nil
}
// ESIMRetryNotification sends one retained notification and removes it from the
// eUICC only after the receiver returns the SGP.22 success acknowledgement.
func (manager *Manager) ESIMRetryNotification(ctx context.Context, id, aidHex string, sequenceNumber uint64) error {
manager.esimMu.Lock()
defer manager.esimMu.Unlock()
if err := manager.waitForESIMRecovery(ctx, id); err != nil {
return err
}
channel, err := manager.openEuiccAID(ctx, id, targetEuiccAID(aidHex))
if err != nil {
return err
}
defer channel.close(context.Background())
notifications, err := channel.retrieveNotifications(ctx, &sequenceNumber)
if err != nil {
return err
}
for _, notification := range notifications {
if notification.SequenceNumber == sequenceNumber {
return channel.deliverNotification(ctx, notification)
}
}
return fmt.Errorf("esim: notification %d was not found", sequenceNumber)
}
@@ -0,0 +1,96 @@
package device
import (
"bytes"
"testing"
)
func TestPositiveIntegerEncodingRoundTripsFullUint64Range(t *testing.T) {
for _, value := range []uint64{0, 1, 127, 128, 255, 256, ^uint64(0)} {
encoded := encodePositiveInteger(value)
decoded, ok := decodePositiveInteger(encoded)
if !ok || decoded != value {
t.Errorf("round trip %d: encoded=%X decoded=%d ok=%t", value, encoded, decoded, ok)
}
}
}
func testNotificationMetadata(t *testing.T, sequence byte, event []byte, address, iccid string) []byte {
t.Helper()
iccidBCD, err := encodeICCID(iccid)
if err != nil {
t.Fatal(err)
}
return derConstruct(0xBF2F,
derEncode(0x80, []byte{sequence}),
derEncode(0x81, event),
derEncode(0x0C, []byte(address)),
derEncode(0x5A, iccidBCD),
)
}
func TestParsePendingNotifications(t *testing.T) {
installMetadata := testNotificationMetadata(t, 7, []byte{7, 0x80}, "install.example.com", "8944476500017228672")
install := derConstruct(0xBF37, derConstruct(0xBF27, installMetadata))
deleteMetadata := testNotificationMetadata(t, 9, []byte{4, 0x10}, "delete.example.com", "89441000400128014257")
deleted := derConstruct(0x30, deleteMetadata, derEncode(0x5F37, []byte{1, 2, 3}))
notifications, err := parsePendingNotifications(derConstruct(0xBF2B, derConstruct(0xA0, install, deleted)))
if err != nil {
t.Fatalf("parsePendingNotifications: %v", err)
}
if len(notifications) != 2 {
t.Fatalf("notifications = %#v", notifications)
}
// Results are grouped by receiver, then sorted by sequence number.
if got := notifications[0]; got.SequenceNumber != 9 || got.Event != "delete" ||
got.Address != "delete.example.com" || got.ICCID != "89441000400128014257" || !bytes.Equal(got.raw, deleted) {
t.Fatalf("delete notification = %#v, raw=%X", got, got.raw)
}
if got := notifications[1]; got.SequenceNumber != 7 || got.Event != "install" ||
got.Address != "install.example.com" || got.ICCID != "8944476500017228672" || !bytes.Equal(got.raw, install) {
t.Fatalf("install notification = %#v, raw=%X", got, got.raw)
}
metadata, err := parseNotificationMetadataList(derConstruct(0xBF28, derConstruct(0xA0, installMetadata, deleteMetadata)))
if err != nil || len(metadata) != 2 {
t.Fatalf("parseNotificationMetadataList = %#v, %v", metadata, err)
}
if metadata[0].SequenceNumber != 9 || metadata[0].Event != "delete" || len(metadata[0].raw) != 0 {
t.Fatalf("listed metadata = %#v", metadata[0])
}
}
func TestNotificationRequestsAndRemoveResult(t *testing.T) {
if got := buildListNotificationsRequest(); !bytes.Equal(got, []byte{0xBF, 0x28, 0x00}) {
t.Fatalf("list request = %X", got)
}
if got := buildRetrieveNotificationsRequest(nil); !bytes.Equal(got, []byte{0xBF, 0x2B, 0x00}) {
t.Fatalf("retrieve all request = %X", got)
}
sequenceNumber := uint64(128)
wantRetrieve := []byte{0xBF, 0x2B, 0x04, 0x80, 0x02, 0x00, 0x80}
if got := buildRetrieveNotificationsRequest(&sequenceNumber); !bytes.Equal(got, wantRetrieve) {
t.Fatalf("retrieve request = %X, want %X", got, wantRetrieve)
}
wantRemove := []byte{0xBF, 0x30, 0x04, 0x80, 0x02, 0x00, 0x80}
if got := buildRemoveNotificationRequest(sequenceNumber); !bytes.Equal(got, wantRemove) {
t.Fatalf("remove request = %X, want %X", got, wantRemove)
}
if err := removeNotificationResult([]byte{0xBF, 0x30, 0x03, 0x80, 0x01, 0x00}); err != nil {
t.Fatalf("removeNotificationResult(ok): %v", err)
}
if err := removeNotificationResult([]byte{0xBF, 0x30, 0x03, 0x80, 0x01, 0x7F}); err == nil {
t.Fatal("undefinedError response was accepted")
}
}
func TestParsePendingNotificationsRejectsMalformedMetadata(t *testing.T) {
missingAddress := derConstruct(0x30, derConstruct(0xBF2F,
derEncode(0x80, []byte{1}),
derEncode(0x81, []byte{4, 0x10}),
))
if _, err := parsePendingNotifications(derConstruct(0xBF2B, missingAddress)); err == nil {
t.Fatal("notification without receiver address was accepted")
}
}
+39
View File
@@ -244,6 +244,45 @@ func TestTransientEuiccCMEClassification(t *testing.T) {
}
}
func TestDiscoverEuiccAIDsFindsXeSIMAlternateISDR(t *testing.T) {
manageChannel := clientStep{
command: `AT+CSIM=10,"0070000001"`,
response: okResponse(`+CSIM: 6,"019000"`),
}
closeChannel := clientStep{
command: `AT+CSIM=10,"0070800100"`,
response: okResponse(`+CSIM: 4,"9000"`),
}
selectStep := func(aid, response string) clientStep {
return clientStep{
command: fmt.Sprintf(`AT+CSIM=42,"01A4040010%s"`, aid),
response: okResponse(fmt.Sprintf(`+CSIM: 4,"%s"`, response)),
}
}
client := &transcriptClient{steps: []clientStep{
// No eSTK product applet on this card.
manageChannel,
selectStep(estkProductAID, "6A82"),
closeChannel,
// XeSIM does not expose the standard GSMA ...0100 application.
manageChannel,
selectStep(isdRAID, "6A82"),
closeChannel,
// Its dedicated ...0177 ISD-R is selectable.
manageChannel,
selectStep(xesimISDRAID, "9000"),
closeChannel,
}}
manager, id := newStartedTestManager(t, client)
aids := manager.discoverEuiccAIDs(context.Background(), id)
if len(aids) != 1 || aids[0] != xesimISDRAID {
t.Fatalf("discovered AIDs = %#v, want XeSIM %s", aids, xesimISDRAID)
}
client.assertDone(t)
}
func TestEUICCChannelStuckWrapsTransientCME(t *testing.T) {
cause := &modem.CommandError{
Command: `AT+CSIM=10,"0070000001"`,
+48 -1
View File
@@ -50,6 +50,8 @@ type ussdSession struct {
type managedDevice struct {
opMu sync.Mutex
candidate modem.Candidate
backend string
lastICCID string
client modem.Client
snapshot *Snapshot
lastError string
@@ -358,16 +360,44 @@ func (manager *Manager) Refresh(ctx context.Context, id string) (Snapshot, error
return Snapshot{}, err
}
candidate := manager.candidateFor(state)
backend := manager.backendFor(state)
client, err := manager.clientLocked(ctx, state, candidate)
if err != nil {
manager.setResult(id, state, nil, err)
return Snapshot{}, err
}
snapshot, err := manager.readSnapshot(ctx, id, candidate, client)
previousICCID := state.lastICCID
snapshot, err := manager.readSnapshot(ctx, id, candidate, backend, previousICCID, client)
if err == nil && strings.TrimSpace(snapshot.ICCID) != "" {
state.lastICCID = strings.TrimSpace(snapshot.ICCID)
}
manager.setResult(id, state, &snapshot, err)
return snapshot, err
}
// SetBackend selects which control plane supplies registration and data state.
// AT remains available in either mode for UICC, RF, SMS, voice and diagnostics.
func (manager *Manager) SetBackend(id, backend string) error {
backend = strings.ToLower(strings.TrimSpace(backend))
if backend != "at" && backend != "qmi" {
return fmt.Errorf("unsupported device backend %q", backend)
}
manager.mu.Lock()
defer manager.mu.Unlock()
state := manager.devices[id]
if state == nil || !state.discovered {
return ErrNotFound
}
state.backend = backend
return nil
}
func (manager *Manager) backendFor(state *managedDevice) string {
manager.mu.RLock()
defer manager.mu.RUnlock()
return state.backend
}
func (manager *Manager) ExecuteAT(
ctx context.Context,
id string,
@@ -529,3 +559,20 @@ func (manager *Manager) command(
}
return response, nil
}
// sensitiveCommand executes an AT command containing credentials or other
// authentication material. Modem errors commonly echo the complete command,
// so neither the returned error nor the retained device state may wrap it.
func (manager *Manager) sensitiveCommand(
ctx context.Context,
client modem.Client,
command string,
) (modem.Response, error) {
commandCtx, cancel := manager.withTimeout(ctx, manager.commandTimeout)
defer cancel()
response, err := client.Execute(commandCtx, command)
if err != nil {
return response, errors.New("sensitive modem command failed")
}
return response, nil
}
+72 -8
View File
@@ -19,6 +19,14 @@ func TestManagerRefreshBuildsEC20Snapshot(t *testing.T) {
),
},
{command: "AT+CPIN?", response: okResponse("+CPIN: READY")},
{
command: "AT+CCID",
response: modem.Response{Final: "+CME ERROR: 100"},
err: errors.New("CCID unsupported"),
},
{command: "AT+QCCID", response: okResponse("+QCCID: 8986001234567890123F")},
{command: "AT+CIMI", response: okResponse("460001234567890")},
{command: "AT+CRSM=176,28486,0,0,17", response: okResponse(`+CRSM: 144,0,"00434D4343FFFFFFFFFFFFFFFFFFFFFFFF"`)},
{command: "AT+CSQ", response: okResponse("+CSQ: 20,99")},
{
command: `AT+QENG="servingcell"`,
@@ -29,13 +37,6 @@ func TestManagerRefreshBuildsEC20Snapshot(t *testing.T) {
{command: "AT+COPS?", response: okResponse(`+COPS: 0,0,"China Mobile",7`)},
{command: "AT+CEREG?", response: okResponse(`+CEREG: 0,5`)},
{command: "AT+CGSN", response: okResponse("867123456789012")},
{
command: "AT+CCID",
response: modem.Response{Final: "+CME ERROR: 100"},
err: errors.New("CCID unsupported"),
},
{command: "AT+QCCID", response: okResponse("+QCCID: 8986001234567890123F")},
{command: "AT+CIMI", response: okResponse("460001234567890")},
{command: "AT+CFUN?", response: okResponse("+CFUN: 1")},
{command: "AT+CNUM", response: okResponse(`+CNUM: "","+8613800138000",145`)},
}}
@@ -74,7 +75,7 @@ func TestManagerRefreshBuildsEC20Snapshot(t *testing.T) {
}
if snapshot.IMEI != "867123456789012" ||
snapshot.ICCID != "8986001234567890123" ||
snapshot.IMSI != "460001234567890" {
snapshot.IMSI != "460001234567890" || snapshot.SPN != "CMCC" {
t.Fatalf("subscriber identifiers = %#v", snapshot)
}
if !snapshot.ModeKnown || snapshot.OperatingMode != 1 ||
@@ -96,6 +97,18 @@ func TestManagerRefreshBuildsEC20Snapshot(t *testing.T) {
client.assertDone(t)
}
func TestParseSPNASCIIAndUCS2(t *testing.T) {
if got := parseSPN(okResponse(`+CRSM: 144,0,"004C6562617261FFFFFFFFFFFFFFFFFFFF"`)); got != "Lebara" {
t.Fatalf("ASCII SPN = %q", got)
}
if got := parseSPN(okResponse(`+CRSM: 144,0,"0080004C00650062006100720061FFFF"`)); got != "Lebara" {
t.Fatalf("UCS2 SPN = %q", got)
}
if got := parseSPN(okResponse(`+CRSM: 106,130,""`)); got != "" {
t.Fatalf("failed CRSM SPN = %q", got)
}
}
func TestParseICCIDIdentifierStripsTwoFillerNibbles(t *testing.T) {
response := modem.Response{Lines: []string{"+CCID: 894921007608519523FF"}}
if got := parseICCIDIdentifier(response, []string{"+CCID:", "+QCCID:"}, 18, 22); got != "894921007608519523" {
@@ -122,6 +135,57 @@ func TestManagerRequiresStartAndKnownDevice(t *testing.T) {
}
}
func TestManagerBackendSelectionIsExplicit(t *testing.T) {
manager, id := newStartedTestManager(t, &transcriptClient{})
if err := manager.SetBackend(id, "qmi"); err != nil {
t.Fatal(err)
}
state, err := manager.lookup(id)
if err != nil {
t.Fatal(err)
}
if got := manager.backendFor(state); got != "qmi" {
t.Fatalf("backend = %q, want qmi", got)
}
if err := manager.SetBackend(id, "mbim"); err == nil {
t.Fatal("unsupported backend was accepted")
}
}
func TestManagerForcesRFOffBeforeInspectingChangedSIMNetwork(t *testing.T) {
client := &transcriptClient{steps: []clientStep{
{command: "ATI", response: okResponse("Quectel", "EC20", "Revision: test")},
{command: "AT+CPIN?", response: okResponse("+CPIN: READY")},
{command: "AT+CCID", response: okResponse("+CCID: 8900000000000000002")},
// This must precede CIMI, signal, serving-cell and operator queries.
{command: "AT+CFUN=4", response: okResponse()},
{command: "AT+CIMI", response: okResponse("234150000000002")},
{command: "AT+CRSM=176,28486,0,0,17", response: okResponse(`+CRSM: 144,0,"004C6562617261FFFFFFFFFFFFFFFFFFFF"`)},
{command: "AT+CSQ", response: okResponse("+CSQ: 99,99")},
{command: `AT+QENG="servingcell"`, response: okResponse(`+QENG: "servingcell","SEARCH"`)},
{command: "AT+COPS?", response: okResponse("+COPS: 0")},
{command: "AT+CEREG?", response: okResponse("+CEREG: 0,0")},
{command: "AT+CGSN", response: okResponse("867123456789012")},
{command: "AT+CFUN?", response: okResponse("+CFUN: 4")},
{command: "AT+CNUM", response: okResponse(`+CNUM: "","+447700900002",145`)},
}}
manager, id := newStartedTestManager(t, client)
state, err := manager.lookup(id)
if err != nil {
t.Fatal(err)
}
state.lastICCID = "8900000000000000001"
snapshot, err := manager.Refresh(context.Background(), id)
if err != nil {
t.Fatal(err)
}
if !snapshot.SIMChanged || !snapshot.FlightMode || snapshot.OperatingMode != 4 {
t.Fatalf("changed SIM snapshot = %#v", snapshot)
}
client.assertDone(t)
}
func TestExecuteSensitiveATDoesNotPersistCommandOrModemError(t *testing.T) {
const secretCommand = `AT+CSIM=78,"00880081221000112233445566778899AABBCCDDEEFF1000112233445566778899AABBCCDDEEFF00"`
client := &transcriptClient{steps: []clientStep{{
File diff suppressed because one or more lines are too long
+20
View File
@@ -40,6 +40,7 @@ func TestCarrierNameForPLMNUsesGlobalDatabase(t *testing.T) {
func TestCarrierForPLMNReturnsCountryCode(t *testing.T) {
tests := map[string]string{
"23415": "GB",
"23487": "GB",
"26202": "DE",
"310260": "US",
"22201": "IT",
@@ -53,3 +54,22 @@ func TestCarrierForPLMNReturnsCountryCode(t *testing.T) {
}
}
}
func TestCarrierForIMSIHandlesTwoAndThreeDigitMNCs(t *testing.T) {
tests := []struct {
imsi string
wantPLMN string
wantCountry string
}{
{imsi: "234336570710174", wantPLMN: "23433", wantCountry: "GB"},
{imsi: "234159609054263", wantPLMN: "23415", wantCountry: "GB"},
{imsi: "234870123456789", wantPLMN: "23487", wantCountry: "GB"},
{imsi: "310260123456789", wantPLMN: "310260", wantCountry: "US"},
}
for _, item := range tests {
plmn, name, country, ok := CarrierForIMSI(item.imsi)
if !ok || plmn != item.wantPLMN || name == "" || country != item.wantCountry {
t.Errorf("CarrierForIMSI(%q) = (%q, %q, %q, %v), want PLMN %q and country %q", item.imsi, plmn, name, country, ok, item.wantPLMN, item.wantCountry)
}
}
}
+91 -19
View File
@@ -3,12 +3,14 @@ package device
import (
"context"
"encoding/csv"
"encoding/hex"
"fmt"
"io"
"strconv"
"strings"
"time"
"unicode"
"unicode/utf16"
"vocat/internal/modem"
)
@@ -17,6 +19,8 @@ func (manager *Manager) readSnapshot(
ctx context.Context,
id string,
candidate modem.Candidate,
backend string,
previousICCID string,
client modem.Client,
) (Snapshot, error) {
snapshot := Snapshot{
@@ -47,6 +51,36 @@ func (manager *Manager) readSnapshot(
if response, ok := optional("AT+CPIN?"); ok {
snapshot.SIMStatus, snapshot.SIMReady = parseCPIN(response)
}
ccid, ccidErr := manager.command(ctx, client, "AT+CCID")
if ccidErr != nil {
ccid, ccidErr = manager.command(ctx, client, "AT+QCCID")
}
if ccidErr != nil {
snapshot.Warnings = append(snapshot.Warnings, "read ICCID: "+ccidErr.Error())
} else {
snapshot.ICCID = parseICCIDIdentifier(ccid, []string{"+CCID:", "+QCCID:"}, 18, 22)
}
previousICCID = strings.TrimSpace(previousICCID)
if previousICCID != "" && snapshot.ICCID != "" && !strings.EqualFold(previousICCID, snapshot.ICCID) {
// A different physical SIM must never inherit the previous card's
// permission to use cellular RF. Disable RF before reading serving-cell
// or operator state; policy reconciliation will then start VoWiFi.
if _, err := manager.command(ctx, client, "AT+CFUN=4"); err != nil {
return snapshot, fmt.Errorf("protect changed SIM with RF off: %w", err)
}
snapshot.SIMChanged = true
}
if response, ok := optional("AT+CIMI"); ok {
snapshot.IMSI = parseIdentifier(response, []string{"+CIMI:"}, 10, 18)
}
// EF_SPN is the SIM-issued brand (for example "Lebara"), which is distinct
// from the IMSI sponsor/core PLMN. A Lebara UK subscription may therefore
// legitimately carry a Vodafone NL IMSI while still presenting Lebara as
// its customer-facing operator. Failure is intentionally silent because
// EF_SPN is optional and some physical SIMs deny CRSM access to it.
if response, spnErr := manager.command(ctx, client, "AT+CRSM=176,28486,0,0,17"); spnErr == nil {
snapshot.SPN = parseSPN(response)
}
if response, ok := optional("AT+CSQ"); ok {
snapshot.SignalRaw, snapshot.SignalPercent, snapshot.RSSIDBm = parseCSQ(response)
}
@@ -87,13 +121,16 @@ func (manager *Manager) readSnapshot(
break
}
}
if registration, found := readPlatformRegistration(ctx, candidate); found {
snapshot.RegistrationStatus = registration.Status
snapshot.RegistrationSource = "QMI NAS"
snapshot.PSAttached = registration.PSAttached
if registration.PLMN != "" {
snapshot.OperatorCode = registration.PLMN
snapshot.OperatorName = carrierNameForPLMN(registration.PLMN, registration.Name)
if strings.EqualFold(backend, "qmi") {
registration, found := readPlatformRegistration(ctx, candidate)
if found {
snapshot.RegistrationStatus = registration.Status
snapshot.RegistrationSource = "QMI NAS"
snapshot.PSAttached = registration.PSAttached
if registration.PLMN != "" {
snapshot.OperatorCode = registration.PLMN
snapshot.OperatorName = carrierNameForPLMN(registration.PLMN, registration.Name)
}
}
}
if snapshot.RegistrationSource == "" && (snapshot.OperatorName != "" || snapshot.OperatorCode != "") {
@@ -111,18 +148,6 @@ func (manager *Manager) readSnapshot(
)
}
ccid, ccidErr := manager.command(ctx, client, "AT+CCID")
if ccidErr != nil {
ccid, ccidErr = manager.command(ctx, client, "AT+QCCID")
}
if ccidErr != nil {
snapshot.Warnings = append(snapshot.Warnings, "read ICCID: "+ccidErr.Error())
} else {
snapshot.ICCID = parseICCIDIdentifier(ccid, []string{"+CCID:", "+QCCID:"}, 18, 22)
}
if response, ok := optional("AT+CIMI"); ok {
snapshot.IMSI = parseIdentifier(response, []string{"+CIMI:"}, 10, 18)
}
if response, ok := optional("AT+CFUN?"); ok {
if mode, found := parseCFUN(response); found {
snapshot.OperatingMode = mode
@@ -139,6 +164,53 @@ func (manager *Manager) readSnapshot(
return snapshot, nil
}
func parseSPN(response modem.Response) string {
value := valueAfterPrefix(response, "+CRSM:")
fields := csvValues(value)
if len(fields) < 3 {
return ""
}
sw1, sw1Err := strconv.Atoi(strings.TrimSpace(fields[0]))
sw2, sw2Err := strconv.Atoi(strings.TrimSpace(fields[1]))
if sw1Err != nil || sw2Err != nil || (sw1 != 0x90 && sw1 != 0x91 && sw1 != 0x9f) || sw2 < 0 || sw2 > 255 {
return ""
}
raw, err := hex.DecodeString(strings.Trim(strings.TrimSpace(fields[2]), `"`))
if err != nil || len(raw) < 2 {
return ""
}
alpha := raw[1:] // byte 0 is the display-condition bit field.
for len(alpha) > 0 && (alpha[len(alpha)-1] == 0xff || alpha[len(alpha)-1] == 0x00) {
alpha = alpha[:len(alpha)-1]
}
if len(alpha) == 0 {
return ""
}
if alpha[0] == 0x80 {
ucs2 := alpha[1:]
if len(ucs2)%2 != 0 {
ucs2 = ucs2[:len(ucs2)-1]
}
units := make([]uint16, 0, len(ucs2)/2)
for index := 0; index+1 < len(ucs2); index += 2 {
unit := uint16(ucs2[index])<<8 | uint16(ucs2[index+1])
if unit != 0xffff && unit != 0 {
units = append(units, unit)
}
}
return strings.TrimSpace(string(utf16.Decode(units)))
}
// EF_SPN uses the unpacked GSM default alphabet. Its printable Latin subset
// is byte-compatible with UTF-8/ASCII and covers operator brands in practice.
printable := make([]byte, 0, len(alpha))
for _, value := range alpha {
if value >= 0x20 && value <= 0x7e {
printable = append(printable, value)
}
}
return strings.TrimSpace(string(printable))
}
func parseRegistrationStatus(response modem.Response) (int, bool) {
for _, prefix := range []string{"+CEREG:", "+CGREG:", "+CREG:"} {
values := csvValues(valueAfterPrefix(response, prefix))
+9 -3
View File
@@ -24,9 +24,13 @@ var (
)
type NetworkRequest struct {
Enabled bool `json:"enabled"`
APN string `json:"apn"`
IPVersion string `json:"ipVersion"`
Enabled bool `json:"enabled"`
APN string `json:"apn"`
IPVersion string `json:"ipVersion"`
Username string `json:"username,omitempty"`
Password string `json:"password,omitempty"`
Authentication string `json:"authentication,omitempty"`
Backend string `json:"backend,omitempty"`
}
type NetworkResult struct {
@@ -81,6 +85,7 @@ type Snapshot struct {
Firmware string `json:"firmware"`
SIMStatus string `json:"simStatus"`
SIMReady bool `json:"simReady"`
SIMChanged bool `json:"simChanged,omitempty"`
SignalRaw *int `json:"signalRaw,omitempty"`
SignalPercent *int `json:"signalPercent,omitempty"`
RSSIDBm *int `json:"rssiDbm,omitempty"`
@@ -98,6 +103,7 @@ type Snapshot struct {
IMEI string `json:"imei"`
ICCID string `json:"iccid"`
IMSI string `json:"imsi"`
SPN string `json:"spn,omitempty"`
OperatingMode int `json:"operatingMode"`
ModeKnown bool `json:"modeKnown"`
FlightMode bool `json:"flightMode"`
+27 -9
View File
@@ -8,9 +8,9 @@ import (
"hash/fnv"
"net"
"os"
"path/filepath"
"strings"
"syscall"
"unicode"
)
func platformSupported() error { return nil }
@@ -54,14 +54,15 @@ func boundResolver(networkInterface string) *net.Resolver {
}
func exportRouteDNSServers(networkInterface string) []string {
safeName := strings.Map(func(character rune) rune {
if character >= 'a' && character <= 'z' || character >= 'A' && character <= 'Z' ||
character >= '0' && character <= '9' || character == '-' || character == '_' || character == '.' {
return character
}
return '_'
}, networkInterface)
file, err := os.Open(filepath.Join("/run/vocat", "cellular-"+safeName+".dns"))
if !validInterfaceName(networkInterface) {
return []string{"1.1.1.1", "8.8.8.8"}
}
root, err := os.OpenRoot("/run/vocat")
if err != nil {
return []string{"1.1.1.1", "8.8.8.8"}
}
defer root.Close()
file, err := root.Open("cellular-" + networkInterface + ".dns")
if err != nil {
return []string{"1.1.1.1", "8.8.8.8"}
}
@@ -78,3 +79,20 @@ func exportRouteDNSServers(networkInterface string) []string {
}
return servers
}
// Linux IFNAMSIZ is 16 including the terminator. Restricting names here both
// matches kernel interface names and prevents a stored device value from ever
// becoming a filesystem path component.
func validInterfaceName(value string) bool {
if value == "" || len(value) > 15 || value == "." || value == ".." {
return false
}
for _, character := range value {
if character > unicode.MaxASCII || !(character >= 'a' && character <= 'z' ||
character >= 'A' && character <= 'Z' || character >= '0' && character <= '9' ||
character == '-' || character == '_' || character == '.') {
return false
}
}
return true
}
+18
View File
@@ -0,0 +1,18 @@
//go:build linux
package exportproxy
import "testing"
func TestValidInterfaceName(t *testing.T) {
for _, value := range []string{"wwan0", "wwp0s20f0u5i4", "rmnet_data0", "usb.1"} {
if !validInterfaceName(value) {
t.Errorf("validInterfaceName(%q) = false", value)
}
}
for _, value := range []string{"", ".", "..", "../wwan0", `..\wwan0`, "wwan0/evil", "interface-name-too-long"} {
if validInterfaceName(value) {
t.Errorf("validInterfaceName(%q) = true", value)
}
}
}
+19 -8
View File
@@ -19,6 +19,7 @@ import (
"os"
"os/exec"
"path/filepath"
"regexp"
"runtime"
"sort"
"strings"
@@ -26,10 +27,15 @@ import (
"time"
"vocat/internal/exportproxy"
"vocat/internal/netguard"
)
const maxPackageBytes int64 = 64 << 20
// This syntactic guard gives the request boundary an explicit allowlist. The
// resolved addresses are still checked again by netguard before dialing.
var publicHTTPSURLPattern = regexp.MustCompile(`^https://(?:[A-Za-z0-9](?:[A-Za-z0-9.-]{0,251}[A-Za-z0-9])?|\[[0-9A-Fa-f:.]+\])(?::[0-9]{1,5})?(?:[/?#][^\r\n]*)?$`)
type Plugin struct {
Manifest
Enabled bool `json:"enabled"`
@@ -73,7 +79,7 @@ func NewManager(root string, logger *slog.Logger) (*Manager, error) {
}
manager := &Manager{
root: root, logger: logger, plugins: make(map[string]*Plugin),
client: &http.Client{Timeout: 45 * time.Second},
client: netguard.NewPublicHTTPClient(45*time.Second, true),
}
if err := manager.scan(); err != nil {
return nil, err
@@ -155,9 +161,13 @@ func (manager *Manager) List() []Plugin {
}
func (manager *Manager) InstallURL(ctx context.Context, rawURL, expectedSHA string) (Plugin, error) {
parsed, err := url.Parse(strings.TrimSpace(rawURL))
if err != nil || (parsed.Scheme != "https" && parsed.Scheme != "http") || parsed.Host == "" {
return Plugin{}, errors.New("plugin URL must be an absolute HTTP or HTTPS URL")
rawURL = strings.TrimSpace(rawURL)
if !publicHTTPSURLPattern.MatchString(rawURL) {
return Plugin{}, errors.New("plugin URL must be a public absolute HTTPS URL")
}
parsed, err := netguard.ValidatePublicURL(ctx, rawURL, true)
if err != nil {
return Plugin{}, fmt.Errorf("plugin URL must be a public absolute HTTPS URL: %w", err)
}
request, err := http.NewRequestWithContext(ctx, http.MethodGet, parsed.String(), nil)
if err != nil {
@@ -353,12 +363,13 @@ func (manager *Manager) ServeAsset(w http.ResponseWriter, r *http.Request, id, n
http.NotFound(w, r)
return
}
filename := filepath.Join(plugin.dir, filepath.FromSlash(name))
if !strings.HasPrefix(filepath.Clean(filename), filepath.Clean(plugin.dir)+string(os.PathSeparator)) {
root, err := os.OpenRoot(plugin.dir)
if err != nil {
http.NotFound(w, r)
return
}
file, err := os.Open(filename)
defer root.Close()
file, err := root.Open(filepath.FromSlash(name))
if err != nil {
http.NotFound(w, r)
return
@@ -369,7 +380,7 @@ func (manager *Manager) ServeAsset(w http.ResponseWriter, r *http.Request, id, n
http.NotFound(w, r)
return
}
contentType := mime.TypeByExtension(filepath.Ext(filename))
contentType := mime.TypeByExtension(filepath.Ext(name))
if contentType != "" {
w.Header().Set("Content-Type", contentType)
}
+20
View File
@@ -3,12 +3,32 @@ package extensions
import (
"archive/zip"
"bytes"
"context"
"io"
"log/slog"
"strings"
"testing"
)
func TestInstallURLRejectsNonHTTPSAndPrivateDestinations(t *testing.T) {
manager, err := NewManager(t.TempDir(), nil)
if err != nil {
t.Fatal(err)
}
defer manager.Close()
for _, raw := range []string{
"http://example.com/plugin.zip",
"https://[email protected]/plugin.zip",
"https://example.com/plugin.zip\r\nX-Injected: yes",
"https://127.0.0.1/plugin.zip",
"https://169.254.169.254/latest/meta-data/",
} {
if _, err := manager.InstallURL(context.Background(), raw, ""); err == nil {
t.Errorf("InstallURL(%q) accepted an unsafe destination", raw)
}
}
}
func TestInstallListDisableAndUninstall(t *testing.T) {
manager, err := NewManager(t.TempDir(), slog.New(slog.NewTextHandler(io.Discard, nil)))
if err != nil {
+7 -1
View File
@@ -221,7 +221,13 @@ func readSerialAliases(root string) map[string]string {
func candidateID(productID, serialNumber, usbName string) string {
serialNumber = strings.TrimSpace(serialNumber)
if serialNumber != "" && !strings.EqualFold(serialNumber, "android") {
return "quectel-" + sanitizeID(serialNumber)
// A surprising number of EC20/EC25 carrier boards expose the same
// factory/default USB serial number. The device manager is keyed by this
// value, so using the serial alone silently collapsed two modems connected
// to the same hub into one entry. Include the physical USB topology in the
// discovery key; configured devices remain stable through ATMapper's
// USB-path/IMEI matching even when Linux renumbers ttyUSB nodes.
return "quectel-" + sanitizeID(serialNumber+"-"+usbName)
}
return "quectel-" + sanitizeID(productID+"-"+usbName)
}
+45
View File
@@ -171,6 +171,51 @@ func TestSysFSDiscoverySelectsATPortForSecondQMIUSBModem(t *testing.T) {
}
}
func TestSysFSDiscoveryDoesNotCollapseModemsWithSharedFactorySerial(t *testing.T) {
root := t.TempDir()
sysRoot := filepath.Join(root, "sys")
devRoot := filepath.Join(root, "dev")
usbRoot := filepath.Join(sysRoot, "bus", "usb", "devices")
for index, item := range []struct {
usbName string
ttyBase int
}{
{usbName: "1-5.1", ttyBase: 0},
{usbName: "1-5.2", ttyBase: 4},
} {
mustWrite(t, filepath.Join(usbRoot, item.usbName, "idVendor"), "2c7c\n")
mustWrite(t, filepath.Join(usbRoot, item.usbName, "idProduct"), "0125\n")
mustWrite(t, filepath.Join(usbRoot, item.usbName, "serial"), "0123456789ABCDEF\n")
for number := 0; number < 4; number++ {
interfaceName := item.usbName + ":1." + strconv.Itoa(number)
tty := fmt.Sprintf("ttyUSB%d", item.ttyBase+number)
mustWrite(t, filepath.Join(usbRoot, interfaceName, "bInterfaceNumber"), fmt.Sprintf("%02x\n", number))
mustMkdir(t, filepath.Join(usbRoot, interfaceName, tty, "tty", tty))
}
mustMkdir(t, filepath.Join(usbRoot, item.usbName+":1.4", "usbmisc", fmt.Sprintf("cdc-wdm%d", index)))
}
candidates, err := NewSysFSDiscoverer(sysRoot, devRoot).Discover(context.Background())
if err != nil {
t.Fatalf("Discover: %v", err)
}
if len(candidates) != 2 {
t.Fatalf("got %d candidates, want 2", len(candidates))
}
if candidates[0].ID == candidates[1].ID {
t.Fatalf("shared factory serial collapsed discovery IDs to %q", candidates[0].ID)
}
for _, candidate := range candidates {
if candidate.SerialNumber != "0123456789ABCDEF" {
t.Fatalf("serial = %q", candidate.SerialNumber)
}
if candidate.ATPort.Role != PortRoleAT {
t.Fatalf("AT port = %#v", candidate.ATPort)
}
}
}
func TestSysFSDiscoveryIgnoresNonQuectelUSB(t *testing.T) {
root := t.TempDir()
usbRoot := filepath.Join(root, "sys", "bus", "usb", "devices")
+170
View File
@@ -0,0 +1,170 @@
package netguard
import (
"context"
"crypto/tls"
"errors"
"fmt"
"net"
"net/http"
"net/netip"
"net/url"
"strconv"
"strings"
"time"
)
// ValidatePublicURL accepts an absolute HTTP(S) URL only when every currently
// resolved address is publicly routable. The transport returned by
// NewPublicHTTPClient repeats the same check when it dials, which also prevents
// DNS rebinding between validation and connection establishment.
func ValidatePublicURL(ctx context.Context, raw string, requireHTTPS bool) (*url.URL, error) {
parsed, err := url.Parse(strings.TrimSpace(raw))
if err != nil || !parsed.IsAbs() || parsed.Hostname() == "" {
return nil, errors.New("destination must be an absolute HTTP URL")
}
if parsed.User != nil {
return nil, errors.New("destination URL cannot contain user information")
}
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return nil, errors.New("destination URL must use HTTP or HTTPS")
}
if requireHTTPS && parsed.Scheme != "https" {
return nil, errors.New("destination URL must use HTTPS")
}
if port := parsed.Port(); port != "" {
value, err := strconv.Atoi(port)
if err != nil || value < 1 || value > 65535 {
return nil, errors.New("destination URL has an invalid port")
}
}
if _, err := resolvePublic(ctx, parsed.Hostname()); err != nil {
return nil, err
}
return parsed, nil
}
// NewPublicHTTPClient creates a client that never uses environment proxies,
// rejects private/special-use destinations at dial time, and validates every
// redirect before following it.
func NewPublicHTTPClient(timeout time.Duration, requireHTTPS bool) *http.Client {
if timeout <= 0 {
timeout = 30 * time.Second
}
transport := &http.Transport{
Proxy: nil,
DialContext: PublicDialer(timeout),
ForceAttemptHTTP2: true,
TLSHandshakeTimeout: timeout,
ResponseHeaderTimeout: timeout,
ExpectContinueTimeout: time.Second,
TLSClientConfig: &tls.Config{
MinVersion: tls.VersionTLS12,
},
}
return &http.Client{
Transport: transport,
Timeout: timeout,
CheckRedirect: func(request *http.Request, via []*http.Request) error {
if len(via) >= 4 {
return errors.New("too many redirects")
}
_, err := ValidatePublicURL(request.Context(), request.URL.String(), requireHTTPS)
return err
},
}
}
// PublicDialer resolves the original hostname and connects directly to one of
// its validated public addresses. It does not pass the hostname back through a
// second resolver, so a DNS rebinding response cannot redirect the connection.
func PublicDialer(timeout time.Duration) func(context.Context, string, string) (net.Conn, error) {
return func(ctx context.Context, network, address string) (net.Conn, error) {
host, port, err := net.SplitHostPort(address)
if err != nil {
return nil, fmt.Errorf("parse outbound address: %w", err)
}
addresses, err := resolvePublic(ctx, host)
if err != nil {
return nil, err
}
dialer := net.Dialer{Timeout: timeout}
var lastErr error
for _, address := range addresses {
connection, err := dialer.DialContext(ctx, network, net.JoinHostPort(address.String(), port))
if err == nil {
return connection, nil
}
lastErr = err
}
return nil, fmt.Errorf("connect to public destination: %w", lastErr)
}
}
func resolvePublic(ctx context.Context, host string) ([]netip.Addr, error) {
if literal, err := netip.ParseAddr(strings.Trim(host, "[]")); err == nil {
literal = literal.Unmap()
if !publicAddress(literal) {
return nil, errors.New("destination resolves to a private or special-use address")
}
return []netip.Addr{literal}, nil
}
addresses, err := net.DefaultResolver.LookupNetIP(ctx, "ip", host)
if err != nil {
return nil, fmt.Errorf("resolve destination: %w", err)
}
result := make([]netip.Addr, 0, len(addresses))
for _, address := range addresses {
address = address.Unmap()
if !publicAddress(address) {
return nil, errors.New("destination resolves to a private or special-use address")
}
result = append(result, address)
}
if len(result) == 0 {
return nil, errors.New("destination has no IP address")
}
return result, nil
}
var blockedNetworks = []netip.Prefix{
netip.MustParsePrefix("0.0.0.0/8"),
netip.MustParsePrefix("10.0.0.0/8"),
netip.MustParsePrefix("100.64.0.0/10"),
netip.MustParsePrefix("127.0.0.0/8"),
netip.MustParsePrefix("169.254.0.0/16"),
netip.MustParsePrefix("172.16.0.0/12"),
netip.MustParsePrefix("192.0.0.0/24"),
netip.MustParsePrefix("192.0.2.0/24"),
netip.MustParsePrefix("192.88.99.0/24"),
netip.MustParsePrefix("192.168.0.0/16"),
netip.MustParsePrefix("198.18.0.0/15"),
netip.MustParsePrefix("198.51.100.0/24"),
netip.MustParsePrefix("203.0.113.0/24"),
netip.MustParsePrefix("224.0.0.0/4"),
netip.MustParsePrefix("240.0.0.0/4"),
netip.MustParsePrefix("::/128"),
netip.MustParsePrefix("::1/128"),
netip.MustParsePrefix("64:ff9b:1::/48"),
netip.MustParsePrefix("100::/64"),
netip.MustParsePrefix("2001:db8::/32"),
netip.MustParsePrefix("fc00::/7"),
netip.MustParsePrefix("fe80::/10"),
netip.MustParsePrefix("ff00::/8"),
// Block both the well-known and local-use NAT64 prefixes. Otherwise a
// public-looking IPv6 literal could translate to a private IPv4 target.
netip.MustParsePrefix("64:ff9b::/96"),
netip.MustParsePrefix("2002::/16"),
}
func publicAddress(address netip.Addr) bool {
if !address.IsValid() || !address.IsGlobalUnicast() {
return false
}
for _, blocked := range blockedNetworks {
if blocked.Contains(address) {
return false
}
}
return true
}
+29
View File
@@ -0,0 +1,29 @@
package netguard
import (
"context"
"testing"
)
func TestValidatePublicURLRejectsUnsafeDestinations(t *testing.T) {
tests := []string{
"http://127.0.0.1/plugin.zip",
"https://[::1]/plugin.zip",
"https://169.254.169.254/latest/meta-data/",
"https://[64:ff9b::7f00:1]/",
"https://[2002:7f00:1::]/",
"file:///etc/passwd",
"https://user:[email protected]/plugin.zip",
}
for _, raw := range tests {
if _, err := ValidatePublicURL(context.Background(), raw, false); err == nil {
t.Errorf("ValidatePublicURL(%q) accepted an unsafe destination", raw)
}
}
}
func TestValidatePublicURLCanRequireHTTPS(t *testing.T) {
if _, err := ValidatePublicURL(context.Background(), "http://8.8.8.8/plugin.zip", true); err == nil {
t.Fatal("HTTP destination was accepted while HTTPS was required")
}
}
@@ -0,0 +1,300 @@
package server
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"crypto/tls"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/mail"
"net/smtp"
"strconv"
"strings"
"time"
"vocat/internal/store"
)
type automaticTaskNotification struct {
Title string
Text string
Time time.Time
Task store.AutomaticTask
Run store.AutomaticTaskRun
}
func (s *Server) notifyAutomaticTask(ctx context.Context, task store.AutomaticTask, run store.AutomaticTaskRun) {
deviceLabel := task.DeviceID
if configured, err := s.store.Device(ctx, task.DeviceID); err == nil {
deviceLabel = firstNonEmpty(configured.Name, configured.ID)
}
status := "成功"
detail := firstNonEmpty(run.Output, "任务已完成")
if run.Status != "success" {
status = "失败"
detail = firstNonEmpty(run.Error, "未知错误")
}
taskType := map[string]string{"sms": "发送短信", "call": "拨打电话", "public_ip": "获取漫游公网 IP"}[task.TaskType]
environment := map[string]string{"vowifi": "VoWiFi", "cellular": "基站直连"}[task.Environment]
notification := automaticTaskNotification{
Title: "自动任务执行" + status,
Text: strings.Join([]string{
"自动任务执行" + status,
"任务 " + task.Name,
"设备 " + deviceLabel,
"类型 " + firstNonEmpty(taskType, task.TaskType),
"环境 " + firstNonEmpty(environment, task.Environment),
"时间 " + run.FinishedAt.Local().Format("2006-01-02 15:04:05"),
"结果 " + detail,
}, "\n"),
Time: run.FinishedAt, Task: task, Run: run,
}
for _, channel := range []string{"telegram", "bark", "email", "pushplus", "webhook"} {
setting, err := s.store.NotificationSetting(ctx, channel)
if errors.Is(err, store.ErrNotFound) || (err == nil && !setting.Enabled) {
continue
}
if err != nil {
s.logger.Warn("read automatic task notification setting", "channel", channel, "error", err)
continue
}
var config map[string]any
if err := json.Unmarshal(setting.Config, &config); err != nil {
s.logger.Warn("decode automatic task notification setting", "channel", channel, "error", err)
continue
}
if err := sendAutomaticTaskNotification(ctx, channel, config, notification); err != nil {
s.logger.Warn("send automatic task notification", "channel", channel, "task_id", task.ID, "error", err)
}
}
}
func sendAutomaticTaskNotification(ctx context.Context, channel string, config map[string]any, message automaticTaskNotification) error {
switch channel {
case "telegram":
return sendTelegramTextNotification(ctx, config, message.Text)
case "bark":
return sendBarkTextNotification(ctx, config, message.Title, message.Text)
case "email":
return sendEmailTextNotification(ctx, config, message.Title, message.Text)
case "pushplus":
return sendPushplusTextNotification(ctx, config, message.Title, message.Text)
case "webhook":
return sendAutomaticTaskWebhook(ctx, config, message)
default:
return fmt.Errorf("unsupported notification channel %q", channel)
}
}
func sendTelegramTextNotification(ctx context.Context, config map[string]any, text string) error {
token := configString(config, "bot_token")
parsed, err := validateTelegramAPIURL(ctx, configString(config, "base_url"), token, "sendMessage")
if err != nil {
return err
}
client, err := restrictedHTTPClient(ctx, 8*time.Second, configString(config, "proxy"))
if err != nil {
return err
}
payload, _ := json.Marshal(map[string]any{"chat_id": configString(config, "chat_id"), "text": text})
request, err := http.NewRequestWithContext(ctx, http.MethodPost, parsed.String(), bytes.NewReader(payload))
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("User-Agent", "vocat-automatic-task/1")
return performNotificationRequest(client, request, true)
}
func sendBarkTextNotification(ctx context.Context, config map[string]any, title, text string) error {
client, err := restrictedHTTPClient(ctx, 8*time.Second, "")
if err != nil {
return err
}
payload := map[string]any{"title": title, "body": text}
for _, field := range []string{"group", "icon", "level"} {
if value := configString(config, field); value != "" {
payload[field] = value
}
}
encoded, _ := json.Marshal(payload)
for _, destination := range configStrings(config, "urls") {
parsed, err := validateOutboundURL(ctx, destination, false)
if err != nil {
return err
}
request, err := http.NewRequestWithContext(ctx, http.MethodPost, parsed.String(), bytes.NewReader(encoded))
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/json; charset=utf-8")
request.Header.Set("User-Agent", "vocat-automatic-task/1")
if err := performNotificationRequest(client, request, false); err != nil {
return err
}
}
return nil
}
func sendPushplusTextNotification(ctx context.Context, config map[string]any, title, text string) error {
destination, err := validateOutboundURL(ctx, "https://www.pushplus.plus/send", true)
if err != nil {
return err
}
payload := map[string]any{"token": configString(config, "token"), "title": title, "content": text, "template": "txt", "timestamp": time.Now().UnixMilli()}
if topic := configString(config, "topic"); topic != "" {
payload["topic"] = topic
}
if channel := configString(config, "channel"); channel != "" {
payload["channel"] = channel
}
encoded, _ := json.Marshal(payload)
client, err := restrictedHTTPClient(ctx, 8*time.Second, "")
if err != nil {
return err
}
request, err := http.NewRequestWithContext(ctx, http.MethodPost, destination.String(), bytes.NewReader(encoded))
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/json; charset=utf-8")
request.Header.Set("User-Agent", "vocat-automatic-task/1")
response, err := client.Do(request)
if err != nil {
return err
}
defer response.Body.Close()
body, _ := io.ReadAll(io.LimitReader(response.Body, 64<<10))
var result struct {
Code int `json:"code"`
Msg string `json:"msg"`
}
if response.StatusCode < 200 || response.StatusCode >= 300 || json.Unmarshal(body, &result) != nil || result.Code != 200 {
return fmt.Errorf("%w: Pushplus HTTP %d code %d %s", errProviderRejected, response.StatusCode, result.Code, result.Msg)
}
return nil
}
func sendAutomaticTaskWebhook(ctx context.Context, config map[string]any, message automaticTaskNotification) error {
payload, _ := json.Marshal(map[string]any{
"event": "automatic_task.completed", "message": message.Text,
"timestamp": message.Time.UTC().Format(time.RFC3339), "task_id": message.Task.ID,
"task_name": message.Task.Name, "device_id": message.Task.DeviceID,
"task_type": message.Task.TaskType, "environment": message.Task.Environment,
"status": message.Run.Status, "attempts": message.Run.Attempts,
"output": message.Run.Output, "error": message.Run.Error,
})
client, err := restrictedHTTPClient(ctx, durationMilliseconds(configInt(config, "timeout_ms"), 5*time.Second), "")
if err != nil {
return err
}
for _, destination := range configStrings(config, "urls") {
parsed, err := validateOutboundURL(ctx, destination, false)
if err != nil {
return err
}
request, err := http.NewRequestWithContext(ctx, http.MethodPost, parsed.String(), bytes.NewReader(payload))
if err != nil {
return err
}
for name, value := range configStringMap(config, "headers") {
request.Header.Set(name, value)
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("User-Agent", "vocat-automatic-task/1")
if secret := configString(config, "secret"); secret != "" {
signature := hmac.New(sha256.New, []byte(secret))
_, _ = signature.Write(payload)
request.Header.Set("X-vocat-Signature", "sha256="+hex.EncodeToString(signature.Sum(nil)))
}
if err := performNotificationRequest(client, request, false); err != nil {
return err
}
}
return nil
}
func sendEmailTextNotification(ctx context.Context, config map[string]any, subject, text string) error {
host := strings.TrimSpace(configString(config, "smtp_host"))
port := configInt(config, "smtp_port")
if port == 0 {
port = 587
}
timeout := 8 * time.Second
connection, err := dialRestricted(ctx, "tcp", net.JoinHostPort(host, strconv.Itoa(port)), timeout)
if err != nil {
return err
}
defer connection.Close()
if err := connection.SetDeadline(time.Now().Add(timeout)); err != nil {
return err
}
tlsConfig := &tls.Config{MinVersion: tls.VersionTLS12, ServerName: host}
useSSL, _ := config["use_ssl"].(bool)
implicitTLS := port == 465 || useSSL
if implicitTLS {
secure := tls.Client(connection, tlsConfig)
if err := secure.HandshakeContext(ctx); err != nil {
return err
}
connection = secure
}
client, err := smtp.NewClient(connection, host)
if err != nil {
return err
}
defer client.Close()
if !implicitTLS {
if available, _ := client.Extension("STARTTLS"); !available {
return errors.New("SMTP server does not offer STARTTLS")
}
if err := client.StartTLS(tlsConfig); err != nil {
return err
}
}
username, password := configString(config, "username"), configString(config, "password")
if username != "" {
if err := client.Auth(smtp.PlainAuth("", username, password, host)); err != nil {
return err
}
}
from, err := parseMailAddress(configString(config, "from_address"))
if err != nil {
return err
}
var recipients []*mail.Address
for _, item := range configStrings(config, "to_addresses") {
address, err := parseMailAddress(item)
if err != nil {
return err
}
recipients = append(recipients, address)
}
if err := client.Mail(from.Address); err != nil {
return err
}
for _, recipient := range recipients {
if err := client.Rcpt(recipient.Address); err != nil {
return err
}
}
writer, err := client.Data()
if err != nil {
return err
}
if err := writePlainTextMail(writer, from, recipients, subject, text); err != nil {
_ = writer.Close()
return err
}
if err := writer.Close(); err != nil {
return err
}
return client.Quit()
}
+805
View File
@@ -0,0 +1,805 @@
package server
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"sync"
"time"
"vocat/internal/device"
"vocat/internal/exportproxy"
"vocat/internal/store"
)
const (
automaticTaskPollInterval = 5 * time.Second
automaticTaskMaxRuntime = 8 * time.Minute
)
type automaticTaskPayload struct {
Phone string `json:"phone,omitempty"`
Message string `json:"message,omitempty"`
DurationSeconds int `json:"duration_seconds,omitempty"`
}
type automaticTaskExecutionError struct {
err error
retryable bool
}
func (value automaticTaskExecutionError) Error() string { return value.err.Error() }
func (value automaticTaskExecutionError) Unwrap() error { return value.err }
type automaticTaskProgress func(string)
type automaticTaskEnvironmentSnapshot struct {
config store.Device
policy store.CardPolicy
}
type automaticTaskScheduler struct {
server *Server
ctx context.Context
mu sync.Mutex
queues map[string]chan store.AutomaticTaskRun
}
func (s *Server) StartAutomaticTasks(ctx context.Context) {
if ctx == nil {
ctx = context.Background()
}
scheduler := &automaticTaskScheduler{server: s, ctx: ctx, queues: make(map[string]chan store.AutomaticTaskRun)}
s.automaticTasks = scheduler
queued, err := s.store.RecoverAutomaticTaskRuns(ctx, time.Now().UTC())
if err != nil {
s.logger.Warn("recover automatic tasks", "error", err)
} else {
for _, run := range queued {
scheduler.enqueue(run)
}
}
go scheduler.run()
}
func (scheduler *automaticTaskScheduler) run() {
ticker := time.NewTicker(automaticTaskPollInterval)
defer ticker.Stop()
scheduler.claim()
for {
select {
case <-scheduler.ctx.Done():
return
case <-ticker.C:
scheduler.claim()
}
}
}
func (scheduler *automaticTaskScheduler) claim() {
runs, err := scheduler.server.store.ClaimDueAutomaticTasks(scheduler.ctx, time.Now().UTC(), 50)
if err != nil {
scheduler.server.logger.Warn("claim automatic tasks", "error", err)
return
}
for _, run := range runs {
scheduler.enqueue(run)
}
}
func (scheduler *automaticTaskScheduler) enqueue(run store.AutomaticTaskRun) {
deviceID := strings.TrimSpace(run.DeviceID)
scheduler.mu.Lock()
queue := scheduler.queues[deviceID]
if queue == nil {
queue = make(chan store.AutomaticTaskRun, 100)
scheduler.queues[deviceID] = queue
go scheduler.worker(deviceID, queue)
}
scheduler.mu.Unlock()
select {
case queue <- run:
case <-scheduler.ctx.Done():
}
}
func (scheduler *automaticTaskScheduler) worker(deviceID string, queue <-chan store.AutomaticTaskRun) {
for {
select {
case <-scheduler.ctx.Done():
return
case run := <-queue:
scheduler.execute(run)
}
}
}
func (scheduler *automaticTaskScheduler) execute(run store.AutomaticTaskRun) {
task, err := scheduler.server.store.AutomaticTask(scheduler.ctx, run.TaskID)
if err != nil {
run.Status, run.Error, run.FinishedAt = "failed", err.Error(), time.Now().UTC()
_ = scheduler.server.store.UpdateAutomaticTaskRun(context.Background(), run)
return
}
run.Status, run.StartedAt = "running", time.Now().UTC()
_ = scheduler.server.store.UpdateAutomaticTaskRun(context.Background(), run)
var output string
for attempt := 1; attempt <= task.RetryCount+1; attempt++ {
run.Attempts = attempt
run.Output = fmt.Sprintf("第 %d 次尝试:正在检查设备和 eSIM Profile", attempt)
_ = scheduler.server.store.UpdateAutomaticTaskRun(context.Background(), run)
progress := func(message string) {
run.Output = fmt.Sprintf("第 %d 次尝试:%s", attempt, message)
_ = scheduler.server.store.UpdateAutomaticTaskRun(context.Background(), run)
}
operationContext, cancel := context.WithTimeout(scheduler.ctx, automaticTaskMaxRuntime)
output, err = scheduler.server.executeAutomaticTask(operationContext, task, progress)
cancel()
if err == nil {
break
}
var executionError automaticTaskExecutionError
if errors.As(err, &executionError) && !executionError.retryable {
break
}
if attempt <= task.RetryCount {
// A device error may contain the full AT command, including APN
// credentials. The persisted run retains a user-facing outcome; logs
// contain only non-sensitive execution metadata.
scheduler.server.logger.Warn("automatic task attempt failed", "task_id", task.ID, "device_id", task.DeviceID, "attempt", attempt)
select {
case <-scheduler.ctx.Done():
break
case <-time.After(time.Duration(attempt*5) * time.Second):
}
}
}
run.FinishedAt = time.Now().UTC()
if err == nil {
run.Status, run.Output, run.Error = "success", output, ""
} else {
run.Status, run.Error = "failed", err.Error()
}
if updateErr := scheduler.server.store.UpdateAutomaticTaskRun(context.Background(), run); updateErr != nil {
scheduler.server.logger.Warn("finish automatic task run", "run_id", run.ID, "error", updateErr)
}
if task.Notify {
go scheduler.server.notifyAutomaticTask(context.Background(), task, run)
}
}
func (s *Server) executeAutomaticTask(ctx context.Context, task store.AutomaticTask, progress automaticTaskProgress) (output string, err error) {
progress("正在检查设备和 eSIM Profile")
config, entry, physicalID, err := s.ensureAutomaticTaskProfile(ctx, task, progress)
if err != nil {
return "", err
}
iccid := strings.TrimSpace(task.ProfileICCID)
policy, policyErr := s.store.CardPolicy(ctx, iccid)
if errors.Is(policyErr, store.ErrNotFound) {
policy = defaultCardPolicy(iccid)
} else if policyErr != nil {
return "", fmt.Errorf("read saved card policy: %w", policyErr)
}
snapshot := automaticTaskEnvironmentSnapshot{config: config, policy: policy}
actionCompleted := false
defer func() {
progress("正在恢复该 Profile 原先保存的卡策略")
if restoreErr := s.restoreAutomaticTaskEnvironment(physicalID, snapshot); restoreErr != nil {
if err == nil && actionCompleted {
output = ""
err = automaticTaskExecutionError{err: fmt.Errorf("task completed but card policy restoration failed: %w", restoreErr), retryable: false}
} else if err == nil {
err = fmt.Errorf("restore card policy: %w", restoreErr)
} else {
err = fmt.Errorf("%w; card policy restoration also failed: %v", err, restoreErr)
}
}
}()
if err := s.prepareAutomaticTaskEnvironment(ctx, &config, entry, physicalID, task, progress); err != nil {
return "", err
}
var payload automaticTaskPayload
if err := json.Unmarshal(task.Payload, &payload); err != nil {
return "", fmt.Errorf("decode task payload: %w", err)
}
switch task.TaskType {
case "sms":
progress("正在发送短信")
output, err = s.executeAutomaticSMS(ctx, task, payload)
case "call":
progress("正在发起通话")
output, err = s.executeAutomaticCall(ctx, task, payload)
case "public_ip":
progress("蜂窝数据已连接,正在查询漫游公网 IP")
output, err = s.executeAutomaticPublicIP(ctx, config, task.ProfileICCID)
default:
return "", fmt.Errorf("unsupported automatic task type %q", task.TaskType)
}
actionCompleted = err == nil
return output, err
}
func (s *Server) ensureAutomaticTaskProfile(ctx context.Context, task store.AutomaticTask, progress automaticTaskProgress) (store.Device, device.Device, string, error) {
config, err := s.store.Device(ctx, task.DeviceID)
if err != nil {
return store.Device{}, device.Device{}, "", fmt.Errorf("read device: %w", err)
}
entry, physicalID, present := s.physicalForConfig(config)
if !present || entry.Snapshot == nil {
return store.Device{}, device.Device{}, "", errors.New("configured device is offline")
}
if strings.EqualFold(strings.TrimSpace(entry.Snapshot.ICCID), strings.TrimSpace(task.ProfileICCID)) {
return config, entry, physicalID, nil
}
progress("正在切换到任务指定的 eSIM Profile")
if _, err := s.devices.SetFlight(ctx, physicalID, true); err != nil {
return store.Device{}, device.Device{}, "", fmt.Errorf("enter airplane mode before profile switch: %w", err)
}
if err := s.devices.ESIMSwitchProfile(ctx, physicalID, task.ProfileICCID, task.ProfileAID); err != nil {
return store.Device{}, device.Device{}, "", fmt.Errorf("switch eSIM profile: %w", err)
}
entry, physicalID, present = s.physicalForConfig(config)
if !present {
return store.Device{}, device.Device{}, "", errors.New("device did not recover after profile switch")
}
snapshot, err := s.devices.Refresh(ctx, physicalID)
if err != nil {
return store.Device{}, device.Device{}, "", fmt.Errorf("verify switched profile: %w", err)
}
if !strings.EqualFold(strings.TrimSpace(snapshot.ICCID), strings.TrimSpace(task.ProfileICCID)) {
return store.Device{}, device.Device{}, "", fmt.Errorf("profile verification failed: current ICCID is %s", firstNonEmpty(snapshot.ICCID, "unavailable"))
}
entry.Snapshot = &snapshot
return config, entry, physicalID, nil
}
func (s *Server) prepareAutomaticTaskEnvironment(ctx context.Context, config *store.Device, entry device.Device, physicalID string, task store.AutomaticTask, progress automaticTaskProgress) error {
iccid := strings.TrimSpace(task.ProfileICCID)
if task.Environment == "vowifi" {
progress("正在准备 VoWiFi 执行环境")
if task.TaskType == "public_ip" {
return errors.New("public IP tasks cannot run over VoWiFi")
}
if _, err := s.devices.SetFlight(ctx, physicalID, true); err != nil {
return fmt.Errorf("enable airplane mode for VoWiFi: %w", err)
}
config.VoWiFiEnabled, config.NetworkEnabled = true, false
if err := s.store.UpsertDevice(ctx, *config); err != nil {
return err
}
if err := s.store.UpsertCardPolicy(ctx, store.CardPolicy{ICCID: iccid, VoWiFiEnabled: true, AirplaneEnabled: true, IPVersion: "IPV4V6", Source: "automatic_task"}); err != nil {
return err
}
if s.vowifi == nil {
return errors.New("VoWiFi runtime is unavailable")
}
state, stateErr := s.vowifi.State(config.ID)
stateMatchesCard := state.ICCID == "" || strings.EqualFold(strings.TrimSpace(state.ICCID), iccid)
if stateErr == nil && stateMatchesCard && state.IMSReady && (task.TaskType != "sms" || state.SMSReady) {
return nil
}
if stateErr == nil && state.Enabled {
_, stateErr = s.vowifi.RequestReconnect(config.ID)
} else {
_, stateErr = s.vowifi.RequestEnabled(config.ID, true)
}
if stateErr != nil {
return fmt.Errorf("start VoWiFi: %w", stateErr)
}
return s.waitAutomaticVoWiFi(ctx, config.ID, iccid, task.TaskType == "sms")
}
if s.vowifi != nil {
if state, stateErr := s.vowifi.State(config.ID); stateErr == nil && (state.Enabled || state.Active) {
if _, stateErr = s.vowifi.RequestEnabled(config.ID, false); stateErr != nil {
return fmt.Errorf("stop VoWiFi: %w", stateErr)
}
if err := s.waitAutomaticVoWiFiStopped(ctx, config.ID); err != nil {
return err
}
}
}
progress("正在开启蜂窝无线并启用自动选网")
config.VoWiFiEnabled = false
config.NetworkEnabled = task.TaskType == "public_ip"
if err := s.store.UpsertDevice(ctx, *config); err != nil {
return err
}
policy, policyErr := s.store.CardPolicy(ctx, iccid)
if errors.Is(policyErr, store.ErrNotFound) {
policy = defaultCardPolicy(iccid)
policy.APN = config.APN
} else if policyErr != nil {
return policyErr
}
policy.NetworkEnabled = config.NetworkEnabled
policy.VoWiFiEnabled = false
policy.AirplaneEnabled = false
policy.Source = "automatic_task"
if err := s.store.UpsertCardPolicy(ctx, policy); err != nil {
return err
}
if task.TaskType != "public_ip" {
if _, err := s.devices.SetNetwork(ctx, physicalID, s.cardNetworkRequest(ctx, physicalID, *config, policy, false)); err != nil {
s.logger.Warn("automatic task could not stop unused cellular data", "device_id", config.ID)
}
}
if _, err := s.devices.SetFlight(ctx, physicalID, false); err != nil {
return fmt.Errorf("enable cellular radio: %w", err)
}
if _, err := s.devices.SetOperatorSelection(ctx, physicalID, true, "", nil); err != nil {
return fmt.Errorf("enable automatic network selection: %w", err)
}
if _, err := s.devices.ReRegisterOperator(ctx, physicalID); err != nil {
return fmt.Errorf("re-register cellular network: %w", err)
}
progress("正在搜索并注册蜂窝网络(漫游注册可能需要数分钟)")
if err := s.waitAutomaticCellular(ctx, physicalID, task.TaskType == "public_ip"); err != nil {
return err
}
if task.TaskType == "public_ip" {
if !s.developerActive(ctx) {
return errors.New("roaming public IP tasks require developer mode")
}
progress("已注册蜂窝网络,正在建立数据连接")
if _, err := s.devices.SetNetwork(ctx, physicalID, s.cardNetworkRequest(ctx, physicalID, *config, policy, true)); err != nil {
return fmt.Errorf("start roaming data: %w", err)
}
}
return nil
}
func (s *Server) waitAutomaticVoWiFi(ctx context.Context, deviceID, iccid string, requireSMS bool) error {
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
for {
state, err := s.vowifi.State(deviceID)
if err == nil && state.IMSReady && (!requireSMS || state.SMSReady) && (state.ICCID == "" || strings.EqualFold(state.ICCID, iccid)) {
return nil
}
if err == nil && state.LastError != "" && !state.Active && !state.Enabled {
return errors.New(state.LastError)
}
select {
case <-ctx.Done():
if err == nil && state.LastError != "" {
return fmt.Errorf("wait for VoWiFi readiness: %s", state.LastError)
}
return fmt.Errorf("wait for VoWiFi readiness: %w", ctx.Err())
case <-ticker.C:
}
}
}
func (s *Server) waitAutomaticVoWiFiStopped(ctx context.Context, deviceID string) error {
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for {
state, err := s.vowifi.State(deviceID)
if err != nil || (!state.Active && !state.Enabled) {
return nil
}
select {
case <-ctx.Done():
return fmt.Errorf("wait for VoWiFi shutdown: %w", ctx.Err())
case <-ticker.C:
}
}
}
func (s *Server) waitAutomaticCellular(ctx context.Context, physicalID string, requirePacketAttach bool) error {
ticker := time.NewTicker(3 * time.Second)
defer ticker.Stop()
stableSamples := 0
for {
snapshot, err := s.devices.Refresh(ctx, physicalID)
registered := err == nil && (snapshot.RegistrationStatus == 1 || snapshot.RegistrationStatus == 5)
if registered && (!requirePacketAttach || snapshot.PSAttached) {
stableSamples++
if stableSamples >= 2 {
return nil
}
} else {
stableSamples = 0
}
if err == nil && snapshot.RegistrationStatus == 3 {
return errors.New("cellular network registration was denied")
}
select {
case <-ctx.Done():
return fmt.Errorf("wait for cellular registration: %w", ctx.Err())
case <-ticker.C:
}
}
}
func (s *Server) executeAutomaticSMS(ctx context.Context, task store.AutomaticTask, payload automaticTaskPayload) (string, error) {
body, _ := json.Marshal(map[string]any{"device_id": task.DeviceID, "phone": payload.Phone, "message": payload.Message})
recorder := httptest.NewRecorder()
request := httptest.NewRequestWithContext(ctx, http.MethodPost, "/api/sms/send", bytes.NewReader(body))
request.Header.Set("Content-Type", "application/json")
s.handleSMSSend(recorder, request)
if recorder.Code < 200 || recorder.Code >= 300 {
failure := fmt.Errorf("send SMS failed (HTTP %d): %s", recorder.Code, compactAutomaticResponse(recorder.Body.Bytes()))
// Once any part reached the modem/IMS transaction, retrying the whole
// message could deliver a duplicate. Preparation failures remain safe to
// retry according to the configured count.
return "", automaticTaskExecutionError{err: failure, retryable: automaticSMSRetrySafe(recorder.Body.Bytes())}
}
return "短信已提交到 " + payload.Phone, nil
}
func automaticSMSRetrySafe(body []byte) bool {
var payload struct {
Data struct {
PartsAttempted int `json:"parts_attempted"`
PartsAccepted int `json:"parts_accepted"`
RetrySafe *bool `json:"retry_safe"`
} `json:"data"`
}
if json.Unmarshal(body, &payload) != nil {
return false
}
if payload.Data.RetrySafe != nil {
return *payload.Data.RetrySafe
}
return payload.Data.PartsAttempted == 0 && payload.Data.PartsAccepted == 0
}
func (s *Server) executeAutomaticCall(ctx context.Context, task store.AutomaticTask, payload automaticTaskPayload) (string, error) {
config, err := s.store.Device(ctx, task.DeviceID)
if err != nil {
return "", err
}
_, physicalID, present := s.physicalForConfig(config)
if !present {
return "", errors.New("configured device is offline")
}
body, _ := json.Marshal(map[string]any{"number": payload.Phone, "duration_seconds": payload.DurationSeconds})
recorder := httptest.NewRecorder()
request := httptest.NewRequestWithContext(ctx, http.MethodPost, "/api/devices/calls/dial", bytes.NewReader(body))
request.Header.Set("Content-Type", "application/json")
s.handleCallAction(recorder, request, config, physicalID, "dial")
if recorder.Code < 200 || recorder.Code >= 300 {
return "", fmt.Errorf("dial failed (HTTP %d): %s", recorder.Code, compactAutomaticResponse(recorder.Body.Bytes()))
}
return fmt.Sprintf("已拨打 %s,将在 %d 秒后自动挂断", payload.Phone, payload.DurationSeconds), nil
}
func (s *Server) executeAutomaticPublicIP(ctx context.Context, config store.Device, iccid string) (string, error) {
if strings.TrimSpace(config.Interface) == "" {
return "", errors.New("device has no cellular network interface")
}
info, err := exportproxy.LookupPublicIP(ctx, config.Interface)
if err != nil {
return "", fmt.Errorf("detect roaming public IP: %w", err)
}
s.savePublicIP(config.ID, iccid, info)
return strings.TrimSpace(fmt.Sprintf("公网 IP %s · %s %s", info.IP, info.CountryCode, info.Region)), nil
}
func (s *Server) restoreAutomaticTaskEnvironment(physicalID string, snapshot automaticTaskEnvironmentSnapshot) error {
cleanupContext, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
config, policy := snapshot.config, snapshot.policy
desiredNetwork := policy.NetworkEnabled && !policy.VoWiFiEnabled && !policy.AirplaneEnabled
config.APN = policy.APN
config.NetworkEnabled = desiredNetwork
config.VoWiFiEnabled = policy.VoWiFiEnabled
var restoreErrors []error
if err := s.store.UpsertCardPolicy(cleanupContext, policy); err != nil {
restoreErrors = append(restoreErrors, fmt.Errorf("persist card policy: %w", err))
}
if err := s.store.UpsertDevice(cleanupContext, config); err != nil {
restoreErrors = append(restoreErrors, fmt.Errorf("persist device policy: %w", err))
}
if policy.VoWiFiEnabled {
if _, err := s.devices.SetNetwork(cleanupContext, physicalID, s.cardNetworkRequest(cleanupContext, physicalID, config, policy, false)); err != nil {
restoreErrors = append(restoreErrors, fmt.Errorf("stop cellular data: %w", err))
}
if _, err := s.devices.SetFlight(cleanupContext, physicalID, true); err != nil {
restoreErrors = append(restoreErrors, fmt.Errorf("restore airplane mode: %w", err))
}
if s.vowifi == nil {
restoreErrors = append(restoreErrors, errors.New("VoWiFi runtime is unavailable"))
} else if state, stateErr := s.vowifi.State(config.ID); stateErr == nil && state.Enabled {
if _, err := s.vowifi.RequestReconnect(config.ID); err != nil {
restoreErrors = append(restoreErrors, fmt.Errorf("restore VoWiFi: %w", err))
}
} else if _, err := s.vowifi.RequestEnabled(config.ID, true); err != nil {
restoreErrors = append(restoreErrors, fmt.Errorf("restore VoWiFi: %w", err))
}
return errors.Join(restoreErrors...)
}
if s.vowifi != nil {
if state, stateErr := s.vowifi.State(config.ID); stateErr == nil && (state.Enabled || state.Active) {
if _, err := s.vowifi.RequestEnabled(config.ID, false); err != nil {
restoreErrors = append(restoreErrors, fmt.Errorf("stop VoWiFi: %w", err))
}
}
}
if policy.AirplaneEnabled {
if _, err := s.devices.SetNetwork(cleanupContext, physicalID, s.cardNetworkRequest(cleanupContext, physicalID, config, policy, false)); err != nil {
restoreErrors = append(restoreErrors, fmt.Errorf("stop cellular data: %w", err))
}
if _, err := s.devices.SetFlight(cleanupContext, physicalID, true); err != nil {
restoreErrors = append(restoreErrors, fmt.Errorf("restore airplane mode: %w", err))
}
return errors.Join(restoreErrors...)
}
if !desiredNetwork {
if _, err := s.devices.SetNetwork(cleanupContext, physicalID, s.cardNetworkRequest(cleanupContext, physicalID, config, policy, false)); err != nil {
restoreErrors = append(restoreErrors, fmt.Errorf("stop cellular data: %w", err))
}
}
if _, err := s.devices.SetFlight(cleanupContext, physicalID, false); err != nil {
restoreErrors = append(restoreErrors, fmt.Errorf("restore cellular radio: %w", err))
}
if desiredNetwork {
if _, err := s.devices.SetNetwork(cleanupContext, physicalID, s.cardNetworkRequest(cleanupContext, physicalID, config, policy, true)); err != nil {
restoreErrors = append(restoreErrors, fmt.Errorf("restore cellular data: %w", err))
}
}
return errors.Join(restoreErrors...)
}
func (s *Server) cardNetworkRequest(
ctx context.Context,
physicalID string,
config store.Device,
policy store.CardPolicy,
enabled bool,
) device.NetworkRequest {
request := device.NetworkRequest{
Enabled: enabled, APN: policy.APN, IPVersion: policy.IPVersion, Backend: config.DeviceBackend,
}
if request.IPVersion == "" {
request.IPVersion = "IPV4V6"
}
profile, err := s.store.CardAPNProfileByAPN(ctx, policy.ICCID, policy.APN, policy.IPVersion)
if err != nil {
return request
}
request.Username = profile.Username
request.Password = profile.Password
request.Authentication = profile.AuthType
if entry, getErr := s.devices.Get(physicalID); getErr == nil && entry.Snapshot != nil &&
entry.Snapshot.RegistrationStatus == 5 && profile.RoamingIPVersion != "" {
request.IPVersion = profile.RoamingIPVersion
}
return request
}
func compactAutomaticResponse(body []byte) string {
var payload map[string]any
if json.Unmarshal(body, &payload) == nil {
if apiErr, ok := payload["error"].(map[string]any); ok {
return firstNonEmpty(fmt.Sprint(apiErr["message"]), fmt.Sprint(apiErr["code"]), "request failed")
}
}
return strings.TrimSpace(string(body))
}
func (s *Server) routeAutomaticTasksAPI(w http.ResponseWriter, r *http.Request, cleanPath string) bool {
segments := splitAPIPath(cleanPath)
if len(segments) == 0 || segments[0] != "automatic-tasks" {
return false
}
if len(segments) == 1 {
s.handleAutomaticTasks(w, r)
return true
}
if len(segments) == 2 && segments[1] == "runs" {
s.handleAutomaticTaskRuns(w, r)
return true
}
id, err := strconv.ParseInt(segments[1], 10, 64)
if err != nil || id <= 0 {
writeError(w, http.StatusBadRequest, "invalid_task_id", "automatic task ID is invalid")
return true
}
if len(segments) == 2 {
s.handleAutomaticTask(w, r, id)
return true
}
if len(segments) == 3 && segments[2] == "run" {
s.handleAutomaticTaskRunNow(w, r, id)
return true
}
writeError(w, http.StatusNotFound, "not_found", "automatic task endpoint not found")
return true
}
func (s *Server) handleAutomaticTasks(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
tasks, err := s.store.ListAutomaticTasks(r.Context())
if err != nil {
s.writeStoreError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"tasks": tasks}})
case http.MethodPost:
task, err := s.decodeAutomaticTask(r, 0)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_automatic_task", err.Error())
return
}
saved, err := s.store.SaveAutomaticTask(r.Context(), task)
if err != nil {
s.writeStoreError(w, err)
return
}
writeJSON(w, http.StatusCreated, map[string]any{"data": saved})
default:
w.Header().Set("Allow", "GET, POST")
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
}
}
func (s *Server) handleAutomaticTask(w http.ResponseWriter, r *http.Request, id int64) {
switch r.Method {
case http.MethodPut:
task, err := s.decodeAutomaticTask(r, id)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_automatic_task", err.Error())
return
}
saved, err := s.store.SaveAutomaticTask(r.Context(), task)
if err != nil {
s.writeStoreError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"data": saved})
case http.MethodDelete:
if err := s.store.DeleteAutomaticTask(r.Context(), id); err != nil {
s.writeStoreError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"deleted": true}})
default:
w.Header().Set("Allow", "PUT, DELETE")
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
}
}
func (s *Server) handleAutomaticTaskRuns(w http.ResponseWriter, r *http.Request) {
if !requireMethod(w, r, http.MethodGet) {
return
}
query := r.URL.Query()
limit, _ := strconv.Atoi(query.Get("limit"))
offset, _ := strconv.Atoi(query.Get("offset"))
runs, total, err := s.store.ListAutomaticTaskRunsPaginated(r.Context(), limit, offset)
if err != nil {
s.writeStoreError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"runs": runs, "total": total}})
}
func (s *Server) handleAutomaticTaskRunNow(w http.ResponseWriter, r *http.Request, id int64) {
if !requireMethod(w, r, http.MethodPost) {
return
}
if s.automaticTasks == nil {
writeError(w, http.StatusServiceUnavailable, "scheduler_unavailable", "automatic task scheduler is unavailable")
return
}
task, err := s.store.AutomaticTask(r.Context(), id)
if err != nil {
s.writeStoreError(w, err)
return
}
run, err := s.store.QueueAutomaticTaskNow(r.Context(), task)
if err != nil {
s.writeStoreError(w, err)
return
}
s.automaticTasks.enqueue(run)
writeJSON(w, http.StatusAccepted, map[string]any{"data": run})
}
func (s *Server) decodeAutomaticTask(r *http.Request, id int64) (store.AutomaticTask, error) {
var request struct {
Name string `json:"name"`
Enabled bool `json:"enabled"`
DeviceID string `json:"device_id"`
ProfileICCID string `json:"profile_iccid"`
ProfileAID string `json:"profile_aid"`
TaskType string `json:"task_type"`
Environment string `json:"environment"`
IntervalDays int `json:"interval_days"`
StartDate string `json:"start_date"`
RunTime string `json:"run_time"`
Timezone string `json:"timezone"`
RetryCount int `json:"retry_count"`
Notify bool `json:"notify"`
Payload automaticTaskPayload `json:"payload"`
}
if err := s.decodeJSON(nilResponseWriter{}, r, &request); err != nil {
return store.AutomaticTask{}, err
}
request.Name, request.DeviceID = strings.TrimSpace(request.Name), strings.TrimSpace(request.DeviceID)
request.ProfileICCID, request.ProfileAID = strings.TrimSpace(request.ProfileICCID), strings.TrimSpace(request.ProfileAID)
request.TaskType, request.Environment = strings.ToLower(strings.TrimSpace(request.TaskType)), strings.ToLower(strings.TrimSpace(request.Environment))
if request.Name == "" || request.DeviceID == "" || request.ProfileICCID == "" {
return store.AutomaticTask{}, errors.New("name, device, and eSIM profile are required")
}
if _, err := s.store.Device(r.Context(), request.DeviceID); err != nil {
return store.AutomaticTask{}, errors.New("selected device does not exist")
}
if request.Environment != "vowifi" && request.Environment != "cellular" {
return store.AutomaticTask{}, errors.New("environment must be vowifi or cellular")
}
if request.TaskType != "sms" && request.TaskType != "call" && request.TaskType != "public_ip" {
return store.AutomaticTask{}, errors.New("unsupported task type")
}
if request.TaskType == "public_ip" && request.Environment != "cellular" {
return store.AutomaticTask{}, errors.New("public IP tasks must use cellular direct mode")
}
if request.IntervalDays < 1 || request.IntervalDays > 365 || request.RetryCount < 0 || request.RetryCount > 10 {
return store.AutomaticTask{}, errors.New("interval_days must be 1-365 and retry_count must be 0-10")
}
if request.TaskType == "sms" {
if !validDialNumber(request.Payload.Phone) || strings.TrimSpace(request.Payload.Message) == "" {
return store.AutomaticTask{}, errors.New("SMS phone and message are required")
}
if blocked, reason := blockedSMSDestination(request.Payload.Phone); blocked {
return store.AutomaticTask{}, errors.New(reason)
}
}
if request.TaskType == "call" && (!validDialNumber(request.Payload.Phone) || request.Payload.DurationSeconds < 1 || request.Payload.DurationSeconds > 600) {
return store.AutomaticTask{}, errors.New("call phone is required and automatic hang-up must be 1-600 seconds")
}
request.Timezone = strings.TrimSpace(request.Timezone)
if request.Timezone == "" {
request.Timezone = time.Local.String()
}
location, err := time.LoadLocation(request.Timezone)
if err != nil {
return store.AutomaticTask{}, errors.New("timezone must be a valid IANA time zone")
}
nextRun, err := nextAutomaticRun(request.StartDate, request.RunTime, request.IntervalDays, time.Now().In(location))
if err != nil {
return store.AutomaticTask{}, err
}
payload, _ := json.Marshal(request.Payload)
task := store.AutomaticTask{ID: id, Name: request.Name, Enabled: request.Enabled, DeviceID: request.DeviceID,
ProfileICCID: request.ProfileICCID, ProfileAID: request.ProfileAID, TaskType: request.TaskType,
Environment: request.Environment, IntervalDays: request.IntervalDays, StartDate: request.StartDate,
RunTime: request.RunTime, Timezone: request.Timezone, Payload: payload, RetryCount: request.RetryCount, Notify: request.Notify, NextRunAt: nextRun.UTC()}
if id != 0 {
if previous, previousErr := s.store.AutomaticTask(r.Context(), id); previousErr == nil {
task.CreatedAt, task.LastRunAt, task.LastStatus, task.LastError = previous.CreatedAt, previous.LastRunAt, previous.LastStatus, previous.LastError
}
}
return task, nil
}
func nextAutomaticRun(date, clock string, intervalDays int, now time.Time) (time.Time, error) {
location := now.Location()
start, err := time.ParseInLocation("2006-01-02 15:04", strings.TrimSpace(date)+" "+strings.TrimSpace(clock), location)
if err != nil {
return time.Time{}, errors.New("start_date and run_time must use YYYY-MM-DD and HH:MM")
}
for start.Before(now) {
start = start.AddDate(0, 0, intervalDays)
}
return start, nil
}
// nilResponseWriter is used only because decodeJSON's size/error contract is
// shared with HTTP handlers; decode errors are returned to the real handler.
type nilResponseWriter struct{}
func (nilResponseWriter) Header() http.Header { return make(http.Header) }
func (nilResponseWriter) Write([]byte) (int, error) { return 0, nil }
func (nilResponseWriter) WriteHeader(statusCode int) {}
+30
View File
@@ -0,0 +1,30 @@
package server
import (
"testing"
"time"
)
func TestNextAutomaticRunUsesIntervalAndLocalClock(t *testing.T) {
location := time.FixedZone("test", 8*60*60)
now := time.Date(2026, 8, 10, 12, 0, 0, 0, location)
next, err := nextAutomaticRun("2026-08-01", "09:30", 3, now)
if err != nil {
t.Fatal(err)
}
want := time.Date(2026, 8, 13, 9, 30, 0, 0, location)
if !next.Equal(want) {
t.Fatalf("next run = %v, want %v", next, want)
}
}
func TestAutomaticSMSRetrySafetyPreventsDuplicateSubmission(t *testing.T) {
unsafe := []byte(`{"data":{"parts_attempted":1,"parts_accepted":1,"retry_safe":false}}`)
if automaticSMSRetrySafe(unsafe) {
t.Fatal("partially submitted SMS was considered safe to retry")
}
safe := []byte(`{"data":{"parts_attempted":0,"parts_accepted":0}}`)
if !automaticSMSRetrySafe(safe) {
t.Fatal("unattempted SMS was not considered safe to retry")
}
}
+40 -15
View File
@@ -7,37 +7,62 @@ import (
)
func (s *Server) handleDeveloperSettings(w http.ResponseWriter, r *http.Request) {
if !s.developerEnabled {
if !s.developerActive(r.Context()) {
writeError(w, http.StatusNotFound, "not_found", "resource not found")
return
}
switch r.Method {
case http.MethodGet:
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{
"device_limit": developer.DeviceLimit(r.Context(), s.store, true),
"default_device_limit": developer.DefaultDeviceLimit,
"max_device_limit": developer.MaxDeviceLimit,
}})
s.writeDeveloperSettings(w, r)
case http.MethodPut:
var request struct {
DeviceLimit int `json:"device_limit"`
DeviceLimit *int `json:"device_limit"`
SMSHourlyLimit *int `json:"sms_hourly_limit"`
}
if err := s.decodeJSON(w, r, &request); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
return
}
if err := developer.SetDeviceLimit(r.Context(), s.store, request.DeviceLimit); err != nil {
writeError(w, http.StatusBadRequest, "invalid_device_limit", err.Error())
if request.DeviceLimit == nil && request.SMSHourlyLimit == nil {
writeError(w, http.StatusBadRequest, "invalid_request", "at least one developer setting is required")
return
}
s.recordAudit(r.Context(), "admin", "settings.developer.device_limit", "settings", "developer", "success", "device limit updated")
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{
"device_limit": request.DeviceLimit,
"default_device_limit": developer.DefaultDeviceLimit,
"max_device_limit": developer.MaxDeviceLimit,
}})
if request.DeviceLimit != nil && (*request.DeviceLimit < 1 || *request.DeviceLimit > developer.MaxDeviceLimit) {
writeError(w, http.StatusBadRequest, "invalid_device_limit", "device limit is outside the supported range")
return
}
if request.SMSHourlyLimit != nil && (*request.SMSHourlyLimit < 1 || *request.SMSHourlyLimit > developer.MaxSMSHourlyLimit) {
writeError(w, http.StatusBadRequest, "invalid_sms_hourly_limit", "SMS hourly limit is outside the supported range")
return
}
if request.DeviceLimit != nil {
if err := developer.SetDeviceLimit(r.Context(), s.store, *request.DeviceLimit); err != nil {
writeError(w, http.StatusBadRequest, "invalid_device_limit", err.Error())
return
}
s.recordAudit(r.Context(), "admin", "settings.developer.device_limit", "settings", "developer", "success", "device limit updated")
}
if request.SMSHourlyLimit != nil {
if err := developer.SetSMSHourlyLimit(r.Context(), s.store, *request.SMSHourlyLimit); err != nil {
writeError(w, http.StatusBadRequest, "invalid_sms_hourly_limit", err.Error())
return
}
s.recordAudit(r.Context(), "admin", "settings.developer.sms_hourly_limit", "settings", "developer", "success", "global SMS hourly limit updated")
}
s.writeDeveloperSettings(w, r)
default:
w.Header().Set("Allow", "GET, PUT")
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
}
}
func (s *Server) writeDeveloperSettings(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{
"device_limit": developer.DeviceLimit(r.Context(), s.store, true),
"default_device_limit": developer.DefaultDeviceLimit,
"max_device_limit": developer.MaxDeviceLimit,
"sms_hourly_limit": developer.SMSHourlyLimit(r.Context(), s.store),
"default_sms_hourly_limit": developer.DefaultSMSHourlyLimit,
"max_sms_hourly_limit": developer.MaxSMSHourlyLimit,
}})
}
@@ -1,9 +1,15 @@
package server
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"vocat/internal/developer"
"vocat/internal/store"
)
func TestDeveloperOnlySettingsAreHiddenWhenModeIsOff(t *testing.T) {
@@ -20,3 +26,27 @@ func TestDeveloperOnlySettingsAreHiddenWhenModeIsOff(t *testing.T) {
}
}
}
func TestDeveloperSettingsUpdatesGlobalSMSLimit(t *testing.T) {
ctx := context.Background()
database, err := store.Open(ctx, ":memory:")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = database.Close() })
enabled, _ := json.Marshal(map[string]bool{"enabled": true})
if err := database.UpsertAppSetting(ctx, store.AppSetting{Key: developer.EnabledSettingKey, Value: enabled}); err != nil {
t.Fatal(err)
}
server := &Server{store: database, developerEnabled: true, logger: regionTestLogger(), maxRequestBodyBytes: 4096}
request := httptest.NewRequest(http.MethodPut, "/api/settings/developer", strings.NewReader(`{"sms_hourly_limit":25}`))
request.Header.Set("Content-Type", "application/json")
response := httptest.NewRecorder()
server.handleDeveloperSettings(response, request)
if response.Code != http.StatusOK {
t.Fatalf("status = %d, body=%s", response.Code, response.Body.String())
}
if got := developer.SMSHourlyLimit(ctx, database); got != 25 {
t.Fatalf("SMS hourly limit = %d, want 25", got)
}
}
+328 -36
View File
@@ -2,6 +2,7 @@ package server
import (
"context"
"encoding/csv"
"encoding/json"
"errors"
"fmt"
@@ -236,14 +237,46 @@ func (s *Server) handleDevices(w http.ResponseWriter, r *http.Request) bool {
return true
}
config := payload.toStoreDevice()
// Newly added hardware starts fail-closed: RF is disabled immediately and
// VoWiFi becomes the desired service. Cellular registration is only
// restored by the user's later airplane-mode-off action.
config.VoWiFiEnabled = true
config.NetworkEnabled = false
if !s.developerActive(r.Context()) {
config.NetworkEnabled = false
}
fillConfigFromPhysical(&config, *selected)
if selector, ok := s.devices.(interface{ SetBackend(string, string) error }); ok {
if err := selector.SetBackend(selected.ID, config.DeviceBackend); err != nil {
s.writeDeviceError(w, err)
return true
}
}
if _, err := s.devices.SetFlight(r.Context(), selected.ID, true); err != nil {
s.writeDeviceError(w, err)
return true
}
if err := s.store.UpsertDevice(r.Context(), config); err != nil {
s.writeStoreError(w, err)
return true
}
if selected.Snapshot != nil {
iccid := strings.TrimSpace(selected.Snapshot.ICCID)
if iccid != "" {
if err := s.store.UpsertCardPolicy(r.Context(), store.CardPolicy{
ICCID: iccid, VoWiFiEnabled: true, AirplaneEnabled: true,
IPVersion: "IPV4V6", Source: "default",
}); err != nil {
s.writeStoreError(w, err)
return true
}
}
}
if s.vowifi != nil {
if _, err := s.vowifi.RequestEnabled(config.ID, true); err != nil {
s.logger.Warn("new device saved in safe airplane mode but VoWiFi start was not queued", "device_id", config.ID, "error", err)
}
}
writeJSON(w, http.StatusCreated, map[string]any{
"data": map[string]any{
"status": "created",
@@ -416,9 +449,20 @@ func (s *Server) handleDevicePath(
}
next.ID = id
next.CreatedAt = config.CreatedAt
// VoWiFi/airplane transitions are transactional device actions. A
// general config save must not silently bypass their RF-safe ordering.
next.VoWiFiEnabled = config.VoWiFiEnabled
if next.Name == id && strings.TrimSpace(payload.Name) == "" {
next.Name = config.Name
}
if _, physicalID, present := s.physicalForConfig(next); present {
if selector, ok := s.devices.(interface{ SetBackend(string, string) error }); ok {
if err := selector.SetBackend(physicalID, next.DeviceBackend); err != nil {
s.writeDeviceError(w, err)
return true
}
}
}
if err := s.store.UpsertDevice(r.Context(), next); err != nil {
s.writeStoreError(w, err)
return true
@@ -435,7 +479,7 @@ func (s *Server) handleDevicePath(
entry, physicalID, physicalPresent := s.physicalForConfig(config)
if len(tail) > 0 && tail[0] == "esim" {
return s.handleESIM(w, r, tail[1:], physicalID, physicalPresent)
return s.handleESIM(w, r, tail[1:], physicalID, physicalPresent, config.ID)
}
switch strings.Join(tail, "/") {
case "overview":
@@ -503,12 +547,17 @@ func (s *Server) handleDevicePath(
if !s.requirePhysicalDevice(w, physicalPresent) {
return true
}
return s.handleFlightMode(w, r, physicalID)
return s.handleFlightMode(w, r, config, physicalID)
case "network":
if !s.requirePhysicalDevice(w, physicalPresent) {
return true
}
return s.handleCellularData(w, r, config, physicalID)
case "network/apns":
if !s.requirePhysicalDevice(w, physicalPresent) {
return true
}
return s.handleAPNProfiles(w, r, physicalID)
case "network/public-ip":
if !s.requirePhysicalDevice(w, physicalPresent) {
return true
@@ -756,9 +805,59 @@ func (s *Server) handleVoWiFiEnabled(
}
}
// Establish RF-off synchronously before changing the asynchronous VoWiFi
// lifecycle. This removes the attach window both when entering VoWiFi and
// when leaving it: teardown starts from CFUN=4 and is required to remain
// there until the user explicitly disables airplane mode.
previous := config.VoWiFiEnabled
liveICCID := ""
entry, physicalID, present := s.physicalForConfig(config)
if present {
if _, err := s.devices.SetFlight(r.Context(), physicalID, true); err != nil {
s.writeDeviceError(w, err)
return true
}
}
if entry.Snapshot != nil {
iccid := strings.TrimSpace(entry.Snapshot.ICCID)
if iccid != "" {
liveICCID = iccid
policy, policyErr := s.store.CardPolicy(r.Context(), iccid)
if errors.Is(policyErr, store.ErrNotFound) {
policy = store.CardPolicy{ICCID: iccid, IPVersion: "IPV4V6"}
policyErr = nil
}
if policyErr != nil {
s.writeStoreError(w, policyErr)
return true
}
policy.VoWiFiEnabled = request.Enabled
policy.AirplaneEnabled = true
policy.NetworkEnabled = false
policy.Source = "manual"
if err := s.store.UpsertCardPolicy(r.Context(), policy); err != nil {
s.writeStoreError(w, err)
return true
}
}
}
rollbackCardPolicy := func() {
if liveICCID == "" {
return
}
policy, policyErr := s.store.CardPolicy(context.Background(), liveICCID)
if policyErr != nil {
return
}
policy.VoWiFiEnabled = previous
policy.AirplaneEnabled = true
policy.NetworkEnabled = false
_ = s.store.UpsertCardPolicy(context.Background(), policy)
}
config.VoWiFiEnabled = request.Enabled
if err := s.store.UpsertDevice(r.Context(), config); err != nil {
rollbackCardPolicy()
s.writeStoreError(w, err)
return true
}
@@ -780,6 +879,7 @@ func (s *Server) handleVoWiFiEnabled(
return true
}
config.VoWiFiEnabled = previous
rollbackCardPolicy()
if restoreErr := s.store.UpsertDevice(r.Context(), config); restoreErr != nil {
s.logger.Error(
"restore VoWiFi policy after rejected runtime operation",
@@ -983,7 +1083,7 @@ func (s *Server) handleUSSD(w http.ResponseWriter, r *http.Request, id string) b
return true
}
func (s *Server) handleFlightMode(w http.ResponseWriter, r *http.Request, id string) bool {
func (s *Server) handleFlightMode(w http.ResponseWriter, r *http.Request, config store.Device, physicalID string) bool {
if !requireMethod(w, r, http.MethodPatch) {
return true
}
@@ -994,7 +1094,11 @@ func (s *Server) handleFlightMode(w http.ResponseWriter, r *http.Request, id str
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
return true
}
result, err := s.devices.SetFlight(r.Context(), id, request.Enabled)
if config.VoWiFiEnabled {
writeError(w, http.StatusConflict, "vowifi_owns_airplane_mode", "airplane mode is locked on while VoWiFi is enabled")
return true
}
result, err := s.devices.SetFlight(r.Context(), physicalID, request.Enabled)
if err != nil {
s.writeDeviceError(w, err)
return true
@@ -1002,7 +1106,7 @@ func (s *Server) handleFlightMode(w http.ResponseWriter, r *http.Request, id str
// Unlike VoWiFi, CFUN airplane state is not represented in the device row.
// Persist it against the live ICCID so a restart can distinguish an
// intentional airplane policy from an interrupted VoWiFi teardown.
if entry, getErr := s.devices.Get(id); getErr == nil && entry.Snapshot != nil {
if entry, getErr := s.devices.Get(physicalID); getErr == nil && entry.Snapshot != nil {
iccid := strings.TrimSpace(entry.Snapshot.ICCID)
if iccid != "" {
policy, policyErr := s.store.CardPolicy(r.Context(), iccid)
@@ -1029,6 +1133,65 @@ func (s *Server) handleFlightMode(w http.ResponseWriter, r *http.Request, id str
return true
}
type modemAPNProfile struct {
CID int `json:"cid"`
APN string `json:"apn"`
IPVersion string `json:"ip_version"`
}
func parseModemAPNProfiles(lines []string) []modemAPNProfile {
profiles := make([]modemAPNProfile, 0)
seen := make(map[string]bool)
for _, line := range lines {
line = strings.TrimSpace(line)
prefix := strings.Index(strings.ToUpper(line), "+CGDCONT:")
if prefix < 0 {
continue
}
record, err := csv.NewReader(strings.NewReader(strings.TrimSpace(line[prefix+len("+CGDCONT:"):]))).Read()
if err != nil || len(record) < 3 {
continue
}
cid, err := strconv.Atoi(strings.TrimSpace(record[0]))
if err != nil || cid < 1 {
continue
}
ipVersion := strings.ToUpper(strings.TrimSpace(record[1]))
if ipVersion == "IPV4" {
ipVersion = "IP"
}
if ipVersion != "IP" && ipVersion != "IPV6" && ipVersion != "IPV4V6" {
continue
}
apn := strings.TrimSpace(record[2])
if apn == "" || !device.ValidAPN(apn) {
continue
}
key := strings.ToLower(apn) + "\x00" + ipVersion
if seen[key] {
continue
}
seen[key] = true
profiles = append(profiles, modemAPNProfile{CID: cid, APN: apn, IPVersion: ipVersion})
}
return profiles
}
func (s *Server) handleAPNProfiles(w http.ResponseWriter, r *http.Request, physicalID string) bool {
if !requireMethod(w, r, http.MethodGet) {
return true
}
response, err := s.devices.ExecuteAT(r.Context(), physicalID, "AT+CGDCONT?")
if err != nil {
s.writeDeviceError(w, err)
return true
}
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{
"items": parseModemAPNProfiles(response.Lines),
}})
return true
}
func (s *Server) handleCellularData(
w http.ResponseWriter,
r *http.Request,
@@ -1067,32 +1230,75 @@ func (s *Server) handleCellularData(
}
}
apn := strings.TrimSpace(request.APN)
if apn == "" {
policyIPVersion := "IPV4V6"
activeICCID := ""
isRoaming := false
var activePolicy store.CardPolicy
var activeAPNProfile store.CardAPNProfile
if entry, getErr := s.devices.Get(physicalID); getErr == nil && entry.Snapshot != nil {
activeICCID = strings.TrimSpace(entry.Snapshot.ICCID)
isRoaming = entry.Snapshot.RegistrationStatus == 5
if stored, policyErr := s.store.CardPolicy(r.Context(), activeICCID); policyErr == nil {
activePolicy = stored
if apn == "" {
apn = strings.TrimSpace(stored.APN)
}
if stored.IPVersion != "" {
policyIPVersion = stored.IPVersion
}
}
}
if apn == "" && activePolicy.ICCID == "" {
apn = strings.TrimSpace(config.APN)
}
if !device.ValidAPN(apn) {
writeError(w, http.StatusBadRequest, "invalid_apn", "APN must contain only letters, digits, dots, underscores, or hyphens")
return true
}
if profile, profileErr := s.store.CardAPNProfileByAPN(r.Context(), activeICCID, apn, policyIPVersion); profileErr == nil {
activeAPNProfile = profile
}
effectiveIPVersion := policyIPVersion
if isRoaming && activeAPNProfile.RoamingIPVersion != "" {
effectiveIPVersion = activeAPNProfile.RoamingIPVersion
}
networkRequest := device.NetworkRequest{
Enabled: request.Enabled, APN: apn, IPVersion: effectiveIPVersion,
Username: activeAPNProfile.Username, Password: activeAPNProfile.Password,
Authentication: activeAPNProfile.AuthType, Backend: config.DeviceBackend,
}
controller := http.NewResponseController(w)
_ = controller.SetWriteDeadline(time.Time{})
result, err := s.devices.SetNetwork(r.Context(), physicalID, device.NetworkRequest{
Enabled: request.Enabled, APN: apn, IPVersion: "IPV4V6",
})
result, err := s.devices.SetNetwork(r.Context(), physicalID, networkRequest)
if err != nil {
s.writeDeviceError(w, err)
return true
}
previous := config.NetworkEnabled
config.NetworkEnabled = request.Enabled
if apn != "" {
config.APN = apn
}
config.APN = apn
if err := s.store.UpsertDevice(r.Context(), config); err != nil {
rollbackContext, cancel := context.WithTimeout(context.Background(), 20*time.Second)
_, _ = s.devices.SetNetwork(rollbackContext, physicalID, device.NetworkRequest{
Enabled: previous, APN: config.APN, IPVersion: "IPV4V6",
})
networkRequest.Enabled = previous
networkRequest.APN = config.APN
_, _ = s.devices.SetNetwork(rollbackContext, physicalID, networkRequest)
cancel()
s.writeStoreError(w, err)
return true
}
if validICCID(activeICCID) {
if activePolicy.ICCID == "" {
activePolicy = defaultCardPolicy(activeICCID)
}
activePolicy.APN = apn
activePolicy.IPVersion = policyIPVersion
if strings.TrimSpace(request.APN) != "" {
activePolicy.Source = "manual"
}
if err := s.store.UpsertCardPolicy(r.Context(), activePolicy); err != nil {
s.logger.Warn("cellular APN active but card policy could not be updated", "device_id", config.ID, "iccid", activeICCID, "error", err)
}
}
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{
"enabled": result.Enabled, "interface": result.Interface,
"backend": result.Backend, "export_proxy_only": true,
@@ -1155,8 +1361,10 @@ func (s *Server) writeDeviceError(w http.ResponseWriter, err error) {
case errors.Is(err, context.Canceled):
writeError(w, http.StatusRequestTimeout, "request_canceled", "the modem request was canceled")
default:
s.logger.Warn("device operation failed", "error", err)
writeError(w, http.StatusBadGateway, "modem_error", err.Error())
// Device errors may echo an AT command. Authentication commands can
// contain APN credentials, so keep raw errors out of logs and responses.
s.logger.Warn("device operation failed")
writeError(w, http.StatusBadGateway, "modem_error", "the device operation failed")
}
}
@@ -1266,28 +1474,46 @@ func (s *Server) configuredDeviceSummary(
result["network_connected"] = config.NetworkEnabled
result["data_connected"] = config.NetworkEnabled
result["vowifi_enabled"] = config.VoWiFiEnabled
if runtime, err := s.store.VoWiFiRuntime(context.Background(), config.ID); err == nil {
currentICCID := ""
var currentSnapshot *device.Snapshot
if entry != nil {
currentSnapshot = entry.Snapshot
if entry.Snapshot != nil {
currentICCID = strings.TrimSpace(entry.Snapshot.ICCID)
var runtimeResponse map[string]any
runtimeMatchesCard := true
if s.vowifi != nil {
if runtime, err := s.vowifi.State(config.ID); err == nil {
runtimeMatchesCard = voWiFiRuntimeMatchesSnapshot(runtime.ICCID, entry)
if runtimeMatchesCard {
runtimeResponse = liveVoWiFiRuntime(runtime)
} else {
runtimeResponse = idleVoWiFiRuntime(config.ID, snapshotForEntry(entry))
}
}
runtimeMatchesCard := currentICCID == "" || runtime.ICCID == "" ||
strings.EqualFold(currentICCID, strings.TrimSpace(runtime.ICCID))
var runtimeResponse map[string]any
if runtimeMatchesCard {
runtimeResponse = storedVoWiFiRuntime(runtime)
} else {
// The saved IMS session belongs to a different eSIM profile. Never
// project its registration or number onto the currently selected SIM.
runtimeResponse = idleVoWiFiRuntime(config.ID, currentSnapshot)
}
result["vowifi_runtime"] = runtimeResponse
result["vowifi_active"] = config.VoWiFiEnabled && runtimeMatchesCard && runtime.TunnelReady
}
if runtimeResponse == nil {
if runtime, err := s.store.VoWiFiRuntime(context.Background(), config.ID); err == nil {
currentICCID := ""
var currentSnapshot *device.Snapshot
if entry != nil {
currentSnapshot = entry.Snapshot
if entry.Snapshot != nil {
currentICCID = strings.TrimSpace(entry.Snapshot.ICCID)
}
}
runtimeMatchesCard = currentICCID == "" || runtime.ICCID == "" ||
strings.EqualFold(currentICCID, strings.TrimSpace(runtime.ICCID))
if runtimeMatchesCard {
runtimeResponse = storedVoWiFiRuntime(runtime)
} else {
// The saved IMS session belongs to a different eSIM profile. Never
// project its registration or number onto the currently selected SIM.
runtimeResponse = idleVoWiFiRuntime(config.ID, currentSnapshot)
}
}
}
if runtimeResponse == nil {
runtimeResponse = idleVoWiFiRuntime(config.ID, snapshotForEntry(entry))
}
result["vowifi_runtime"] = runtimeResponse
runtimeEnabled, _ := runtimeResponse["enabled"].(bool)
runtimeTunnelReady, _ := runtimeResponse["tunnel_ready"].(bool)
result["vowifi_active"] = config.VoWiFiEnabled && runtimeMatchesCard && runtimeEnabled && runtimeTunnelReady
// Numbers are SIM-owned data. Resolve the association by the live ICCID
// instead of reusing the last VoWiFi runtime attached to this device ID.
if entry != nil && entry.Snapshot != nil {
@@ -1390,9 +1616,14 @@ func (s *Server) configuredDeviceStatus(
}
func storedVoWiFiRuntime(runtime store.VoWiFiRuntime) map[string]any {
extra, _ := rawJSONObject(runtime.Extra).(map[string]any)
enabled, _ := extra["enabled"].(bool)
active, _ := extra["active"].(bool)
return map[string]any{
"device_id": runtime.DeviceID,
"phase": runtime.Phase,
"enabled": enabled,
"active": active,
"dataplane_mode": runtime.DataplaneMode,
"iccid": runtime.ICCID,
"imsi": runtime.IMSI,
@@ -1416,6 +1647,63 @@ func storedVoWiFiRuntime(runtime store.VoWiFiRuntime) map[string]any {
}
}
func liveVoWiFiRuntime(runtime vowifi.State) map[string]any {
return map[string]any{
"device_id": runtime.DeviceID,
"phase": string(runtime.Phase),
"enabled": runtime.Enabled,
"active": runtime.Active,
"dataplane_mode": runtime.DataplaneMode,
"iccid": runtime.ICCID,
"imsi": runtime.IMSI,
"sim_ready": runtime.SIMReady,
"access_ready": runtime.AccessReady,
"tunnel_ready": runtime.TunnelReady,
"ims_ready": runtime.IMSReady,
"sms_ready": runtime.SMSReady,
"reg_status": map[bool]int{true: 1, false: 0}[runtime.IMSReady],
"reg_status_text": map[bool]string{true: "registered", false: "not registered"}[runtime.IMSReady],
"network_mode": "Wi-Fi",
"local_phone": runtime.PhoneNumber,
"phone_number_source": runtime.PhoneNumberSource,
"last_error_class": runtime.LastErrorClass,
"last_error": runtime.LastError,
"last_reason": runtime.LastReason,
"updated_at": runtime.UpdatedAt,
"tunnel": map[string]any{
"established": runtime.TunnelReady,
"name": runtime.TunnelName,
"dataplane_mode": runtime.DataplaneMode,
"epdg": runtime.EPDG,
"proxy_mode": runtime.ProxyMode,
"proxy_id": runtime.ProxyID,
"security_audit": runtime.Security,
},
"imscore": map[string]any{
"registered": runtime.IMSReady,
"registration_state": runtime.IMSRegistration,
"associated_number": runtime.PhoneNumber,
"number_source": runtime.PhoneNumberSource,
},
"smsip": map[string]any{"ready": runtime.SMSReady},
}
}
func snapshotForEntry(entry *device.Device) *device.Snapshot {
if entry == nil {
return nil
}
return entry.Snapshot
}
func voWiFiRuntimeMatchesSnapshot(runtimeICCID string, entry *device.Device) bool {
current := strings.TrimSpace(snapshotString(snapshotForEntry(entry), func(snapshot *device.Snapshot) string {
return snapshot.ICCID
}))
runtimeICCID = strings.TrimSpace(runtimeICCID)
return current == "" || runtimeICCID == "" || strings.EqualFold(current, runtimeICCID)
}
func rawJSONObject(value json.RawMessage) any {
var result any
if len(value) != 0 && json.Unmarshal(value, &result) == nil {
@@ -1569,6 +1857,7 @@ func modemSummary(snapshot *device.Snapshot, phone string, phoneSource string) m
"operator": "",
"native_mcc": "",
"native_mnc": "",
"native_spn": "",
"operator_country_code": "",
"card_mcc": "",
"card_mnc": "",
@@ -1598,6 +1887,7 @@ func modemSummary(snapshot *device.Snapshot, phone string, phoneSource string) m
"operator": snapshot.OperatorName,
"native_mcc": mcc,
"native_mnc": mnc,
"native_spn": snapshot.SPN,
"operator_country_code": operatorCountryCode,
"card_mcc": cardMCC,
"card_mnc": cardMNC,
@@ -1643,6 +1933,8 @@ func idleVoWiFiRuntime(id string, snapshot *device.Snapshot) map[string]any {
return map[string]any{
"device_id": id,
"phase": "idle",
"enabled": false,
"active": false,
"dataplane_mode": "",
"iccid": iccid,
"imsi": imsi,
+135 -4
View File
@@ -31,6 +31,46 @@ func decodeData(t *testing.T, recorder *httptest.ResponseRecorder) map[string]an
return envelope.Data
}
func TestParseModemAPNProfiles(t *testing.T) {
profiles := parseModemAPNProfiles([]string{
`+CGDCONT: 1,"IPV4V6","internet","0.0.0.0",0,0`,
`+CGDCONT: 2,"IP","ims","0.0.0.0",0,0`,
`+CGDCONT: 3,"IPV4V6","internet","0.0.0.0",0,0`,
`+CGDCONT: 4,"IP","","0.0.0.0",0,0`,
})
if len(profiles) != 2 {
t.Fatalf("profiles = %#v", profiles)
}
if profiles[0].CID != 1 || profiles[0].APN != "internet" || profiles[0].IPVersion != "IPV4V6" {
t.Fatalf("first profile = %#v", profiles[0])
}
if profiles[1].CID != 2 || profiles[1].APN != "ims" || profiles[1].IPVersion != "IP" {
t.Fatalf("second profile = %#v", profiles[1])
}
}
type esimAIDCaptureController struct {
fakeDeviceController
switchAID string
disableAID string
renameAID string
}
func (controller *esimAIDCaptureController) ESIMSwitchProfile(_ context.Context, _, _, aidHex string) error {
controller.switchAID = aidHex
return nil
}
func (controller *esimAIDCaptureController) ESIMDisableProfile(_ context.Context, _, _, aidHex string) error {
controller.disableAID = aidHex
return nil
}
func (controller *esimAIDCaptureController) ESIMRenameProfile(_ context.Context, _, _, _, aidHex string) error {
controller.renameAID = aidHex
return nil
}
func TestAttachSingleEUICCIdentityFillsProfileGroupMetadataKey(t *testing.T) {
groups := []map[string]any{{"eid": "", "aidHex": "", "profiles": []any{}}}
chipInfo := map[string]any{
@@ -256,9 +296,25 @@ func TestHandleESIMShapes(t *testing.T) {
}
// Switch happy path: a present device + fake controller switches by ICCID.
present := &Server{logger: regionTestLogger(), maxRequestBodyBytes: 4096, devices: fakeDeviceController{}}
database, err := store.Open(context.Background(), ":memory:")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = database.Close() })
if err := database.UpsertDevice(context.Background(), store.Device{ID: "dev1", Name: "dev1"}); err != nil {
t.Fatal(err)
}
const switchedICCID = "8900000000000000001"
if err := database.UpsertCardPolicy(context.Background(), store.CardPolicy{
ICCID: switchedICCID, VoWiFiEnabled: false, AirplaneEnabled: false,
APN: "profile.apn", IPVersion: "IP", Source: "manual",
}); err != nil {
t.Fatal(err)
}
controller := &esimAIDCaptureController{}
present := &Server{store: database, logger: regionTestLogger(), maxRequestBodyBytes: 4096, devices: controller}
swOK := httptest.NewRecorder()
swReq := httptest.NewRequest(http.MethodPost, "/esim/actions/switch", strings.NewReader(`{"iccid":"8900000000000000001","aid_hex":"A0"}`))
swReq := httptest.NewRequest(http.MethodPost, "/esim/actions/switch", strings.NewReader(`{"iccid":"8900000000000000001","aidHex":"A0000005591010FFFFFFFF8900000177"}`))
swReq.Header.Set("Content-Type", "application/json")
present.handleESIM(swOK, swReq, []string{"actions", "switch"}, "dev1", true)
if swOK.Code != http.StatusOK {
@@ -267,10 +323,21 @@ func TestHandleESIMShapes(t *testing.T) {
if data := decodeData(t, swOK); data["status"] != "switched" || data["verified"] != true {
t.Fatalf("switch data = %v", data)
}
if controller.switchAID != "A0000005591010FFFFFFFF8900000177" {
t.Fatalf("switch AID = %q, want XeSIM camelCase AID", controller.switchAID)
}
storedPolicy, err := database.CardPolicy(context.Background(), switchedICCID)
if err != nil || storedPolicy.VoWiFiEnabled || storedPolicy.AirplaneEnabled || storedPolicy.APN != "profile.apn" || storedPolicy.IPVersion != "IP" {
t.Fatalf("switch overwrote saved policy: %+v, %v", storedPolicy, err)
}
storedDevice, err := database.Device(context.Background(), "dev1")
if err != nil || storedDevice.VoWiFiEnabled || storedDevice.APN != "profile.apn" {
t.Fatalf("switch did not restore device policy: %+v, %v", storedDevice, err)
}
// Disable happy path routes the active profile to ES10c DisableProfile.
disableOK := httptest.NewRecorder()
disableReq := httptest.NewRequest(http.MethodPost, "/esim/actions/disable", strings.NewReader(`{"iccid":"8900000000000000001","aid_hex":"A0000005591010FFFFFFFF8900000100"}`))
disableReq := httptest.NewRequest(http.MethodPost, "/esim/actions/disable", strings.NewReader(`{"iccid":"8900000000000000001","aidHex":"A0000005591010FFFFFFFF8900000177"}`))
disableReq.Header.Set("Content-Type", "application/json")
present.handleESIM(disableOK, disableReq, []string{"actions", "disable"}, "dev1", true)
if disableOK.Code != http.StatusOK {
@@ -279,10 +346,13 @@ func TestHandleESIMShapes(t *testing.T) {
if data := decodeData(t, disableOK); data["status"] != "disabled" || data["recovering"] != true {
t.Fatalf("disable data = %v", data)
}
if controller.disableAID != "A0000005591010FFFFFFFF8900000177" {
t.Fatalf("disable AID = %q, want XeSIM camelCase AID", controller.disableAID)
}
// Rename happy path routes PATCH to ES10c SetNickname support.
renameOK := httptest.NewRecorder()
renameReq := httptest.NewRequest(http.MethodPatch, "/esim/profiles/8900000000000000001", strings.NewReader(`{"name":"Test profile","aid_hex":"A0000005591010FFFFFFFF8900000100"}`))
renameReq := httptest.NewRequest(http.MethodPatch, "/esim/profiles/8900000000000000001", strings.NewReader(`{"name":"Test profile","aidHex":"A0000005591010FFFFFFFF8900000177"}`))
renameReq.Header.Set("Content-Type", "application/json")
present.handleESIM(renameOK, renameReq, []string{"profiles", "8900000000000000001"}, "dev1", true)
if renameOK.Code != http.StatusOK {
@@ -291,6 +361,9 @@ func TestHandleESIMShapes(t *testing.T) {
if data := decodeData(t, renameOK); data["status"] != "renamed" || data["name"] != "Test profile" {
t.Fatalf("rename data = %v", data)
}
if controller.renameAID != "A0000005591010FFFFFFFF8900000177" {
t.Fatalf("rename AID = %q, want XeSIM camelCase AID", controller.renameAID)
}
// Download on a present device but with no smdp address reports 400.
dlNoSmdp := httptest.NewRecorder()
@@ -300,6 +373,64 @@ func TestHandleESIMShapes(t *testing.T) {
}
}
type fakeEsimNotificationController struct {
fakeDeviceController
items []device.EsimNotification
listErr error
retryErr error
retryDeviceID string
retryAID string
retrySequence uint64
}
func (f *fakeEsimNotificationController) ESIMNotifications(context.Context, string) ([]device.EsimNotification, error) {
return f.items, f.listErr
}
func (f *fakeEsimNotificationController) ESIMRetryNotification(_ context.Context, deviceID, aidHex string, sequenceNumber uint64) error {
f.retryDeviceID = deviceID
f.retryAID = aidHex
f.retrySequence = sequenceNumber
return f.retryErr
}
func TestHandleESIMNotificationsListAndRetry(t *testing.T) {
controller := &fakeEsimNotificationController{items: []device.EsimNotification{{
SequenceNumber: 12,
Event: "delete",
ICCID: "89441000400128014257",
Address: "rsp.example.com",
AIDHex: "A0000005591010FFFFFFFF8900000100",
CanRetry: true,
}}}
server := &Server{logger: regionTestLogger(), devices: controller}
list := httptest.NewRecorder()
server.handleESIM(list, httptest.NewRequest(http.MethodGet, "/esim/notifications", nil), []string{"notifications"}, "dev1", true)
if list.Code != http.StatusOK {
t.Fatalf("list status = %d, body=%s", list.Code, list.Body.String())
}
data := decodeData(t, list)
items, ok := data["items"].([]any)
if !ok || len(items) != 1 {
t.Fatalf("items = %#v", data["items"])
}
item := items[0].(map[string]any)
if item["sequenceNumber"] != float64(12) || item["event"] != "delete" || item["address"] != "rsp.example.com" {
t.Fatalf("item = %#v", item)
}
retry := httptest.NewRecorder()
retryRequest := httptest.NewRequest(http.MethodPost, "/esim/notifications/12/actions/retry?aid_hex=A000", nil)
server.handleESIM(retry, retryRequest, []string{"notifications", "12", "actions", "retry"}, "dev1", true)
if retry.Code != http.StatusOK {
t.Fatalf("retry status = %d, body=%s", retry.Code, retry.Body.String())
}
if controller.retryDeviceID != "dev1" || controller.retryAID != "A000" || controller.retrySequence != 12 {
t.Fatalf("retry args = (%q, %q, %d)", controller.retryDeviceID, controller.retryAID, controller.retrySequence)
}
}
func TestHandleFixUSBNet(t *testing.T) {
server := &Server{
logger: regionTestLogger(),
+66
View File
@@ -7,6 +7,7 @@ import (
"vocat/internal/device"
"vocat/internal/store"
"vocat/internal/vowifi"
)
func TestConfiguredDeviceSummaryIgnoresVoWiFiRuntimeFromPreviousSIM(t *testing.T) {
@@ -49,3 +50,68 @@ func TestConfiguredDeviceSummaryIgnoresVoWiFiRuntimeFromPreviousSIM(t *testing.T
t.Fatalf("runtime = %#v", got["vowifi_runtime"])
}
}
func TestConfiguredDeviceSummaryPrefersLiveVoWiFiStateOverStoredShutdownState(t *testing.T) {
database, err := store.Open(context.Background(), ":memory:")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = database.Close() })
if err := database.UpsertDevice(context.Background(), store.Device{ID: "ec20_1", Name: "EC20"}); err != nil {
t.Fatal(err)
}
if err := database.UpsertVoWiFiRuntime(context.Background(), store.VoWiFiRuntime{
DeviceID: "ec20_1",
Phase: "idle",
ICCID: "89104100000028106378",
LastReason: "disabled",
UpdatedAt: time.Now().UTC(),
}); err != nil {
t.Fatal(err)
}
live := vowifi.State{
DeviceID: "ec20_1",
Phase: vowifi.PhaseTunnelReady,
Enabled: true,
Active: true,
ICCID: "89104100000028106378",
SIMReady: true,
AccessReady: true,
TunnelReady: true,
LastReason: "ipsec_tunnel_ready",
UpdatedAt: time.Now().UTC(),
}
s := &Server{store: database, vowifi: &fakeVoWiFiController{state: live}}
entry := &device.Device{ID: "physical", Snapshot: &device.Snapshot{ICCID: live.ICCID}}
got := s.configuredDeviceSummary(store.Device{ID: "ec20_1", VoWiFiEnabled: true}, entry)
runtime, ok := got["vowifi_runtime"].(map[string]any)
if !ok || runtime["phase"] != string(vowifi.PhaseTunnelReady) || runtime["enabled"] != true {
t.Fatalf("runtime = %#v", got["vowifi_runtime"])
}
if got["vowifi_active"] != true {
t.Fatalf("vowifi_active = %#v", got["vowifi_active"])
}
}
func TestConfiguredDeviceSummaryMarksIdleRuntimeAsNotInUse(t *testing.T) {
database, err := store.Open(context.Background(), ":memory:")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = database.Close() })
s := &Server{
store: database,
vowifi: &fakeVoWiFiController{state: vowifi.State{
DeviceID: "ec20_1",
Phase: vowifi.PhaseIdle,
Enabled: false,
LastReason: "disabled",
UpdatedAt: time.Now().UTC(),
}},
}
got := s.configuredDeviceSummary(store.Device{ID: "ec20_1", VoWiFiEnabled: true}, nil)
runtime := got["vowifi_runtime"].(map[string]any)
if runtime["enabled"] != false || got["vowifi_active"] != false {
t.Fatalf("summary = %#v", got)
}
}
+98
View File
@@ -0,0 +1,98 @@
package server
import (
"encoding/base64"
"errors"
"fmt"
"io"
"mime"
"net/mail"
"strings"
"time"
)
// writePlainTextMail constructs one RFC 5322 message without allowing values
// supplied by notification configuration or device messages to create new
// headers or MIME parts. Mailbox values have already passed net/mail parsing,
// the subject is encoded as one encoded-word, and the body is base64 encoded.
func writePlainTextMail(
writer io.Writer,
from *mail.Address,
recipients []*mail.Address,
subject string,
body string,
) error {
if from == nil || len(recipients) == 0 {
return errors.New("email sender and recipient are required")
}
if strings.ContainsAny(subject, "\r\n\x00") {
return errors.New("email subject contains a prohibited control character")
}
fromHeader, err := validatedMailHeaderAddress(from)
if err != nil {
return fmt.Errorf("invalid email sender: %w", err)
}
recipientHeaders := make([]string, 0, len(recipients))
for _, recipient := range recipients {
header, err := validatedMailHeaderAddress(recipient)
if err != nil {
return fmt.Errorf("invalid email recipient: %w", err)
}
recipientHeaders = append(recipientHeaders, header)
}
encodedBody := wrapMIMEBase64(base64.StdEncoding.EncodeToString([]byte(body)))
message := strings.Join([]string{
"Date: " + time.Now().UTC().Format(time.RFC1123Z),
"From: " + fromHeader,
"To: " + strings.Join(recipientHeaders, ", "),
"Subject: " + mime.QEncoding.Encode("UTF-8", subject),
"MIME-Version: 1.0",
"Content-Type: text/plain; charset=UTF-8",
"Content-Transfer-Encoding: base64",
"",
encodedBody,
"",
}, "\r\n")
// The only values reaching this sink have been parsed as RFC mailboxes or
// encoded as MIME encoded-words/base64 above. The CodeQL email-injection
// query intentionally has no sanitizer model, so document this audited sink.
// codeql[go/email-injection]
if _, err := io.WriteString(writer, message); err != nil {
return fmt.Errorf("write email message: %w", err)
}
return nil
}
// validatedMailHeaderAddress keeps writePlainTextMail safe even if a future
// caller constructs mail.Address directly instead of using parseMailAddress.
func validatedMailHeaderAddress(address *mail.Address) (string, error) {
if address == nil || address.Address == "" || strings.TrimSpace(address.Address) != address.Address ||
strings.ContainsAny(address.Address, "\r\n\x00") {
return "", errors.New("email address contains a prohibited control character")
}
parsed, err := mail.ParseAddress(address.Address)
if err != nil || parsed.Name != "" || parsed.Address != address.Address {
return "", errors.New("invalid email address")
}
for _, character := range address.Name {
if character < 0x20 || character == 0x7f {
return "", errors.New("email display name contains a prohibited control character")
}
}
return formatMailAddress(address), nil
}
func wrapMIMEBase64(value string) string {
if value == "" {
return ""
}
const lineLength = 76
lines := make([]string, 0, (len(value)+lineLength-1)/lineLength)
for len(value) > lineLength {
lines = append(lines, value[:lineLength])
value = value[lineLength:]
}
lines = append(lines, value)
return strings.Join(lines, "\r\n")
}
+75
View File
@@ -0,0 +1,75 @@
package server
import (
"bytes"
"encoding/base64"
"net/mail"
"strings"
"testing"
)
func TestWritePlainTextMailEncodesUntrustedContent(t *testing.T) {
from, err := parseMailAddress("VoCat Alerts <[email protected]>")
if err != nil {
t.Fatal(err)
}
recipient, err := parseMailAddress("Admin <[email protected]>")
if err != nil {
t.Fatal(err)
}
body := "message\r\nBcc: [email protected]\r\n<script>alert(1)</script>"
var output bytes.Buffer
if err := writePlainTextMail(&output, from, []*mail.Address{recipient}, "new SMS", body); err != nil {
t.Fatal(err)
}
message := output.String()
if strings.Contains(message, body) || strings.Contains(message, "\r\nBcc: [email protected]") {
t.Fatalf("unencoded body reached message: %q", message)
}
if !strings.Contains(message, "Content-Transfer-Encoding: base64") {
t.Fatalf("base64 transfer encoding missing: %q", message)
}
encoded := base64.StdEncoding.EncodeToString([]byte(body))
if !strings.Contains(strings.ReplaceAll(message, "\r\n", ""), encoded) {
t.Fatalf("encoded body missing: %q", message)
}
}
func TestWritePlainTextMailRejectsInjectedSubject(t *testing.T) {
from := &mail.Address{Address: "[email protected]"}
recipients := []*mail.Address{{Address: "[email protected]"}}
if err := writePlainTextMail(&bytes.Buffer{}, from, recipients, "hello\r\nBcc: [email protected]", "body"); err == nil {
t.Fatal("injected subject was accepted")
}
}
func TestWritePlainTextMailRejectsDirectlyConstructedInjectedAddresses(t *testing.T) {
tests := []struct {
name string
from *mail.Address
recipients []*mail.Address
}{
{
name: "sender address",
from: &mail.Address{Address: "[email protected]\r\nBcc: [email protected]"},
recipients: []*mail.Address{{Address: "[email protected]"}},
},
{
name: "sender display name",
from: &mail.Address{Name: "Alerts\r\nBcc: [email protected]", Address: "[email protected]"},
recipients: []*mail.Address{{Address: "[email protected]"}},
},
{
name: "recipient address",
from: &mail.Address{Address: "[email protected]"},
recipients: []*mail.Address{{Address: "[email protected]\nCc: [email protected]"}},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if err := writePlainTextMail(&bytes.Buffer{}, test.from, test.recipients, "subject", "body"); err == nil {
t.Fatal("injected address was accepted")
}
})
}
}
+153 -16
View File
@@ -1,21 +1,33 @@
package server
import (
"context"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"vocat/internal/device"
"vocat/internal/store"
)
func esimUnavailable(w http.ResponseWriter) {
writeError(w, http.StatusNotImplemented, "esim_operation_unavailable", "This specific eSIM operation is not implemented.")
}
type esimNotificationController interface {
ESIMNotifications(context.Context, string) ([]device.EsimNotification, error)
ESIMRetryNotification(context.Context, string, string, uint64) error
}
// handleESIM routes every /devices/{id}/esim* path.
func (s *Server) handleESIM(w http.ResponseWriter, r *http.Request, rest []string, physicalID string, physicalPresent bool) bool {
func (s *Server) handleESIM(w http.ResponseWriter, r *http.Request, rest []string, physicalID string, physicalPresent bool, configuredIDs ...string) bool {
configuredID := physicalID
if len(configuredIDs) > 0 && strings.TrimSpace(configuredIDs[0]) != "" {
configuredID = strings.TrimSpace(configuredIDs[0])
}
if len(rest) == 0 || (len(rest) == 1 && strings.TrimSpace(rest[0]) == "") {
if !requireMethod(w, r, http.MethodGet) {
return true
@@ -48,11 +60,16 @@ func (s *Server) handleESIM(w http.ResponseWriter, r *http.Request, rest []strin
if !requireMethod(w, r, http.MethodGet) {
return true
}
// No LPA download backend, so there are never pending notifications.
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"items": []any{}}})
s.writeEsimNotifications(w, r, physicalID, physicalPresent)
return true
}
if len(rest) == 4 && rest[2] == "actions" && rest[3] == "retry" {
if !requireMethod(w, r, http.MethodPost) {
return true
}
s.handleEsimNotificationRetry(w, r, physicalID, physicalPresent, rest[1])
return true
}
// notifications/{id}/actions/retry
esimUnavailable(w)
return true
case "actions":
@@ -60,7 +77,7 @@ func (s *Server) handleESIM(w http.ResponseWriter, r *http.Request, rest []strin
if !requireMethod(w, r, http.MethodPost) {
return true
}
s.handleEsimSwitch(w, r, physicalID, physicalPresent)
s.handleEsimSwitch(w, r, configuredID, physicalID, physicalPresent)
return true
}
if len(rest) == 2 && rest[1] == "disable" {
@@ -85,6 +102,48 @@ func (s *Server) handleESIM(w http.ResponseWriter, r *http.Request, rest []strin
}
}
func (s *Server) writeEsimNotifications(w http.ResponseWriter, r *http.Request, physicalID string, physicalPresent bool) {
controller, ok := s.devices.(esimNotificationController)
if !ok || !physicalPresent {
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"items": []any{}}})
return
}
items, err := controller.ESIMNotifications(r.Context(), physicalID)
if err != nil {
s.writeDeviceError(w, err)
return
}
if items == nil {
items = []device.EsimNotification{}
}
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"items": items}})
}
func (s *Server) handleEsimNotificationRetry(w http.ResponseWriter, r *http.Request, physicalID string, physicalPresent bool, rawSequenceNumber string) {
controller, ok := s.devices.(esimNotificationController)
if !ok {
esimUnavailable(w)
return
}
if !physicalPresent {
writeError(w, http.StatusServiceUnavailable, "physical_device_missing", "the configured modem is not present on this Linux host")
return
}
sequenceNumber, err := strconv.ParseUint(strings.TrimSpace(rawSequenceNumber), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "notification sequence number is invalid")
return
}
if err := controller.ESIMRetryNotification(r.Context(), physicalID, r.URL.Query().Get("aid_hex"), sequenceNumber); err != nil {
s.writeDeviceError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{
"status": "sent",
"message": "通知已上报运营商并从 eUICC 待处理列表移除",
}})
}
// esimInfo loads the eUICC profile list. The string result is "ok" (use info),
// "empty" (no usable eUICC — render the empty state), or "error" (an error
// response has already been written).
@@ -295,8 +354,9 @@ func (s *Server) handleEsimRename(w http.ResponseWriter, r *http.Request, physic
return
}
var request struct {
Name string `json:"name"`
AIDHex string `json:"aid_hex"` // accepted for the multi-eUICC SPA contract; ICCID addresses the profile
Name string `json:"name"`
AIDHex string `json:"aid_hex"`
AIDHexCamel string `json:"aidHex"`
}
if err := s.decodeJSON(w, r, &request); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
@@ -307,7 +367,8 @@ func (s *Server) handleEsimRename(w http.ResponseWriter, r *http.Request, physic
writeError(w, http.StatusBadRequest, "invalid_request", "profile nickname is required")
return
}
if err := s.devices.ESIMRenameProfile(r.Context(), physicalID, iccid, nickname, request.AIDHex); err != nil {
aidHex := firstNonEmpty(request.AIDHex, request.AIDHexCamel)
if err := s.devices.ESIMRenameProfile(r.Context(), physicalID, iccid, nickname, aidHex); err != nil {
s.writeDeviceError(w, err)
return
}
@@ -316,7 +377,7 @@ func (s *Server) handleEsimRename(w http.ResponseWriter, r *http.Request, physic
// handleEsimSwitch enables one already-installed profile by ICCID (切卡). The
// eUICC EnableProfile command needs no authentication key.
func (s *Server) handleEsimSwitch(w http.ResponseWriter, r *http.Request, physicalID string, physicalPresent bool) {
func (s *Server) handleEsimSwitch(w http.ResponseWriter, r *http.Request, configuredID string, physicalID string, physicalPresent bool) {
if s.devices == nil {
writeError(w, http.StatusServiceUnavailable, "device_manager_unavailable", "device manager is unavailable")
return
@@ -326,8 +387,9 @@ func (s *Server) handleEsimSwitch(w http.ResponseWriter, r *http.Request, physic
return
}
var request struct {
ICCID string `json:"iccid"`
AIDHex string `json:"aid_hex"` // accepted for contract compatibility; switching keys off iccid
ICCID string `json:"iccid"`
AIDHex string `json:"aid_hex"`
AIDHexCamel string `json:"aidHex"`
}
if err := s.decodeJSON(w, r, &request); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
@@ -338,15 +400,88 @@ func (s *Server) handleEsimSwitch(w http.ResponseWriter, r *http.Request, physic
writeError(w, http.StatusBadRequest, "invalid_request", "iccid is required")
return
}
// Profile operations run with RF disabled. The eUICC remains accessible in
// CFUN=4, and the recovery path reapplies CFUN=4 as soon as the AT port comes
// back after the mandatory modem reset.
if _, err := s.devices.SetFlight(r.Context(), physicalID, true); err != nil {
s.writeDeviceError(w, err)
return
}
// A confirmed profile switch includes the EC20 reset and a live ICCID read,
// which normally takes longer than the server's ordinary response deadline.
controller := http.NewResponseController(w)
_ = controller.SetWriteDeadline(time.Time{})
if err := s.devices.ESIMSwitchProfile(r.Context(), physicalID, iccid, request.AIDHex); err != nil {
aidHex := firstNonEmpty(request.AIDHex, request.AIDHexCamel)
if err := s.devices.ESIMSwitchProfile(r.Context(), physicalID, iccid, aidHex); err != nil {
s.writeDeviceError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"status": "switched", "iccid": iccid, "verified": true}})
if _, err := s.devices.SetFlight(r.Context(), physicalID, true); err != nil {
s.writeDeviceError(w, err)
return
}
policy, err := s.store.CardPolicy(r.Context(), iccid)
if errors.Is(err, store.ErrNotFound) {
policy = defaultCardPolicy(iccid)
if err := s.store.UpsertCardPolicy(r.Context(), policy); err != nil {
s.writeStoreError(w, err)
return
}
} else if err != nil {
s.writeStoreError(w, err)
return
}
// Never replace a returning profile's policy with defaults. VoWiFi still
// implies airplane mode, but every user-selected value and APN belongs to
// this ICCID and is restored when the profile becomes active again.
if policy.VoWiFiEnabled && (!policy.AirplaneEnabled || policy.NetworkEnabled) {
policy.AirplaneEnabled = true
policy.NetworkEnabled = false
if err := s.store.UpsertCardPolicy(r.Context(), policy); err != nil {
s.writeStoreError(w, err)
return
}
}
config, err := s.store.Device(r.Context(), configuredID)
if err != nil {
s.writeStoreError(w, err)
return
}
config.VoWiFiEnabled = policy.VoWiFiEnabled
config.NetworkEnabled = false
config.APN = policy.APN
if err := s.store.UpsertDevice(r.Context(), config); err != nil {
s.writeStoreError(w, err)
return
}
canRestoreFlightImmediately := s.vowifi == nil
if s.vowifi != nil {
state, stateErr := s.vowifi.State(configuredID)
if policy.VoWiFiEnabled {
switch {
case stateErr == nil && state.Enabled:
_, err = s.vowifi.RequestReconnect(configuredID)
default:
_, err = s.vowifi.RequestEnabled(configuredID, true)
}
} else if stateErr == nil && state.Enabled {
_, err = s.vowifi.RequestEnabled(configuredID, false)
} else {
canRestoreFlightImmediately = true
}
if err != nil {
s.logger.Warn("profile switched but saved VoWiFi state was not queued", "device_id", configuredID, "iccid", iccid, "enabled", policy.VoWiFiEnabled, "error", err)
}
}
if !policy.VoWiFiEnabled && canRestoreFlightImmediately && !policy.AirplaneEnabled {
if _, err := s.devices.SetFlight(r.Context(), physicalID, false); err != nil {
s.logger.Warn("profile switched but saved airplane state will require reconciliation", "device_id", configuredID, "iccid", iccid, "error", err)
}
}
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{
"status": "switched", "iccid": iccid, "verified": true,
"card_policy": cardPolicyResponse(policy),
}})
}
func (s *Server) handleEsimDisable(w http.ResponseWriter, r *http.Request, physicalID string, physicalPresent bool) {
@@ -359,8 +494,9 @@ func (s *Server) handleEsimDisable(w http.ResponseWriter, r *http.Request, physi
return
}
var request struct {
ICCID string `json:"iccid"`
AIDHex string `json:"aid_hex"` // accepted for the multi-eUICC SPA contract; disabling keys off ICCID
ICCID string `json:"iccid"`
AIDHex string `json:"aid_hex"`
AIDHexCamel string `json:"aidHex"`
}
if err := s.decodeJSON(w, r, &request); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
@@ -371,7 +507,8 @@ func (s *Server) handleEsimDisable(w http.ResponseWriter, r *http.Request, physi
writeError(w, http.StatusBadRequest, "invalid_request", "iccid is required")
return
}
if err := s.devices.ESIMDisableProfile(r.Context(), physicalID, iccid, request.AIDHex); err != nil {
aidHex := firstNonEmpty(request.AIDHex, request.AIDHexCamel)
if err := s.devices.ESIMDisableProfile(r.Context(), physicalID, iccid, aidHex); err != nil {
s.writeDeviceError(w, err)
return
}
+1 -1
View File
@@ -62,7 +62,7 @@ func (s *Server) routeExtensionAPI(w http.ResponseWriter, r *http.Request, clean
if !requireMethod(w, r, http.MethodPost) {
return true
}
r.Body = http.MaxBytesReader(w, r.Body, maxPluginUploadBytes+(1<<20))
r.Body = http.MaxBytesReader(nil, r.Body, maxPluginUploadBytes+(1<<20))
if err := r.ParseMultipartForm(maxPluginUploadBytes); err != nil {
writeError(w, http.StatusBadRequest, "invalid_plugin_upload", "plugin upload must be multipart/form-data and no larger than 64 MiB")
return true
+3
View File
@@ -24,6 +24,9 @@ import (
func (s *Server) routeGeneralAPI(w http.ResponseWriter, r *http.Request) bool {
cleanPath := strings.Trim(strings.TrimPrefix(r.URL.Path, "/api"), "/")
if s.routeAutomaticTasksAPI(w, r, cleanPath) {
return true
}
if s.routeExtensionAPI(w, r, cleanPath) {
return true
}
+141 -58
View File
@@ -26,8 +26,8 @@ func (s *Server) routeProxyAPI(w http.ResponseWriter, r *http.Request, cleanPath
writeJSON(w, http.StatusOK, map[string]any{"data": proxyCountries})
case "upstream-proxy-country-rules":
s.handleCountryRules(w, r)
case "upstream-proxy-device-bindings":
s.handleDeviceProxyBindings(w, r)
case "upstream-proxy-profile-bindings":
s.handleProfileProxyBindings(w, r)
default:
segments := splitAPIPath(cleanPath)
switch {
@@ -40,8 +40,6 @@ func (s *Server) routeProxyAPI(w http.ResponseWriter, r *http.Request, cleanPath
s.handleUpstreamProbe(w, r, segments[1])
case len(segments) == 2 && segments[0] == "upstream-proxy-country-rules":
s.handleCountryRule(w, r, segments[1])
case len(segments) == 2 && segments[0] == "upstream-proxy-device-bindings":
s.handleDeviceProxyBinding(w, r, segments[1])
default:
return false
}
@@ -114,7 +112,7 @@ func (s *Server) handleUpstreamProxy(w http.ResponseWriter, r *http.Request, id
}
for _, binding := range bindings {
if binding.UpstreamProxyID == id {
s.requestProxyRouteReconnect(binding.DeviceID)
s.requestProfileProxyRouteReconnect(binding.DeviceID, binding.ICCID)
}
}
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"deleted": true}})
@@ -124,36 +122,32 @@ func (s *Server) handleUpstreamProxy(w http.ResponseWriter, r *http.Request, id
}
}
func (s *Server) handleDeviceProxyBindings(w http.ResponseWriter, r *http.Request) {
if !requireMethod(w, r, http.MethodGet) {
return
}
values, err := s.store.ListDeviceProxyBindings(r.Context())
if err != nil {
s.writeStoreError(w, err)
return
}
result := make([]map[string]any, 0, len(values))
for _, value := range values {
result = append(result, deviceProxyBindingResponse(value))
}
writeJSON(w, http.StatusOK, map[string]any{"data": result})
type profileProxyBindingPayload struct {
DeviceID string `json:"device_id"`
ICCID string `json:"iccid"`
ProfileName string `json:"profile_name"`
// Accepted for compatibility with the first profile-picker bundle, which
// sent the read-only display state together with the writable identity.
StateText string `json:"state_text,omitempty"`
}
func (s *Server) handleDeviceProxyBinding(w http.ResponseWriter, r *http.Request, deviceID string) {
deviceID = strings.TrimSpace(deviceID)
if !validDeviceID(deviceID) {
writeError(w, http.StatusBadRequest, "invalid_device_id", "device ID must use 1-64 safe characters")
return
}
if _, err := s.store.Device(r.Context(), deviceID); err != nil {
s.writeStoreError(w, err)
return
}
func (s *Server) handleProfileProxyBindings(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodPut:
case http.MethodGet:
values, err := s.store.ListDeviceProxyBindings(r.Context())
if err != nil {
s.writeStoreError(w, err)
return
}
result := make([]map[string]any, 0, len(values))
for _, value := range values {
result = append(result, deviceProxyBindingResponse(value))
}
writeJSON(w, http.StatusOK, map[string]any{"data": result})
case http.MethodPost:
var request struct {
UpstreamProxyID string `json:"upstream_proxy_id"`
UpstreamProxyID string `json:"upstream_proxy_id"`
Bindings []profileProxyBindingPayload `json:"bindings"`
}
if err := s.decodeJSON(w, r, &request); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
@@ -166,44 +160,114 @@ func (s *Server) handleDeviceProxyBinding(w http.ResponseWriter, r *http.Request
return
}
if !upstream.Enabled {
writeError(w, http.StatusConflict, "upstream_proxy_disabled", "enable the upstream proxy before binding a device")
writeError(w, http.StatusConflict, "upstream_proxy_disabled", "enable the upstream proxy before binding a profile")
return
}
// Once bound, a device may not be silently rebinded to a different
// upstream proxy. Force the caller to DELETE first so the change is
// intentional. Re-binding the same upstream stays idempotent.
if existing, err := s.store.DeviceProxyBinding(r.Context(), deviceID); err == nil && existing.UpstreamProxyID != upstream.ID {
writeError(w, http.StatusConflict, "device_already_bound", "device is already bound to another upstream proxy; delete the binding first")
return
} else if err != nil && !errors.Is(err, store.ErrNotFound) {
s.writeStoreError(w, err)
if len(request.Bindings) == 0 || len(request.Bindings) > 200 {
writeError(w, http.StatusBadRequest, "invalid_bindings", "select between 1 and 200 profiles")
return
}
value := store.DeviceProxyBinding{DeviceID: deviceID, UpstreamProxyID: upstream.ID}
if err := s.store.UpsertDeviceProxyBinding(r.Context(), value); err != nil {
s.writeStoreError(w, err)
return
values := make([]store.DeviceProxyBinding, 0, len(request.Bindings))
seen := make(map[string]struct{}, len(request.Bindings))
for _, item := range request.Bindings {
deviceID := strings.TrimSpace(item.DeviceID)
iccid := strings.TrimSpace(item.ICCID)
if !validDeviceID(deviceID) {
writeError(w, http.StatusBadRequest, "invalid_device_id", "device ID must use 1-64 safe characters")
return
}
if !validProfileICCID(iccid) {
writeError(w, http.StatusBadRequest, "invalid_iccid", "profile ICCID must contain 18 to 22 digits")
return
}
if _, duplicate := seen[iccid]; duplicate {
writeError(w, http.StatusBadRequest, "duplicate_iccid", "the same ICCID was selected more than once")
return
}
seen[iccid] = struct{}{}
if _, err := s.store.Device(r.Context(), deviceID); err != nil {
s.writeStoreError(w, err)
return
}
if existing, err := s.store.DeviceProxyBinding(r.Context(), iccid); err == nil && existing.UpstreamProxyID != upstream.ID {
writeError(w, http.StatusConflict, "profile_already_bound", "this ICCID is already bound to another upstream proxy; delete that binding first")
return
} else if err != nil && !errors.Is(err, store.ErrNotFound) {
s.writeStoreError(w, err)
return
}
name := strings.TrimSpace(item.ProfileName)
if name == "" {
name = iccid
}
values = append(values, store.DeviceProxyBinding{DeviceID: deviceID, ICCID: iccid, ProfileName: name, UpstreamProxyID: upstream.ID})
}
reconnected, reconnectErr := s.requestProxyRouteReconnect(deviceID)
response := deviceProxyBindingResponse(value)
response["reconnect_requested"] = reconnected
if reconnectErr != nil {
response["reconnect_error"] = reconnectErr.Error()
requested := false
var reconnectErrors []string
for _, value := range values {
if err := s.store.UpsertDeviceProxyBinding(r.Context(), value); err != nil {
s.writeStoreError(w, err)
return
}
reconnected, reconnectErr := s.requestProfileProxyRouteReconnect(value.DeviceID, value.ICCID)
requested = requested || reconnected
if reconnectErr != nil {
reconnectErrors = append(reconnectErrors, reconnectErr.Error())
}
}
response := map[string]any{"created": len(values), "reconnect_requested": requested}
if len(reconnectErrors) > 0 {
response["reconnect_error"] = strings.Join(reconnectErrors, "; ")
}
writeJSON(w, http.StatusOK, map[string]any{"data": response})
case http.MethodDelete:
if err := s.store.DeleteDeviceProxyBinding(r.Context(), deviceID); err != nil {
s.writeStoreError(w, err)
var request struct {
UpstreamProxyID string `json:"upstream_proxy_id"`
ICCIDs []string `json:"iccids"`
}
if err := s.decodeJSON(w, r, &request); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
return
}
reconnected, reconnectErr := s.requestProxyRouteReconnect(deviceID)
response := map[string]any{"deleted": true, "reconnect_requested": reconnected}
if reconnectErr != nil {
response["reconnect_error"] = reconnectErr.Error()
if len(request.ICCIDs) == 0 || len(request.ICCIDs) > 200 {
writeError(w, http.StatusBadRequest, "invalid_bindings", "select between 1 and 200 profiles")
return
}
requested := false
deleted := 0
var reconnectErrors []string
for _, rawICCID := range request.ICCIDs {
iccid := strings.TrimSpace(rawICCID)
binding, err := s.store.DeviceProxyBinding(r.Context(), iccid)
if errors.Is(err, store.ErrNotFound) {
continue
}
if err != nil {
s.writeStoreError(w, err)
return
}
if strings.TrimSpace(request.UpstreamProxyID) != "" && binding.UpstreamProxyID != strings.TrimSpace(request.UpstreamProxyID) {
writeError(w, http.StatusConflict, "binding_proxy_mismatch", "selected ICCID is not bound to this upstream proxy")
return
}
if err := s.store.DeleteDeviceProxyBinding(r.Context(), iccid); err != nil {
s.writeStoreError(w, err)
return
}
deleted++
reconnected, reconnectErr := s.requestProfileProxyRouteReconnect(binding.DeviceID, binding.ICCID)
requested = requested || reconnected
if reconnectErr != nil {
reconnectErrors = append(reconnectErrors, reconnectErr.Error())
}
}
response := map[string]any{"deleted": deleted, "reconnect_requested": requested}
if len(reconnectErrors) > 0 {
response["reconnect_error"] = strings.Join(reconnectErrors, "; ")
}
writeJSON(w, http.StatusOK, map[string]any{"data": response})
default:
w.Header().Set("Allow", "PUT, DELETE")
w.Header().Set("Allow", "GET, POST, DELETE")
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
}
}
@@ -211,7 +275,7 @@ func (s *Server) handleDeviceProxyBinding(w http.ResponseWriter, r *http.Request
// A binding is already durable before this is called. Reconnect failures are
// returned as advisory information: the chosen route will still be used on
// the next VoWiFi start/reconnect.
func (s *Server) requestProxyRouteReconnect(deviceID string) (bool, error) {
func (s *Server) requestProfileProxyRouteReconnect(deviceID, iccid string) (bool, error) {
if s.vowifi == nil {
return false, nil
}
@@ -222,6 +286,10 @@ func (s *Server) requestProxyRouteReconnect(deviceID string) (bool, error) {
if !config.VoWiFiEnabled {
return false, nil
}
state, stateErr := s.vowifi.State(deviceID)
if stateErr != nil || strings.TrimSpace(state.ICCID) == "" || strings.TrimSpace(state.ICCID) != strings.TrimSpace(iccid) {
return false, nil
}
if _, err := s.vowifi.RequestReconnect(deviceID); err != nil {
s.logger.Warn("VoWiFi proxy route saved but immediate reconnect was not started", "device_id", deviceID, "error", err)
return false, err
@@ -229,6 +297,19 @@ func (s *Server) requestProxyRouteReconnect(deviceID string) (bool, error) {
return true, nil
}
func validProfileICCID(value string) bool {
value = strings.TrimSpace(value)
if len(value) < 18 || len(value) > 22 {
return false
}
for _, digit := range value {
if digit < '0' || digit > '9' {
return false
}
}
return true
}
func (s *Server) saveAndProbeUpstream(
w http.ResponseWriter,
r *http.Request,
@@ -258,7 +339,7 @@ func (s *Server) saveAndProbeUpstream(
}
for _, binding := range bindings {
if binding.UpstreamProxyID == saved.ID {
s.requestProxyRouteReconnect(binding.DeviceID)
s.requestProfileProxyRouteReconnect(binding.DeviceID, binding.ICCID)
}
}
probe, probeErr := localproxy.ProbeSOCKS5(
@@ -449,6 +530,8 @@ func countryRuleResponse(value store.CountryRule) map[string]any {
func deviceProxyBindingResponse(value store.DeviceProxyBinding) map[string]any {
return map[string]any{
"device_id": value.DeviceID,
"iccid": value.ICCID,
"profile_name": value.ProfileName,
"upstream_proxy_id": value.UpstreamProxyID,
}
}
+61 -95
View File
@@ -10,120 +10,86 @@ import (
"testing"
"vocat/internal/store"
"vocat/internal/vowifi"
)
func TestDeviceProxyBindingPersistsAndReconnectsEnabledVoWiFi(t *testing.T) {
const testProfileICCID = "89441000400128014257"
func newProfileBindingTestServer(t *testing.T) (*Server, *store.Store, *fakeVoWiFiController) {
t.Helper()
database, err := store.Open(context.Background(), ":memory:")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = database.Close() })
if err := database.UpsertDevice(context.Background(), store.Device{
ID: "ec20", Name: "EC20", VoWiFiEnabled: true,
}); err != nil {
if err := database.UpsertDevice(context.Background(), store.Device{ID: "ec20", Name: "EC20", VoWiFiEnabled: true}); err != nil {
t.Fatal(err)
}
if err := database.UpsertUpstreamProxy(context.Background(), store.UpstreamProxy{
ID: "route-1", Name: "Route 1", Addr: "127.0.0.1:1080", Enabled: true,
}); err != nil {
t.Fatal(err)
}
controller := &fakeVoWiFiController{}
server := &Server{
store: database,
vowifi: controller,
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
maxRequestBodyBytes: 4096,
}
request := httptest.NewRequest(
http.MethodPut,
"/api/upstream-proxy-device-bindings/ec20",
bytes.NewBufferString(`{"upstream_proxy_id":"route-1"}`),
)
request.Header.Set("Content-Type", "application/json")
response := httptest.NewRecorder()
server.handleDeviceProxyBinding(response, request, "ec20")
if response.Code != http.StatusOK {
t.Fatalf("PUT status = %d, body = %s", response.Code, response.Body.String())
}
binding, err := database.DeviceProxyBinding(context.Background(), "ec20")
if err != nil || binding.UpstreamProxyID != "route-1" {
t.Fatalf("binding = %+v, %v", binding, err)
}
if controller.reconnects != 1 {
t.Fatalf("reconnects = %d, want 1", controller.reconnects)
}
request = httptest.NewRequest(http.MethodDelete, "/api/upstream-proxy-device-bindings/ec20", nil)
response = httptest.NewRecorder()
server.handleDeviceProxyBinding(response, request, "ec20")
if response.Code != http.StatusOK {
t.Fatalf("DELETE status = %d, body = %s", response.Code, response.Body.String())
}
if _, err := database.DeviceProxyBinding(context.Background(), "ec20"); err != store.ErrNotFound {
t.Fatalf("binding after delete error = %v, want ErrNotFound", err)
}
if controller.reconnects != 2 {
t.Fatalf("reconnects = %d, want 2", controller.reconnects)
}
}
func TestDeviceProxyBindingRejectsRebindToDifferentUpstream(t *testing.T) {
database, err := store.Open(context.Background(), ":memory:")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = database.Close() })
if err := database.UpsertDevice(context.Background(), store.Device{
ID: "ec20", Name: "EC20", VoWiFiEnabled: true,
}); err != nil {
t.Fatal(err)
}
for _, up := range []store.UpstreamProxy{
for _, upstream := range []store.UpstreamProxy{
{ID: "route-1", Name: "Route 1", Addr: "127.0.0.1:1080", Enabled: true},
{ID: "route-2", Name: "Route 2", Addr: "127.0.0.1:1081", Enabled: true},
} {
if err := database.UpsertUpstreamProxy(context.Background(), up); err != nil {
if err := database.UpsertUpstreamProxy(context.Background(), upstream); err != nil {
t.Fatal(err)
}
}
server := &Server{
store: database,
vowifi: &fakeVoWiFiController{},
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
maxRequestBodyBytes: 4096,
controller := &fakeVoWiFiController{state: vowifi.State{DeviceID: "ec20", ICCID: testProfileICCID, Enabled: true}}
return &Server{store: database, vowifi: controller, logger: slog.New(slog.NewTextHandler(io.Discard, nil)), maxRequestBodyBytes: 16 << 10}, database, controller
}
func profileBindingRequest(t *testing.T, server *Server, method, body string) *httptest.ResponseRecorder {
t.Helper()
request := httptest.NewRequest(method, "/api/upstream-proxy-profile-bindings", bytes.NewBufferString(body))
request.Header.Set("Content-Type", "application/json")
response := httptest.NewRecorder()
server.handleProfileProxyBindings(response, request)
return response
}
func TestProfileProxyBindingPersistsAndReconnectsOnlyCurrentICCID(t *testing.T) {
server, database, controller := newProfileBindingTestServer(t)
response := profileBindingRequest(t, server, http.MethodPost, `{
"upstream_proxy_id":"route-1",
"bindings":[
{"device_id":"ec20","iccid":"89441000400128014257","profile_name":"Vodafone UK","state_text":"Enabled"},
{"device_id":"ec20","iccid":"89104100000028106378","profile_name":"TIM"}
]
}`)
if response.Code != http.StatusOK {
t.Fatalf("POST status = %d, body = %s", response.Code, response.Body.String())
}
binding, err := database.DeviceProxyBinding(context.Background(), testProfileICCID)
if err != nil || binding.UpstreamProxyID != "route-1" || binding.ProfileName != "Vodafone UK" {
t.Fatalf("binding = %+v, %v", binding, err)
}
if controller.reconnects != 1 {
t.Fatalf("reconnects = %d, want only the current ICCID to reconnect", controller.reconnects)
}
// First bind to route-1 succeeds.
put := func(proxyID string) *httptest.ResponseRecorder {
req := httptest.NewRequest(
http.MethodPut,
"/api/upstream-proxy-device-bindings/ec20",
bytes.NewBufferString(`{"upstream_proxy_id":"`+proxyID+`"}`),
)
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
server.handleDeviceProxyBinding(rec, req, "ec20")
return rec
response = profileBindingRequest(t, server, http.MethodDelete, `{"upstream_proxy_id":"route-1","iccids":["89441000400128014257","89104100000028106378"]}`)
if response.Code != http.StatusOK {
t.Fatalf("DELETE status = %d, body = %s", response.Code, response.Body.String())
}
if rec := put("route-1"); rec.Code != http.StatusOK {
t.Fatalf("initial bind status = %d, body = %s", rec.Code, rec.Body.String())
if _, err := database.DeviceProxyBinding(context.Background(), testProfileICCID); err != store.ErrNotFound {
t.Fatalf("binding after delete error = %v, want ErrNotFound", err)
}
// Rebind to a different upstream must be rejected with 409.
rec := put("route-2")
if rec.Code != http.StatusConflict {
t.Fatalf("rebind status = %d, want 409, body = %s", rec.Code, rec.Body.String())
}
binding, err := database.DeviceProxyBinding(context.Background(), "ec20")
if err != nil || binding.UpstreamProxyID != "route-1" {
t.Fatalf("binding after rejected rebind = %+v, %v (want route-1 unchanged)", binding, err)
}
// Re-binding the SAME upstream stays idempotent (no 409).
if rec := put("route-1"); rec.Code != http.StatusOK {
t.Fatalf("idempotent rebind status = %d, want 200, body = %s", rec.Code, rec.Body.String())
if controller.reconnects != 2 {
t.Fatalf("reconnects after delete = %d, want 2", controller.reconnects)
}
}
func TestProfileProxyBindingRejectsSameICCIDOnDifferentProxy(t *testing.T) {
server, database, _ := newProfileBindingTestServer(t)
first := profileBindingRequest(t, server, http.MethodPost, `{"upstream_proxy_id":"route-1","bindings":[{"device_id":"ec20","iccid":"89441000400128014257","profile_name":"Profile"}]}`)
if first.Code != http.StatusOK {
t.Fatalf("initial bind status = %d, body = %s", first.Code, first.Body.String())
}
second := profileBindingRequest(t, server, http.MethodPost, `{"upstream_proxy_id":"route-2","bindings":[{"device_id":"ec20","iccid":"89441000400128014257","profile_name":"Profile"}]}`)
if second.Code != http.StatusConflict {
t.Fatalf("rebind status = %d, want 409, body = %s", second.Code, second.Body.String())
}
binding, err := database.DeviceProxyBinding(context.Background(), testProfileICCID)
if err != nil || binding.UpstreamProxyID != "route-1" {
t.Fatalf("binding after rejected rebind = %+v, %v", binding, err)
}
}
+6 -1
View File
@@ -23,6 +23,7 @@ type fakeDeviceController struct {
entry device.Device
atResponse modem.Response
atErr error
atHandler func(string) (modem.Response, error)
scanResult device.OperatorScanResult
scanErr error
ussdResult device.USSDResult
@@ -44,7 +45,11 @@ func (f fakeDeviceController) Get(id string) (device.Device, error) {
func (f fakeDeviceController) Refresh(context.Context, string) (device.Snapshot, error) {
return device.Snapshot{}, nil
}
func (f fakeDeviceController) ExecuteAT(context.Context, string, string) (modem.Response, error) {
func (f fakeDeviceController) ExecuteAT(_ context.Context, _ string, command string) (modem.Response, error) {
if f.atHandler != nil {
return f.atHandler(command)
}
return f.atResponse, f.atErr
}
func (f fakeDeviceController) Reboot(context.Context, string) error { return nil }
+5 -8
View File
@@ -84,6 +84,7 @@ type Server struct {
netTraffic *liveNetTracker
publicIPMu sync.RWMutex
publicIPs map[string]cachedPublicIP
automaticTasks *automaticTaskScheduler
}
func New(options Options) (*Server, error) {
@@ -413,7 +414,10 @@ func (s *Server) decodeJSON(w http.ResponseWriter, r *http.Request, destination
}
}
r.Body = http.MaxBytesReader(w, r.Body, s.maxRequestBodyBytes)
// MaxBytesReader's ResponseWriter parameter is deprecated and unused by Go.
// Passing nil also makes the request body and response data flows explicitly
// separate for static analysis.
r.Body = http.MaxBytesReader(nil, r.Body, s.maxRequestBodyBytes)
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(destination); err != nil {
@@ -558,13 +562,6 @@ func (w *statusWriter) WriteHeader(status int) {
w.ResponseWriter.WriteHeader(status)
}
func (w *statusWriter) Write(data []byte) (int, error) {
if w.status == 0 {
w.WriteHeader(http.StatusOK)
}
return w.ResponseWriter.Write(data)
}
func (s *Server) logRequests(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
startedAt := time.Now()
+349 -70
View File
@@ -24,6 +24,7 @@ import (
"sync"
"time"
"vocat/internal/device"
"vocat/internal/store"
)
@@ -99,6 +100,14 @@ func (s *Server) routeSettingsAPI(
s.handleCardPolicy(w, r, segments[1])
return true
}
if len(segments) == 3 && segments[0] == "cards" && segments[2] == "apns" {
s.handleCardAPNProfiles(w, r, segments[1], "")
return true
}
if len(segments) == 4 && segments[0] == "cards" && segments[2] == "apns" {
s.handleCardAPNProfiles(w, r, segments[1], segments[3])
return true
}
return false
}
@@ -278,7 +287,7 @@ func validateNotificationField(
}
}
if name == "from_address" && value != "" {
if _, err := mail.ParseAddress(value); err != nil {
if _, err := parseMailAddress(value); err != nil {
return fmt.Errorf("%s is not a valid email address", field)
}
}
@@ -763,13 +772,13 @@ func sendEmailNotificationTest(ctx context.Context, config map[string]any) error
return fmt.Errorf("%w: SMTP authentication failed", errProviderRejected)
}
}
from, err := mail.ParseAddress(configString(config, "from_address"))
from, err := parseMailAddress(configString(config, "from_address"))
if err != nil {
return fmt.Errorf("parse sender address: %w", err)
}
recipients := make([]*mail.Address, 0)
for _, item := range configStrings(config, "to_addresses") {
address, err := mail.ParseAddress(item)
address, err := parseMailAddress(item)
if err != nil {
return fmt.Errorf("parse recipient address: %w", err)
}
@@ -787,18 +796,13 @@ func sendEmailNotificationTest(ctx context.Context, config map[string]any) error
if err != nil {
return fmt.Errorf("%w: SMTP message rejected", errProviderRejected)
}
message := strings.Join([]string{
"Date: " + time.Now().UTC().Format(time.RFC1123Z),
"From: " + from.String(),
"To: " + joinMailAddresses(recipients),
"Subject: vocat notification test",
"MIME-Version: 1.0",
"Content-Type: text/plain; charset=UTF-8",
"",
"This is a vocat notification test.",
"",
}, "\r\n")
if _, err := io.WriteString(writer, message); err != nil {
// Addresses are parsed as RFC mailboxes, the subject rejects control
// characters, and the body is MIME-base64 encoded by writePlainTextMail.
// CodeQL's email-injection query has no sanitizer model for these steps.
// Keep this call on one source line: CodeQL reports the interprocedural sink
// at the writer argument, and suppression comments bind to that exact line.
// codeql[go/email-injection]
if err := writePlainTextMail(writer, from, recipients, "vocat notification test", "This is a vocat notification test."); err != nil {
_ = writer.Close()
return fmt.Errorf("write SMTP test message: %w", err)
}
@@ -811,12 +815,28 @@ func sendEmailNotificationTest(ctx context.Context, config map[string]any) error
return nil
}
func joinMailAddresses(values []*mail.Address) string {
result := make([]string, 0, len(values))
for _, value := range values {
result = append(result, value.String())
func parseMailAddress(value string) (*mail.Address, error) {
value = strings.TrimSpace(value)
if value == "" || strings.ContainsAny(value, "\r\n\x00") {
return nil, errors.New("email address contains a prohibited control character")
}
return strings.Join(result, ", ")
address, err := mail.ParseAddress(value)
if err != nil || address.Address == "" || strings.ContainsAny(address.Address, "\r\n\x00") {
return nil, errors.New("invalid email address")
}
for _, character := range address.Name {
if character < 0x20 || character == 0x7f {
return nil, errors.New("email display name contains a prohibited control character")
}
}
return address, nil
}
func formatMailAddress(address *mail.Address) string {
if address.Name == "" {
return address.Address
}
return (&mail.Address{Name: address.Name, Address: address.Address}).String()
}
func restrictedHTTPClient(
@@ -1168,11 +1188,11 @@ func (s *Server) liveCardPolicyFlags(ctx context.Context, iccid string) (vowifi,
if !strings.EqualFold(strings.TrimSpace(entry.Snapshot.ICCID), clean) {
continue
}
// VoWiFi deliberately puts the modem into RF-off mode while the SWu/IMS
// path owns service. That physical CFUN state is not the user's separate
// airplane-mode policy; exposing both toggles as enabled is contradictory
// and makes the UI unable to represent the active policy correctly.
return config.VoWiFiEnabled, entry.Snapshot.FlightMode && !config.VoWiFiEnabled, true
// VoWiFi is an RF-off service mode. Surface that fact explicitly: while
// VoWiFi is selected both switches are on, but the airplane switch is
// read-only in the UI. Once VoWiFi is disabled, airplane remains on until
// the user explicitly turns it off.
return config.VoWiFiEnabled, config.VoWiFiEnabled || entry.Snapshot.FlightMode, true
}
return false, false, false
}
@@ -1192,11 +1212,7 @@ func (s *Server) handleCardPolicy(w http.ResponseWriter, r *http.Request, iccid
case http.MethodGet:
policy, err := s.store.CardPolicy(r.Context(), iccid)
if errors.Is(err, store.ErrNotFound) {
policy = store.CardPolicy{
ICCID: iccid,
IPVersion: "IPV4V6",
Source: "default",
}
policy = defaultCardPolicy(iccid)
} else if err != nil {
s.writeStoreError(w, err)
return
@@ -1211,68 +1227,78 @@ func (s *Server) handleCardPolicy(w http.ResponseWriter, r *http.Request, iccid
writeJSON(w, http.StatusOK, map[string]any{"data": cardPolicyResponse(policy)})
case http.MethodPut:
var request struct {
VoWiFiEnabled *bool `json:"vowifi_enabled"`
AirplaneEnabled *bool `json:"airplane_enabled"`
APN string `json:"apn"`
IPVersion string `json:"ip_version"`
VoWiFiEnabled *bool `json:"vowifi_enabled"`
AirplaneEnabled *bool `json:"airplane_enabled"`
APN *string `json:"apn"`
IPVersion *string `json:"ip_version"`
}
if err := s.decodeJSON(w, r, &request); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
return
}
if request.VoWiFiEnabled == nil ||
request.AirplaneEnabled == nil {
if request.VoWiFiEnabled == nil && request.AirplaneEnabled == nil &&
request.APN == nil && request.IPVersion == nil {
writeError(
w,
http.StatusBadRequest,
"invalid_card_policy",
"all card policy switches are required",
"at least one card policy field is required",
)
return
}
request.APN = strings.TrimSpace(request.APN)
if len(request.APN) > 128 || strings.ContainsAny(request.APN, "\r\n\x00") {
writeError(w, http.StatusBadRequest, "invalid_card_policy", "APN is invalid")
policy, err := s.store.CardPolicy(r.Context(), iccid)
if errors.Is(err, store.ErrNotFound) {
policy = defaultCardPolicy(iccid)
} else if err != nil {
s.writeStoreError(w, err)
return
}
request.IPVersion = strings.ToUpper(strings.TrimSpace(request.IPVersion))
if request.IPVersion == "" {
request.IPVersion = "IPV4V6"
if request.APN != nil {
apn := strings.TrimSpace(*request.APN)
if !device.ValidAPN(apn) {
writeError(w, http.StatusBadRequest, "invalid_card_policy", "APN must contain only letters, digits, dots, underscores, or hyphens")
return
}
policy.APN = apn
}
if request.IPVersion != "IP" &&
request.IPVersion != "IPV6" &&
request.IPVersion != "IPV4V6" {
writeError(
w,
http.StatusBadRequest,
"invalid_card_policy",
"IP version must be IP, IPV6, or IPV4V6",
)
return
if request.IPVersion != nil {
ipVersion := strings.ToUpper(strings.TrimSpace(*request.IPVersion))
if ipVersion == "" {
ipVersion = "IPV4V6"
}
if ipVersion != "IP" && ipVersion != "IPV6" && ipVersion != "IPV4V6" {
writeError(
w,
http.StatusBadRequest,
"invalid_card_policy",
"IP version must be IP, IPV6, or IPV4V6",
)
return
}
policy.IPVersion = ipVersion
}
if *request.VoWiFiEnabled && *request.AirplaneEnabled {
writeError(
w,
http.StatusBadRequest,
"invalid_card_policy",
"VoWiFi and airplane mode cannot both be enabled",
)
return
if request.VoWiFiEnabled != nil {
policy.VoWiFiEnabled = *request.VoWiFiEnabled
}
policy := store.CardPolicy{
ICCID: iccid,
NetworkEnabled: false,
VoWiFiEnabled: *request.VoWiFiEnabled,
AirplaneEnabled: *request.AirplaneEnabled,
APN: request.APN,
IPVersion: request.IPVersion,
Source: "manual",
if request.AirplaneEnabled != nil {
policy.AirplaneEnabled = *request.AirplaneEnabled
}
// VoWiFi always owns an RF-off modem. Store airplane=true even when an
// older client omits that implication, so disabling VoWiFi cannot expose a
// brief cellular attach window.
if policy.VoWiFiEnabled {
policy.AirplaneEnabled = true
policy.NetworkEnabled = false
}
if policy.IPVersion == "" {
policy.IPVersion = "IPV4V6"
}
policy.Source = "manual"
if err := s.store.UpsertCardPolicy(r.Context(), policy); err != nil {
s.writeStoreError(w, err)
return
}
policy, err := s.store.CardPolicy(r.Context(), iccid)
policy, err = s.store.CardPolicy(r.Context(), iccid)
if err != nil {
s.writeStoreError(w, err)
return
@@ -1284,6 +1310,259 @@ func (s *Server) handleCardPolicy(w http.ResponseWriter, r *http.Request, iccid
}
}
func defaultCardPolicy(iccid string) store.CardPolicy {
return store.CardPolicy{
ICCID: strings.TrimSpace(iccid),
VoWiFiEnabled: true,
AirplaneEnabled: true,
IPVersion: "IPV4V6",
Source: "default",
}
}
type cardAPNProfilePayload struct {
APN string `json:"apn"`
Username string `json:"username"`
Password *string `json:"password"`
ClearPassword bool `json:"clear_password"`
Proxy string `json:"proxy"`
MCC string `json:"mcc"`
MNC string `json:"mnc"`
IPVersion string `json:"ip_version"`
RoamingIPVersion string `json:"roaming_ip_version"`
AuthType string `json:"auth_type"`
}
func (s *Server) decodeCardAPNProfilePayload(w http.ResponseWriter, r *http.Request) (cardAPNProfilePayload, bool) {
var request cardAPNProfilePayload
if err := s.decodeJSON(w, r, &request); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
return request, false
}
request.APN = strings.TrimSpace(request.APN)
if request.APN == "" || !device.ValidAPN(request.APN) {
writeError(w, http.StatusBadRequest, "invalid_apn", "APN must contain only letters, digits, dots, underscores, or hyphens")
return request, false
}
request.IPVersion = strings.ToUpper(strings.TrimSpace(request.IPVersion))
if request.IPVersion == "" {
request.IPVersion = "IPV4V6"
}
if request.IPVersion != "IP" && request.IPVersion != "IPV6" && request.IPVersion != "IPV4V6" {
writeError(w, http.StatusBadRequest, "invalid_ip_version", "IP version must be IP, IPV6, or IPV4V6")
return request, false
}
request.RoamingIPVersion = strings.ToUpper(strings.TrimSpace(request.RoamingIPVersion))
if request.RoamingIPVersion == "" {
request.RoamingIPVersion = "IP"
}
if request.RoamingIPVersion != "IP" && request.RoamingIPVersion != "IPV6" && request.RoamingIPVersion != "IPV4V6" {
writeError(w, http.StatusBadRequest, "invalid_roaming_ip_version", "roaming IP version must be IP, IPV6, or IPV4V6")
return request, false
}
request.AuthType = strings.ToUpper(strings.TrimSpace(request.AuthType))
if request.AuthType == "" {
request.AuthType = "NONE"
}
if request.AuthType != "NONE" && request.AuthType != "PAP" && request.AuthType != "CHAP" && request.AuthType != "PAP_OR_CHAP" {
writeError(w, http.StatusBadRequest, "invalid_auth_type", "authentication type must be NONE, PAP, CHAP, or PAP_OR_CHAP")
return request, false
}
request.Username = strings.TrimSpace(request.Username)
request.Proxy = strings.TrimSpace(request.Proxy)
request.MCC = strings.TrimSpace(request.MCC)
request.MNC = strings.TrimSpace(request.MNC)
password := ""
if request.Password != nil {
password = *request.Password
}
if !validAPNText(request.Username, 128) || !validAPNText(password, 128) || !validAPNText(request.Proxy, 255) {
writeError(w, http.StatusBadRequest, "invalid_apn_credentials", "APN username, password, or proxy contains unsupported characters")
return request, false
}
if request.MCC != "" && !decimalLength(request.MCC, 3, 3) {
writeError(w, http.StatusBadRequest, "invalid_mcc", "MCC must contain exactly 3 digits")
return request, false
}
if request.MNC != "" && !decimalLength(request.MNC, 2, 3) {
writeError(w, http.StatusBadRequest, "invalid_mnc", "MNC must contain 2 or 3 digits")
return request, false
}
return request, true
}
func (s *Server) handleCardAPNProfiles(w http.ResponseWriter, r *http.Request, iccid, profileID string) {
iccid = strings.TrimSpace(iccid)
if !validICCID(iccid) {
writeError(w, http.StatusBadRequest, "invalid_iccid", "ICCID must contain between 10 and 32 decimal digits")
return
}
if profileID != "" {
id, err := strconv.ParseInt(profileID, 10, 64)
if err != nil || id < 1 {
writeError(w, http.StatusBadRequest, "invalid_apn_profile", "APN profile ID is invalid")
return
}
profiles, err := s.store.ListCardAPNProfiles(r.Context(), iccid)
if err != nil {
s.writeStoreError(w, err)
return
}
var existing store.CardAPNProfile
for _, profile := range profiles {
if profile.ID == id {
existing = profile
break
}
}
if existing.ID == 0 {
writeError(w, http.StatusNotFound, "apn_profile_not_found", "APN profile was not found")
return
}
switch r.Method {
case http.MethodDelete:
if err := s.store.DeleteCardAPNProfile(r.Context(), iccid, id); err != nil {
s.writeStoreError(w, err)
return
}
policy, err := s.store.CardPolicy(r.Context(), iccid)
if err == nil && strings.EqualFold(policy.APN, existing.APN) && strings.EqualFold(policy.IPVersion, existing.IPVersion) {
policy.APN = ""
policy.IPVersion = "IPV4V6"
policy.Source = "manual"
if err := s.store.UpsertCardPolicy(r.Context(), policy); err != nil {
s.writeStoreError(w, err)
return
}
} else if err != nil && !errors.Is(err, store.ErrNotFound) {
s.writeStoreError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"deleted": true, "id": id}})
case http.MethodPatch, http.MethodPut:
request, ok := s.decodeCardAPNProfilePayload(w, r)
if !ok {
return
}
password := existing.Password
if request.ClearPassword {
password = ""
} else if request.Password != nil && *request.Password != "" {
password = *request.Password
}
updated, err := s.store.UpdateCardAPNProfile(r.Context(), store.CardAPNProfile{
ID: id, ICCID: iccid, APN: request.APN, Username: request.Username,
Password: password, Proxy: request.Proxy, MCC: request.MCC, MNC: request.MNC,
IPVersion: request.IPVersion, RoamingIPVersion: request.RoamingIPVersion,
AuthType: request.AuthType,
})
if err != nil {
s.writeStoreError(w, err)
return
}
policy, policyErr := s.store.CardPolicy(r.Context(), iccid)
if policyErr == nil && strings.EqualFold(policy.APN, existing.APN) && strings.EqualFold(policy.IPVersion, existing.IPVersion) {
policy.APN = updated.APN
policy.IPVersion = updated.IPVersion
policy.Source = "manual"
if err := s.store.UpsertCardPolicy(r.Context(), policy); err != nil {
s.writeStoreError(w, err)
return
}
} else if policyErr != nil && !errors.Is(policyErr, store.ErrNotFound) {
s.writeStoreError(w, policyErr)
return
}
writeJSON(w, http.StatusOK, map[string]any{"data": cardAPNProfileResponse(updated)})
default:
w.Header().Set("Allow", "PATCH, PUT, DELETE")
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
}
return
}
switch r.Method {
case http.MethodGet:
profiles, err := s.store.ListCardAPNProfiles(r.Context(), iccid)
if err != nil {
s.writeStoreError(w, err)
return
}
items := make([]map[string]any, 0, len(profiles))
for _, profile := range profiles {
items = append(items, cardAPNProfileResponse(profile))
}
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"items": items}})
case http.MethodPost:
request, ok := s.decodeCardAPNProfilePayload(w, r)
if !ok {
return
}
if _, err := s.store.CardPolicy(r.Context(), iccid); errors.Is(err, store.ErrNotFound) {
if err := s.store.UpsertCardPolicy(r.Context(), defaultCardPolicy(iccid)); err != nil {
s.writeStoreError(w, err)
return
}
} else if err != nil {
s.writeStoreError(w, err)
return
}
password := ""
if request.Password != nil {
password = *request.Password
}
profile, err := s.store.UpsertCardAPNProfile(r.Context(), store.CardAPNProfile{
ICCID: iccid, APN: request.APN, Username: request.Username,
Password: password, Proxy: request.Proxy, MCC: request.MCC, MNC: request.MNC,
IPVersion: request.IPVersion, RoamingIPVersion: request.RoamingIPVersion,
AuthType: request.AuthType,
})
if err != nil {
s.writeStoreError(w, err)
return
}
writeJSON(w, http.StatusCreated, map[string]any{"data": cardAPNProfileResponse(profile)})
default:
w.Header().Set("Allow", "GET, POST")
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
}
}
func cardAPNProfileResponse(profile store.CardAPNProfile) map[string]any {
return map[string]any{
"id": profile.ID, "iccid": profile.ICCID, "apn": profile.APN,
"username": profile.Username, "has_password": profile.Password != "",
"proxy": profile.Proxy, "mcc": profile.MCC, "mnc": profile.MNC,
"ip_version": profile.IPVersion, "roaming_ip_version": profile.RoamingIPVersion,
"auth_type": profile.AuthType, "created_at": profile.CreatedAt,
"updated_at": profile.UpdatedAt,
}
}
func validAPNText(value string, maxLength int) bool {
if len(value) > maxLength || strings.ContainsAny(value, "\r\n\x00\"") {
return false
}
for _, character := range value {
if character < 0x20 || character == 0x7f {
return false
}
}
return true
}
func decimalLength(value string, minimum, maximum int) bool {
if len(value) < minimum || len(value) > maximum {
return false
}
for _, character := range value {
if character < '0' || character > '9' {
return false
}
}
return true
}
func validICCID(value string) bool {
if len(value) < 10 || len(value) > 32 {
return false
+122 -5
View File
@@ -9,6 +9,7 @@ import (
"net/http"
"net/http/httptest"
"net/netip"
"strconv"
"strings"
"sync/atomic"
"testing"
@@ -424,7 +425,8 @@ func TestCardPolicyDefaultValidationAndPersistence(t *testing.T) {
response := decodeSettingsResponse(t, recorder)
policy := response["data"].(map[string]any)
if policy["iccid"] != iccid || policy["source"] != "default" ||
policy["ip_version"] != "IPV4V6" {
policy["ip_version"] != "IPV4V6" || policy["vowifi_enabled"] != true ||
policy["airplane_enabled"] != true {
t.Fatalf("default policy = %#v", policy)
}
@@ -434,8 +436,8 @@ func TestCardPolicyDefaultValidationAndPersistence(t *testing.T) {
"/api/cards/"+iccid+"/policy",
`{"vowifi_enabled":true,"airplane_enabled":true,"apn":"ims","ip_version":"IPV4V6"}`,
)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("conflicting policy status = %d, body = %s", recorder.Code, recorder.Body)
if recorder.Code != http.StatusOK {
t.Fatalf("RF-safe policy status = %d, body = %s", recorder.Code, recorder.Body)
}
recorder = test.request(
@@ -450,14 +452,109 @@ func TestCardPolicyDefaultValidationAndPersistence(t *testing.T) {
response = decodeSettingsResponse(t, recorder)
policy = response["data"].(map[string]any)
if policy["source"] != "manual" || policy["vowifi_enabled"] != true ||
policy["ip_version"] != "IPV4V6" {
policy["airplane_enabled"] != true || policy["ip_version"] != "IPV4V6" {
t.Fatalf("saved policy = %#v", policy)
}
stored, err := test.database.CardPolicy(context.Background(), iccid)
if err != nil || !stored.VoWiFiEnabled || stored.APN != "ims" {
if err != nil || !stored.VoWiFiEnabled || !stored.AirplaneEnabled || stored.APN != "ims" {
t.Fatalf("stored policy = %+v, %v", stored, err)
}
// Updating only the switches must preserve the ICCID-specific APN.
recorder = test.request(
t,
http.MethodPut,
"/api/cards/"+iccid+"/policy",
`{"vowifi_enabled":false,"airplane_enabled":false}`,
)
if recorder.Code != http.StatusOK {
t.Fatalf("partial policy status = %d, body = %s", recorder.Code, recorder.Body)
}
stored, err = test.database.CardPolicy(context.Background(), iccid)
if err != nil || stored.VoWiFiEnabled || stored.AirplaneEnabled || stored.APN != "ims" {
t.Fatalf("partially updated policy = %+v, %v", stored, err)
}
// APN-only updates are accepted without changing either switch.
recorder = test.request(t, http.MethodPut, "/api/cards/"+iccid+"/policy", `{"apn":"mobile.example","ip_version":"ip"}`)
if recorder.Code != http.StatusOK {
t.Fatalf("APN-only policy status = %d, body = %s", recorder.Code, recorder.Body)
}
stored, err = test.database.CardPolicy(context.Background(), iccid)
if err != nil || stored.VoWiFiEnabled || stored.AirplaneEnabled || stored.APN != "mobile.example" || stored.IPVersion != "IP" {
t.Fatalf("APN-only updated policy = %+v, %v", stored, err)
}
// A profile can keep multiple custom APNs independently of the active APN.
recorder = test.request(t, http.MethodPost, "/api/cards/"+iccid+"/apns", `{
"apn":"custom.table","username":"gg","password":"p","proxy":"",
"mcc":"234","mnc":"10","ip_version":"IPV4V6",
"roaming_ip_version":"IP","auth_type":"PAP"
}`)
if recorder.Code != http.StatusCreated {
t.Fatalf("create custom APN status = %d, body = %s", recorder.Code, recorder.Body)
}
response = decodeSettingsResponse(t, recorder)
custom := response["data"].(map[string]any)
customID := int64(custom["id"].(float64))
recorder = test.request(t, http.MethodGet, "/api/cards/"+iccid+"/apns", "")
if recorder.Code != http.StatusOK {
t.Fatalf("list custom APNs status = %d, body = %s", recorder.Code, recorder.Body)
}
response = decodeSettingsResponse(t, recorder)
items := response["data"].(map[string]any)["items"].([]any)
if len(items) != 1 {
t.Fatalf("custom APNs = %#v", items)
}
listed := items[0].(map[string]any)
if listed["apn"] != "custom.table" || listed["username"] != "gg" ||
listed["has_password"] != true || listed["mcc"] != "234" || listed["mnc"] != "10" ||
listed["roaming_ip_version"] != "IP" || listed["auth_type"] != "PAP" {
t.Fatalf("custom APNs = %#v", items)
}
if _, exposed := listed["password"]; exposed {
t.Fatalf("custom APN API exposed stored password: %#v", listed)
}
storedAPN, err := test.database.CardAPNProfileByAPN(context.Background(), iccid, "custom.table", "IPV4V6")
if err != nil || storedAPN.Username != "gg" || storedAPN.Password != "p" || storedAPN.AuthType != "PAP" {
t.Fatalf("stored custom APN = %#v, %v", storedAPN, err)
}
recorder = test.request(t, http.MethodPatch, "/api/cards/"+iccid+"/apns/"+strconv.FormatInt(customID, 10), `{
"apn":"custom.edited","username":"gg2","proxy":"","mcc":"234","mnc":"10",
"ip_version":"IPV4V6","roaming_ip_version":"IP","auth_type":"PAP"
}`)
if recorder.Code != http.StatusOK {
t.Fatalf("edit custom APN status = %d, body = %s", recorder.Code, recorder.Body)
}
storedAPN, err = test.database.CardAPNProfileByAPN(context.Background(), iccid, "custom.edited", "IPV4V6")
if err != nil || storedAPN.Username != "gg2" || storedAPN.Password != "p" {
t.Fatalf("editing custom APN did not preserve password: %#v, %v", storedAPN, err)
}
recorder = test.request(t, http.MethodPut, "/api/cards/"+iccid+"/policy", `{"apn":"custom.edited","ip_version":"IPV4V6"}`)
if recorder.Code != http.StatusOK {
t.Fatalf("activate custom APN status = %d, body = %s", recorder.Code, recorder.Body)
}
recorder = test.request(t, http.MethodPatch, "/api/cards/"+iccid+"/apns/"+strconv.FormatInt(customID, 10), `{
"apn":"custom.final","username":"gg2","clear_password":true,"proxy":"",
"mcc":"234","mnc":"10","ip_version":"IP","roaming_ip_version":"IPV4V6","auth_type":"CHAP"
}`)
if recorder.Code != http.StatusOK {
t.Fatalf("edit active custom APN status = %d, body = %s", recorder.Code, recorder.Body)
}
stored, err = test.database.CardPolicy(context.Background(), iccid)
storedAPN, profileErr := test.database.CardAPNProfileByAPN(context.Background(), iccid, "custom.final", "IP")
if err != nil || profileErr != nil || stored.APN != "custom.final" || stored.IPVersion != "IP" || storedAPN.Password != "" {
t.Fatalf("active APN edit was not synchronized: policy=%#v profile=%#v errors=%v/%v", stored, storedAPN, err, profileErr)
}
recorder = test.request(t, http.MethodDelete, "/api/cards/"+iccid+"/apns/"+strconv.FormatInt(customID, 10), "")
if recorder.Code != http.StatusOK {
t.Fatalf("delete custom APN status = %d, body = %s", recorder.Code, recorder.Body)
}
stored, err = test.database.CardPolicy(context.Background(), iccid)
if err != nil || stored.APN != "" || stored.IPVersion != "IPV4V6" {
t.Fatalf("deleting active custom APN did not restore automatic mode: %+v, %v", stored, err)
}
recorder = test.request(t, http.MethodGet, "/api/cards/not-an-iccid/policy", "")
if recorder.Code != http.StatusBadRequest {
t.Fatalf("invalid ICCID status = %d", recorder.Code)
@@ -582,3 +679,23 @@ func TestRouteSettingsAPIReturnsFalseForUnknownPath(t *testing.T) {
t.Fatal("unknown path was claimed by settings router")
}
}
func TestParseMailAddressRejectsHeaderInjection(t *testing.T) {
for _, value := range []string{
"[email protected]\r\nBcc: [email protected]",
"[email protected]\nX-Test: injected",
"display\x00name <[email protected]>",
} {
if _, err := parseMailAddress(value); err == nil {
t.Errorf("parseMailAddress(%q) accepted header injection", value)
}
}
address, err := parseMailAddress("Vocat Alerts <[email protected]>")
if err != nil {
t.Fatal(err)
}
header := formatMailAddress(address)
if strings.ContainsAny(header, "\r\n") {
t.Fatalf("formatted address contains a line break: %q", header)
}
}
+34
View File
@@ -12,6 +12,7 @@ import (
"strings"
"time"
"vocat/internal/developer"
"vocat/internal/device"
"vocat/internal/store"
"vocat/internal/vowifi"
@@ -224,6 +225,12 @@ func (s *Server) handleSMSSend(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "blocked_destination", reason)
return
}
// Validate the logical message before consuming a global send slot. Both
// cellular AT and VoWiFi IMS use this same encoder/validator.
if _, err := device.PrepareSMSSubmitTPDUs(request.Phone, request.Message); err != nil {
s.writeDeviceError(w, err)
return
}
config, err := s.store.Device(r.Context(), request.DeviceID)
if err != nil {
s.writeStoreError(w, err)
@@ -233,6 +240,33 @@ func (s *Server) handleSMSSend(w http.ResponseWriter, r *http.Request) {
if !s.requirePhysicalDevice(w, present) {
return
}
limit := developer.SMSHourlyLimit(r.Context(), s.store)
reservation, err := s.store.ReserveSMSSend(r.Context(), request.DeviceID, limit, time.Now().UTC())
if err != nil {
s.writeStoreError(w, err)
return
}
if !reservation.Allowed {
retryAfter := time.Until(reservation.ResetAt)
if retryAfter < time.Second {
retryAfter = time.Second
}
w.Header().Set("Retry-After", strconv.FormatInt(int64((retryAfter+time.Second-1)/time.Second), 10))
writeJSON(w, http.StatusTooManyRequests, map[string]any{
"error": apiError{
Code: "sms_rate_limited",
Message: fmt.Sprintf("Global SMS limit reached: at most %d messages may be submitted in a rolling one-hour window.", reservation.Limit),
},
"data": map[string]any{
"limit": reservation.Limit,
"used": reservation.Used,
"remaining": reservation.Remaining,
"reset_at": reservation.ResetAt,
"retry_after": int64((retryAfter + time.Second - 1) / time.Second),
},
})
return
}
if config.VoWiFiEnabled && s.vowifi != nil {
state, stateErr := s.vowifi.State(request.DeviceID)
sender, canSendIMS := s.vowifi.(imsSMSController)
+52
View File
@@ -5,9 +5,12 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"vocat/internal/developer"
"vocat/internal/device"
"vocat/internal/store"
)
@@ -155,3 +158,52 @@ func TestBlockedSMSDestination(t *testing.T) {
})
}
}
func TestHandleSMSSendEnforcesGlobalHourlyLimit(t *testing.T) {
ctx := context.Background()
database, err := store.Open(ctx, ":memory:")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = database.Close() })
if err := developer.SetSMSHourlyLimit(ctx, database, 1); err != nil {
t.Fatal(err)
}
if err := database.UpsertDevice(ctx, store.Device{ID: "ec20_1", Name: "EC20"}); err != nil {
t.Fatal(err)
}
if reservation, err := database.ReserveSMSSend(ctx, "another-device", 1, time.Now().UTC()); err != nil || !reservation.Allowed {
t.Fatalf("seed global SMS reservation = %+v, %v", reservation, err)
}
server := &Server{
store: database,
logger: regionTestLogger(),
maxRequestBodyBytes: 4096,
devices: fakeDeviceController{entry: device.Device{
ID: "ec20_1",
Discovered: true,
Snapshot: &device.Snapshot{DeviceID: "ec20_1"},
}},
}
request := httptest.NewRequest(
http.MethodPost,
"/api/sms/send",
strings.NewReader(`{"device_id":"ec20_1","phone":"+447700900123","message":"hello"}`),
)
request.Header.Set("Content-Type", "application/json")
response := httptest.NewRecorder()
server.handleSMSSend(response, request)
if response.Code != http.StatusTooManyRequests {
t.Fatalf("status = %d, want 429; body=%s", response.Code, response.Body.String())
}
if response.Header().Get("Retry-After") == "" {
t.Fatal("Retry-After header is missing")
}
var envelope errorEnvelope
if err := json.Unmarshal(response.Body.Bytes(), &envelope); err != nil {
t.Fatal(err)
}
if envelope.Error.Code != "sms_rate_limited" {
t.Fatalf("error code = %q, want sms_rate_limited", envelope.Error.Code)
}
}
+8 -15
View File
@@ -11,7 +11,6 @@ import (
"errors"
"fmt"
"io"
"mime"
"net"
"net/http"
"net/mail"
@@ -402,13 +401,13 @@ func sendEmailSMSNotification(ctx context.Context, config map[string]any, messag
return fmt.Errorf("%w: SMTP authentication failed", errProviderRejected)
}
}
from, err := mail.ParseAddress(configString(config, "from_address"))
from, err := parseMailAddress(configString(config, "from_address"))
if err != nil {
return fmt.Errorf("parse sender address: %w", err)
}
recipients := make([]*mail.Address, 0)
for _, item := range configStrings(config, "to_addresses") {
address, err := mail.ParseAddress(item)
address, err := parseMailAddress(item)
if err != nil {
return fmt.Errorf("parse recipient address: %w", err)
}
@@ -426,19 +425,13 @@ func sendEmailSMSNotification(ctx context.Context, config map[string]any, messag
if err != nil {
return fmt.Errorf("%w: SMTP message rejected", errProviderRejected)
}
email := strings.Join([]string{
"Date: " + time.Now().UTC().Format(time.RFC1123Z),
"From: " + from.String(),
"To: " + joinMailAddresses(recipients),
"Subject: " + mime.QEncoding.Encode("UTF-8", "收到新短信 - "+message.DeviceLabel),
"MIME-Version: 1.0",
"Content-Type: text/plain; charset=UTF-8",
"Content-Transfer-Encoding: 8bit",
"",
if err := writePlainTextMail(
writer,
from,
recipients,
"收到新短信 - "+message.DeviceLabel,
message.Text(),
"",
}, "\r\n")
if _, err := io.WriteString(writer, email); err != nil {
); err != nil {
_ = writer.Close()
return fmt.Errorf("write SMTP notification: %w", err)
}
File diff suppressed because it is too large Load Diff
+252 -1
View File
@@ -10,6 +10,7 @@ import (
"vocat/internal/device"
"vocat/internal/modem"
"vocat/internal/store"
"vocat/internal/vowifi"
)
func TestTelegramAPIURLSupportsBaseAndTemplate(t *testing.T) {
@@ -86,6 +87,59 @@ func TestValidTelegramDialNumber(t *testing.T) {
}
}
func TestResolveTelegramPhoneNumberPrefersCurrentSIMAssociation(t *testing.T) {
snapshot := &device.Snapshot{
ICCID: "89441000400128013903",
Phone: device.PhoneNumber{Number: "00000000000"},
}
state := &vowifi.State{
ICCID: snapshot.ICCID,
PhoneNumber: "+447386125520",
}
if got := resolveTelegramPhoneNumber("+447700900123", state, snapshot); got != "+447700900123" {
t.Fatalf("resolved association number = %q", got)
}
if got := resolveTelegramPhoneNumber("", state, snapshot); got != "+447386125520" {
t.Fatalf("resolved IMS number = %q", got)
}
}
func TestResolveTelegramPhoneNumberRejectsPlaceholderAndStaleRuntime(t *testing.T) {
snapshot := &device.Snapshot{
ICCID: "current-card",
Phone: device.PhoneNumber{Number: "00000000000"},
}
state := &vowifi.State{
ICCID: "previous-card",
PhoneNumber: "+447386083638",
}
if got := resolveTelegramPhoneNumber("", state, snapshot); got != "--" {
t.Fatalf("stale or placeholder number leaked as %q", got)
}
for _, placeholder := range []string{"00000000000", "1111111111", "+0000000000", "not-a-number"} {
if usableTelegramPhoneNumber(placeholder) {
t.Errorf("placeholder %q was accepted", placeholder)
}
}
}
func TestTelegramCarrierPresentationSeparatesHomeAndServingNetworks(t *testing.T) {
if got := telegramHomeCarrier("234336570710174"); !strings.Contains(got, "🇬🇧") || !strings.Contains(got, "23433") {
t.Fatalf("home carrier = %q", got)
}
if got := telegramHomeCarrier("204040123456789", "Lebara"); !strings.Contains(got, "Lebara") || !strings.Contains(got, "20404") || !strings.Contains(got, "🇬🇧") || strings.Contains(got, "🇳🇱") {
t.Fatalf("branded foreign-core carrier = %q", got)
}
flight := &device.Snapshot{FlightMode: true, OperatorName: "stale network", RegistrationStatus: 1}
if got := telegramCurrentNetwork(flight); got != "--(飞行模式)" {
t.Fatalf("flight-mode serving network = %q", got)
}
serving := &device.Snapshot{OperatorCode: "46001", RegistrationStatus: 5, AccessTech: "LTE", Band: "B3"}
if got := telegramCurrentNetwork(serving); !strings.Contains(got, "🇨🇳") || !strings.Contains(got, "已驻网(漫游)") {
t.Fatalf("serving network = %q", got)
}
}
func TestTelegramPendingActionIsAuthorizedOneShot(t *testing.T) {
bot := &telegramBot{pending: make(map[string]telegramPendingAction)}
action := telegramPendingAction{Kind: "call", ChatID: -1001, AdminID: 42, CreatedAt: time.Now()}
@@ -101,6 +155,61 @@ func TestTelegramPendingActionIsAuthorizedOneShot(t *testing.T) {
}
}
func TestTelegramMenuCallbackParsing(t *testing.T) {
prefix, token, operation, ok := parseTelegramMenuCallback("call:0123456789abcdef:answer")
if !ok || prefix != "call" || token != "0123456789abcdef" || operation != "answer" {
t.Fatalf("parsed callback = %q %q %q %t", prefix, token, operation, ok)
}
for _, invalid := range []string{"", "call:token", "unknown:token:op", "d::status"} {
if _, _, _, ok := parseTelegramMenuCallback(invalid); ok {
t.Fatalf("invalid callback %q was accepted", invalid)
}
}
}
func TestTelegramMenuPendingCanBeReusedButConfirmationCannot(t *testing.T) {
bot := &telegramBot{pending: make(map[string]telegramPendingAction)}
menuToken, err := bot.putPending(telegramPendingAction{
Kind: "menu_device", DeviceID: "EC20", ChatID: 1, AdminID: 2, CreatedAt: time.Now(),
})
if err != nil {
t.Fatal(err)
}
if _, ok := bot.getPending(menuToken, 1, 2); !ok {
t.Fatal("first menu lookup failed")
}
if _, ok := bot.getPending(menuToken, 1, 2); !ok {
t.Fatal("menu token was unexpectedly consumed")
}
confirmToken, err := bot.putPending(telegramPendingAction{
Kind: "sms", ChatID: 1, AdminID: 2, CreatedAt: time.Now(),
})
if err != nil {
t.Fatal(err)
}
if _, ok := bot.takePending(confirmToken, 1, 2); !ok {
t.Fatal("confirmation token lookup failed")
}
if _, ok := bot.takePending(confirmToken, 1, 2); ok {
t.Fatal("confirmation token was reusable")
}
}
func TestTelegramInputStateIsScopedAndCancelable(t *testing.T) {
bot := &telegramBot{inputs: make(map[string]telegramInputState)}
bot.setInput(telegramInputState{Kind: "sms_phone", DeviceID: "EC20", ChatID: 10, AdminID: 20})
if state, ok := bot.input(10, 20); !ok || state.DeviceID != "EC20" || state.Kind != "sms_phone" {
t.Fatalf("input state = %#v, %t", state, ok)
}
if _, ok := bot.input(10, 21); ok {
t.Fatal("another administrator read the input state")
}
bot.clearInput(10, 20)
if _, ok := bot.input(10, 20); ok {
t.Fatal("cleared input state remained available")
}
}
func TestFormatTelegramATIncludesFinalResult(t *testing.T) {
if got := formatTelegramAT(modem.Response{Final: "OK"}); got != "OK" {
t.Fatalf("formatTelegramAT(OK) = %q", got)
@@ -165,7 +274,7 @@ func TestTelegramExecutesInteractiveUSSDForConfiguredDevice(t *testing.T) {
}
formatted := formatTelegramUSSD("EC20", result)
for _, expected := range []string{
"设备:EC20", "状态:awaiting_input", "1. Balance", "/ussd_reply 0123456789abcdef", "/ussd_cancel 0123456789abcdef",
"设备:EC20", "状态:awaiting_input", "1. Balance", "请直接发送回复内容",
} {
if !strings.Contains(formatted, expected) {
t.Fatalf("USSD result %q does not contain %q", formatted, expected)
@@ -181,3 +290,145 @@ func TestTelegramErrorsRedactBotTokens(t *testing.T) {
t.Fatalf("redacted error = %q", redacted)
}
}
func TestTelegramCallTransportFollowsConfiguredCardMode(t *testing.T) {
controller := &telegramTestCallController{state: vowifi.State{IMSReady: true, Phase: vowifi.PhaseIMSReady}}
bot := &telegramBot{server: &Server{vowifi: controller}}
transport, gotController, err := bot.telegramCallTransport(
store.Device{ID: "EC20", VoWiFiEnabled: true},
device.Device{Snapshot: &device.Snapshot{FlightMode: true}},
)
if err != nil || transport != "vowifi" || gotController == nil {
t.Fatalf("VoWiFi route = %q, %#v, %v", transport, gotController, err)
}
transport, gotController, err = bot.telegramCallTransport(
store.Device{ID: "EC20", VoWiFiEnabled: false},
device.Device{Snapshot: &device.Snapshot{FlightMode: false}},
)
if err != nil || transport != "cellular" || gotController != nil {
t.Fatalf("cellular route = %q, %#v, %v", transport, gotController, err)
}
}
func TestTelegramCallTransportDoesNotFallBackFromUnreadyVoWiFi(t *testing.T) {
controller := &telegramTestCallController{state: vowifi.State{
Phase: vowifi.PhaseFailed, LastError: "SIP registration was rejected: SIP 403",
}}
bot := &telegramBot{server: &Server{vowifi: controller}}
_, _, err := bot.telegramCallTransport(
store.Device{ID: "EC20", VoWiFiEnabled: true},
device.Device{Snapshot: &device.Snapshot{FlightMode: true}},
)
if err == nil || !strings.Contains(err.Error(), "SIP 403") {
t.Fatalf("unready VoWiFi route error = %v", err)
}
}
func TestTelegramTimedVoWiFiCallUsesIMSAndHangsUpByCallID(t *testing.T) {
controller := &telegramTestCallController{state: vowifi.State{IMSReady: true}}
controller.dialResult = vowifi.Call{ID: "ims-call-1", Number: "+447700900123", Direction: "outgoing", State: "dialing"}
controller.calls = []vowifi.Call{{ID: "ims-call-1", Number: "+447700900123", Direction: "outgoing", State: "active"}}
bot := &telegramBot{server: &Server{vowifi: controller}}
result, err := bot.executeTimedVoWiFiCall(context.Background(), telegramRuntimeConfig{}, telegramPendingAction{
DeviceID: "EC20", Argument: "+447700900123", Duration: 20 * time.Millisecond,
}, controller)
if err != nil || !strings.Contains(result, "已接通") {
t.Fatalf("timed VoWiFi result = %q, %v", result, err)
}
if controller.dialed != "+447700900123" || len(controller.hungUp) != 1 || controller.hungUp[0] != "ims-call-1" {
t.Fatalf("IMS actions dial=%q hangup=%#v", controller.dialed, controller.hungUp)
}
}
func TestTelegramTimedCellularCallUsesATDCLCCAndATH(t *testing.T) {
commands := make([]string, 0, 3)
devices := fakeDeviceController{atHandler: func(command string) (modem.Response, error) {
commands = append(commands, command)
switch {
case strings.HasPrefix(command, "ATD"):
return modem.Response{Final: "OK"}, nil
case command == "AT+CLCC":
return modem.Response{Lines: []string{`+CLCC: 1,0,2,0,0,"+447700900123",145`}, Final: "OK"}, nil
case command == "ATH":
return modem.Response{Final: "OK"}, nil
default:
return modem.Response{}, errors.New("unexpected command")
}
}}
bot := &telegramBot{server: &Server{devices: devices}}
result, err := bot.executeTimedCellularCall(context.Background(), telegramRuntimeConfig{}, telegramPendingAction{
DeviceID: "EC20", Argument: "+447700900123", Duration: 20 * time.Millisecond,
}, "physical")
if err != nil || !strings.Contains(result, "正在拨号") {
t.Fatalf("timed cellular result = %q, %v", result, err)
}
joined := strings.Join(commands, ",")
for _, expected := range []string{"ATD+447700900123;", "AT+CLCC", "ATH"} {
if !strings.Contains(joined, expected) {
t.Fatalf("commands %q omit %q", joined, expected)
}
}
}
func TestTelegramVoWiFiFailureIncludesSIPDiagnostic(t *testing.T) {
err := telegramVoWiFiCallFailure(vowifi.Call{State: "failed", SIPCode: 403, Reason: "Forbidden"})
if !strings.Contains(err.Error(), "SIP 403") || !strings.Contains(err.Error(), "Forbidden") {
t.Fatalf("VoWiFi diagnostic = %q", err)
}
}
func TestTelegramVoWiFi487IsReportedAsCancelledOutcome(t *testing.T) {
result, err := telegramVoWiFiCallOutcome("888", vowifi.Call{
State: "failed", SIPCode: 487, Reason: "Request Terminated",
})
if err != nil || !strings.Contains(result, "取消或终止") || !strings.Contains(result, "SIP 487") {
t.Fatalf("487 outcome = %q, %v", result, err)
}
}
type telegramTestCallController struct {
state vowifi.State
calls []vowifi.Call
dialResult vowifi.Call
dialErr error
dialed string
hungUp []string
}
func (controller *telegramTestCallController) State(string) (vowifi.State, error) {
return controller.state, nil
}
func (controller *telegramTestCallController) RequestEnabled(string, bool) (vowifi.State, error) {
return controller.state, nil
}
func (controller *telegramTestCallController) RequestReconnect(string) (vowifi.State, error) {
return controller.state, nil
}
func (controller *telegramTestCallController) Calls(string) ([]vowifi.Call, error) {
return append([]vowifi.Call(nil), controller.calls...), nil
}
func (controller *telegramTestCallController) DialCall(_ context.Context, _ string, number string) (vowifi.Call, error) {
controller.dialed = number
return controller.dialResult, controller.dialErr
}
func (controller *telegramTestCallController) AnswerCall(_ context.Context, _ string, id string) (vowifi.Call, error) {
for _, call := range controller.calls {
if call.ID == id {
call.State = "active"
return call, nil
}
}
return vowifi.Call{}, errors.New("call not found")
}
func (controller *telegramTestCallController) HangupCall(_ context.Context, _ string, id string) error {
controller.hungUp = append(controller.hungUp, id)
return nil
}
+313
View File
@@ -0,0 +1,313 @@
package store
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"time"
)
const automaticTaskSelect = `
SELECT id, name, enabled, device_id, profile_iccid, profile_aid,
task_type, environment, interval_days, start_date, run_time,
timezone, payload_json, retry_count, notify, next_run_at, last_run_at,
last_status, last_error, created_at, updated_at
FROM automatic_tasks`
func (s *Store) SaveAutomaticTask(ctx context.Context, value AutomaticTask) (AutomaticTask, error) {
now := time.Now().UTC()
if strings.TrimSpace(value.Timezone) == "" {
value.Timezone = time.Local.String()
}
if value.CreatedAt.IsZero() {
value.CreatedAt = now
}
value.UpdatedAt = now
if len(value.Payload) == 0 {
value.Payload = []byte(`{}`)
}
if value.ID == 0 {
result, err := s.db.ExecContext(ctx, `INSERT INTO automatic_tasks (
name, enabled, device_id, profile_iccid, profile_aid, task_type,
environment, interval_days, start_date, run_time, timezone, payload_json,
retry_count, notify, next_run_at, last_run_at, last_status,
last_error, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
strings.TrimSpace(value.Name), value.Enabled, strings.TrimSpace(value.DeviceID),
strings.TrimSpace(value.ProfileICCID), strings.TrimSpace(value.ProfileAID),
value.TaskType, value.Environment, value.IntervalDays, value.StartDate,
value.RunTime, value.Timezone, string(value.Payload), value.RetryCount, value.Notify,
value.NextRunAt.Unix(), unixOrZero(value.LastRunAt), value.LastStatus,
value.LastError, value.CreatedAt.Unix(), value.UpdatedAt.Unix())
if err != nil {
return AutomaticTask{}, fmt.Errorf("create automatic task: %w", err)
}
value.ID, _ = result.LastInsertId()
} else {
result, err := s.db.ExecContext(ctx, `UPDATE automatic_tasks SET
name = ?, enabled = ?, device_id = ?, profile_iccid = ?, profile_aid = ?,
task_type = ?, environment = ?, interval_days = ?, start_date = ?,
run_time = ?, timezone = ?, payload_json = ?, retry_count = ?, notify = ?,
next_run_at = ?, updated_at = ? WHERE id = ?`,
strings.TrimSpace(value.Name), value.Enabled, strings.TrimSpace(value.DeviceID),
strings.TrimSpace(value.ProfileICCID), strings.TrimSpace(value.ProfileAID),
value.TaskType, value.Environment, value.IntervalDays, value.StartDate,
value.RunTime, value.Timezone, string(value.Payload), value.RetryCount, value.Notify,
value.NextRunAt.Unix(), value.UpdatedAt.Unix(), value.ID)
if err != nil {
return AutomaticTask{}, fmt.Errorf("update automatic task %d: %w", value.ID, err)
}
if count, _ := result.RowsAffected(); count == 0 {
return AutomaticTask{}, ErrNotFound
}
}
return s.AutomaticTask(ctx, value.ID)
}
func (s *Store) AutomaticTask(ctx context.Context, id int64) (AutomaticTask, error) {
return scanAutomaticTask(s.db.QueryRowContext(ctx, automaticTaskSelect+` WHERE id = ?`, id))
}
func (s *Store) ListAutomaticTasks(ctx context.Context) ([]AutomaticTask, error) {
rows, err := s.db.QueryContext(ctx, automaticTaskSelect+` ORDER BY created_at DESC, id DESC`)
if err != nil {
return nil, fmt.Errorf("list automatic tasks: %w", err)
}
defer rows.Close()
var result []AutomaticTask
for rows.Next() {
value, scanErr := scanAutomaticTask(rows)
if scanErr != nil {
return nil, scanErr
}
result = append(result, value)
}
return result, rows.Err()
}
func (s *Store) DeleteAutomaticTask(ctx context.Context, id int64) error {
result, err := s.db.ExecContext(ctx, `DELETE FROM automatic_tasks WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("delete automatic task %d: %w", id, err)
}
if count, _ := result.RowsAffected(); count == 0 {
return ErrNotFound
}
return nil
}
func (s *Store) ClaimDueAutomaticTasks(ctx context.Context, now time.Time, limit int) ([]AutomaticTaskRun, error) {
if limit <= 0 || limit > 100 {
limit = 50
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
defer tx.Rollback()
rows, err := tx.QueryContext(ctx, automaticTaskSelect+`
WHERE enabled = 1 AND next_run_at <= ? ORDER BY next_run_at, id LIMIT ?`, now.Unix(), limit)
if err != nil {
return nil, err
}
var tasks []AutomaticTask
for rows.Next() {
task, scanErr := scanAutomaticTask(rows)
if scanErr != nil {
rows.Close()
return nil, scanErr
}
tasks = append(tasks, task)
}
rows.Close()
result := make([]AutomaticTaskRun, 0, len(tasks))
for _, task := range tasks {
next := task.NextRunAt
location := time.Local
if loaded, loadErr := time.LoadLocation(task.Timezone); loadErr == nil {
location = loaded
}
for !next.After(now) {
next = next.In(location).AddDate(0, 0, task.IntervalDays).UTC()
}
if _, err = tx.ExecContext(ctx, `UPDATE automatic_tasks SET next_run_at = ?, updated_at = ? WHERE id = ?`, next.Unix(), now.Unix(), task.ID); err != nil {
return nil, err
}
created, createErr := tx.ExecContext(ctx, `INSERT INTO automatic_task_runs (
task_id, device_id, scheduled_at, status, created_at, updated_at
) VALUES (?, ?, ?, 'queued', ?, ?)`, task.ID, task.DeviceID, task.NextRunAt.Unix(), now.Unix(), now.Unix())
if createErr != nil {
return nil, createErr
}
runID, _ := created.LastInsertId()
result = append(result, AutomaticTaskRun{ID: runID, TaskID: task.ID, DeviceID: task.DeviceID, ScheduledAt: task.NextRunAt, Status: "queued", CreatedAt: now, UpdatedAt: now})
}
if err := tx.Commit(); err != nil {
return nil, err
}
return result, nil
}
func (s *Store) QueueAutomaticTaskNow(ctx context.Context, task AutomaticTask) (AutomaticTaskRun, error) {
now := time.Now().UTC()
result, err := s.db.ExecContext(ctx, `INSERT INTO automatic_task_runs (
task_id, device_id, scheduled_at, status, created_at, updated_at
) VALUES (?, ?, ?, 'queued', ?, ?)`, task.ID, task.DeviceID, now.Unix(), now.Unix(), now.Unix())
if err != nil {
return AutomaticTaskRun{}, fmt.Errorf("queue automatic task: %w", err)
}
id, _ := result.LastInsertId()
return AutomaticTaskRun{ID: id, TaskID: task.ID, DeviceID: task.DeviceID, ScheduledAt: now, Status: "queued", CreatedAt: now, UpdatedAt: now}, nil
}
func (s *Store) UpdateAutomaticTaskRun(ctx context.Context, run AutomaticTaskRun) error {
now := time.Now().UTC()
_, err := s.db.ExecContext(ctx, `UPDATE automatic_task_runs SET
started_at = ?, finished_at = ?, status = ?, attempts = ?, output = ?, error = ?, updated_at = ?
WHERE id = ?`, unixOrZero(run.StartedAt), unixOrZero(run.FinishedAt), run.Status,
run.Attempts, run.Output, run.Error, now.Unix(), run.ID)
if err != nil {
return fmt.Errorf("update automatic task run %d: %w", run.ID, err)
}
if run.Status == "success" || run.Status == "failed" {
_, err = s.db.ExecContext(ctx, `UPDATE automatic_tasks SET
last_run_at = ?, last_status = ?, last_error = ?, updated_at = ? WHERE id = ?`,
run.FinishedAt.Unix(), run.Status, run.Error, now.Unix(), run.TaskID)
}
return err
}
// RecoverAutomaticTaskRuns reconciles durable run records with the in-memory
// scheduler after a process restart. Running work cannot still be executing,
// while queued work is safe to put back onto the per-device queues.
func (s *Store) RecoverAutomaticTaskRuns(ctx context.Context, now time.Time) ([]AutomaticTaskRun, error) {
const restartError = "service restarted before the automatic task completed"
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
defer tx.Rollback()
if _, err = tx.ExecContext(ctx, `UPDATE automatic_task_runs SET
status = 'failed', finished_at = ?, error = ?, updated_at = ?
WHERE status = 'running'`, now.Unix(), restartError, now.Unix()); err != nil {
return nil, fmt.Errorf("recover running automatic tasks: %w", err)
}
if _, err = tx.ExecContext(ctx, `UPDATE automatic_tasks SET
last_run_at = ?, last_status = 'failed', last_error = ?, updated_at = ?
WHERE id IN (
SELECT task_id FROM automatic_task_runs
WHERE status = 'failed' AND error = ? AND finished_at = ?
)`, now.Unix(), restartError, now.Unix(), restartError, now.Unix()); err != nil {
return nil, fmt.Errorf("recover automatic task status: %w", err)
}
rows, err := tx.QueryContext(ctx, automaticTaskRunSelect+` WHERE status = 'queued' ORDER BY id`)
if err != nil {
return nil, fmt.Errorf("recover queued automatic tasks: %w", err)
}
queued, err := scanAutomaticTaskRuns(rows)
if err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, err
}
return queued, nil
}
const automaticTaskRunSelect = `
SELECT id, task_id, device_id, scheduled_at, started_at, finished_at,
status, attempts, output, error, created_at, updated_at
FROM automatic_task_runs`
func (s *Store) ListAutomaticTaskRuns(ctx context.Context, limit int) ([]AutomaticTaskRun, error) {
if limit <= 0 || limit > 500 {
limit = 100
}
rows, err := s.db.QueryContext(ctx, automaticTaskRunSelect+` ORDER BY id DESC LIMIT ?`, limit)
if err != nil {
return nil, err
}
return scanAutomaticTaskRuns(rows)
}
// ListAutomaticTaskRunsPaginated returns one page of runs (newest first) plus
// the total run count, so the UI can page through the full history instead of
// a fixed recent window.
func (s *Store) ListAutomaticTaskRunsPaginated(ctx context.Context, limit, offset int) ([]AutomaticTaskRun, int, error) {
if limit <= 0 {
limit = 20
}
if limit > 100 {
limit = 100
}
if offset < 0 {
offset = 0
}
total := 0
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM automatic_task_runs`).Scan(&total); err != nil {
return nil, 0, fmt.Errorf("count automatic task runs: %w", err)
}
rows, err := s.db.QueryContext(ctx, automaticTaskRunSelect+` ORDER BY id DESC LIMIT ? OFFSET ?`, limit, offset)
if err != nil {
return nil, 0, err
}
runs, err := scanAutomaticTaskRuns(rows)
if err != nil {
return nil, 0, err
}
return runs, total, nil
}
func scanAutomaticTaskRuns(rows *sql.Rows) ([]AutomaticTaskRun, error) {
defer rows.Close()
var result []AutomaticTaskRun
for rows.Next() {
var value AutomaticTaskRun
var scheduled, started, finished, created, updated int64
if err := rows.Scan(&value.ID, &value.TaskID, &value.DeviceID, &scheduled, &started,
&finished, &value.Status, &value.Attempts, &value.Output, &value.Error, &created, &updated); err != nil {
return nil, err
}
value.ScheduledAt, value.StartedAt, value.FinishedAt = time.Unix(scheduled, 0).UTC(), timeFromUnix(started), timeFromUnix(finished)
value.CreatedAt, value.UpdatedAt = time.Unix(created, 0).UTC(), time.Unix(updated, 0).UTC()
result = append(result, value)
}
return result, rows.Err()
}
func scanAutomaticTask(row rowScanner) (AutomaticTask, error) {
var value AutomaticTask
var enabled, notify bool
var payload string
var nextRun, lastRun, created, updated int64
if err := row.Scan(&value.ID, &value.Name, &enabled, &value.DeviceID, &value.ProfileICCID,
&value.ProfileAID, &value.TaskType, &value.Environment, &value.IntervalDays,
&value.StartDate, &value.RunTime, &value.Timezone, &payload, &value.RetryCount, &notify,
&nextRun, &lastRun, &value.LastStatus, &value.LastError, &created, &updated); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return AutomaticTask{}, ErrNotFound
}
return AutomaticTask{}, err
}
value.Enabled, value.Notify = enabled, notify
value.Payload = []byte(payload)
value.NextRunAt, value.LastRunAt = time.Unix(nextRun, 0).UTC(), timeFromUnix(lastRun)
value.CreatedAt, value.UpdatedAt = time.Unix(created, 0).UTC(), time.Unix(updated, 0).UTC()
return value, nil
}
func unixOrZero(value time.Time) int64 {
if value.IsZero() {
return 0
}
return value.Unix()
}
func timeFromUnix(value int64) time.Time {
if value <= 0 {
return time.Time{}
}
return time.Unix(value, 0).UTC()
}
+182
View File
@@ -0,0 +1,182 @@
package store
import (
"context"
"encoding/json"
"path/filepath"
"strings"
"testing"
"time"
)
func TestAutomaticTasksAreClaimedInDeviceQueueOrderAndAdvanceSchedule(t *testing.T) {
ctx := context.Background()
database := openTestStore(t, filepath.Join(t.TempDir(), "automatic-tasks.db"))
mustSaveDevice(t, database, "ec20", "EC20")
now := time.Now().UTC().Truncate(time.Second)
for index := 0; index < 2; index++ {
payload, _ := json.Marshal(map[string]any{"phone": "10086", "message": "test"})
if _, err := database.SaveAutomaticTask(ctx, AutomaticTask{
Name: "task", Enabled: true, DeviceID: "ec20", ProfileICCID: "8944100000000000000",
TaskType: "sms", Environment: "vowifi", IntervalDays: 2,
StartDate: "2026-08-10", RunTime: "12:00", Timezone: "Asia/Shanghai", Payload: payload,
NextRunAt: now.Add(time.Duration(index-2) * time.Minute),
}); err != nil {
t.Fatal(err)
}
}
runs, err := database.ClaimDueAutomaticTasks(ctx, now, 10)
if err != nil {
t.Fatal(err)
}
if len(runs) != 2 || runs[0].DeviceID != "ec20" || runs[1].DeviceID != "ec20" || runs[0].TaskID >= runs[1].TaskID {
t.Fatalf("claimed runs = %+v", runs)
}
tasks, err := database.ListAutomaticTasks(ctx)
if err != nil {
t.Fatal(err)
}
for _, task := range tasks {
if !task.NextRunAt.After(now) {
t.Fatalf("task %d next run was not advanced: %v", task.ID, task.NextRunAt)
}
}
second, err := database.ClaimDueAutomaticTasks(ctx, now, 10)
if err != nil || len(second) != 0 {
t.Fatalf("same schedule claimed twice: %+v, %v", second, err)
}
}
func TestDeletingAutomaticTaskRemovesRunHistory(t *testing.T) {
ctx := context.Background()
database := openTestStore(t, filepath.Join(t.TempDir(), "automatic-task-delete.db"))
mustSaveDevice(t, database, "ec20", "EC20")
task, err := database.SaveAutomaticTask(ctx, AutomaticTask{
Name: "task", Enabled: true, DeviceID: "ec20", ProfileICCID: "one",
TaskType: "call", Environment: "cellular", IntervalDays: 1,
StartDate: "2026-08-10", RunTime: "12:00", Timezone: "Asia/Shanghai", Payload: []byte(`{"phone":"10086","duration_seconds":10}`),
NextRunAt: time.Now().Add(time.Hour),
})
if err != nil {
t.Fatal(err)
}
if _, err := database.QueueAutomaticTaskNow(ctx, task); err != nil {
t.Fatal(err)
}
if err := database.DeleteAutomaticTask(ctx, task.ID); err != nil {
t.Fatal(err)
}
runs, err := database.ListAutomaticTaskRuns(ctx, 10)
if err != nil || len(runs) != 0 {
t.Fatalf("orphan runs = %+v, %v", runs, err)
}
}
func TestListAutomaticTaskRunsPaginated(t *testing.T) {
ctx := context.Background()
database := openTestStore(t, filepath.Join(t.TempDir(), "automatic-task-runs-page.db"))
mustSaveDevice(t, database, "ec20", "EC20")
task, err := database.SaveAutomaticTask(ctx, AutomaticTask{
Name: "task", Enabled: true, DeviceID: "ec20", ProfileICCID: "one",
TaskType: "call", Environment: "cellular", IntervalDays: 1,
StartDate: "2026-08-10", RunTime: "12:00", Timezone: "Asia/Shanghai", Payload: []byte(`{"phone":"10086","duration_seconds":10}`),
NextRunAt: time.Now().Add(time.Hour),
})
if err != nil {
t.Fatal(err)
}
for index := 0; index < 5; index++ {
if _, err := database.QueueAutomaticTaskNow(ctx, task); err != nil {
t.Fatal(err)
}
}
first, total, err := database.ListAutomaticTaskRunsPaginated(ctx, 2, 0)
if err != nil {
t.Fatal(err)
}
if total != 5 || len(first) != 2 {
t.Fatalf("first page: total = %d, runs = %+v", total, first)
}
if first[0].ID <= first[1].ID {
t.Fatalf("runs not newest-first: %+v", first)
}
last, total, err := database.ListAutomaticTaskRunsPaginated(ctx, 2, 4)
if err != nil {
t.Fatal(err)
}
if total != 5 || len(last) != 1 {
t.Fatalf("last page: total = %d, runs = %+v", total, last)
}
// Out-of-range paging inputs are clamped to defaults, not errors.
all, total, err := database.ListAutomaticTaskRunsPaginated(ctx, 0, -5)
if err != nil {
t.Fatal(err)
}
if total != 5 || len(all) != 5 {
t.Fatalf("clamped page: total = %d, runs = %+v", total, all)
}
}
func TestRecoverAutomaticTaskRunsFailsRunningAndReturnsQueued(t *testing.T) {
ctx := context.Background()
database := openTestStore(t, filepath.Join(t.TempDir(), "automatic-task-recovery.db"))
mustSaveDevice(t, database, "ec20", "EC20")
task, err := database.SaveAutomaticTask(ctx, AutomaticTask{
Name: "task", Enabled: true, DeviceID: "ec20", ProfileICCID: "one",
TaskType: "call", Environment: "cellular", IntervalDays: 1,
StartDate: "2026-08-10", RunTime: "12:00", Timezone: "Asia/Shanghai", Payload: []byte(`{"phone":"10086","duration_seconds":10}`),
NextRunAt: time.Now().Add(time.Hour),
})
if err != nil {
t.Fatal(err)
}
running, err := database.QueueAutomaticTaskNow(ctx, task)
if err != nil {
t.Fatal(err)
}
running.Status = "running"
running.StartedAt = time.Now().UTC().Add(-time.Minute)
running.Attempts = 1
if err := database.UpdateAutomaticTaskRun(ctx, running); err != nil {
t.Fatal(err)
}
queued, err := database.QueueAutomaticTaskNow(ctx, task)
if err != nil {
t.Fatal(err)
}
recoveredAt := time.Now().UTC().Truncate(time.Second)
recovered, err := database.RecoverAutomaticTaskRuns(ctx, recoveredAt)
if err != nil {
t.Fatal(err)
}
if len(recovered) != 1 || recovered[0].ID != queued.ID || recovered[0].Status != "queued" {
t.Fatalf("recovered queued runs = %+v", recovered)
}
runs, err := database.ListAutomaticTaskRuns(ctx, 10)
if err != nil {
t.Fatal(err)
}
foundRunning := false
for _, run := range runs {
if run.ID == running.ID {
foundRunning = true
if run.Status != "failed" || run.FinishedAt.IsZero() || !strings.Contains(run.Error, "service restarted") {
t.Fatalf("recovered running run = %+v", run)
}
}
}
if !foundRunning {
t.Fatal("running run was not found after recovery")
}
recoveredTask, err := database.AutomaticTask(ctx, task.ID)
if err != nil {
t.Fatal(err)
}
if recoveredTask.LastStatus != "failed" || !strings.Contains(recoveredTask.LastError, "service restarted") {
t.Fatalf("recovered task status = %+v", recoveredTask)
}
}
+92 -8
View File
@@ -58,7 +58,8 @@ func TestMigrationFromAuthenticationSchema(t *testing.T) {
"local_proxy_config", "upstream_proxies", "country_rules",
"device_proxy_bindings",
"notification_settings", "app_settings", "audit_events",
"log_events", "card_policies", "traffic_buckets",
"log_events", "card_policies", "card_apn_profiles", "traffic_buckets",
"sms_send_attempts",
} {
var found string
err := database.db.QueryRowContext(ctx, `
@@ -105,6 +106,85 @@ func TestMigration7BackfillsSMSModemIMEI(t *testing.T) {
}
}
func TestMigration12ConvertsOnlyKnownActiveDeviceBindingToICCID(t *testing.T) {
ctx := context.Background()
path := filepath.Join(t.TempDir(), "profile-proxy-binding.db")
raw, err := sql.Open("sqlite", path)
if err != nil {
t.Fatal(err)
}
for version := 1; version <= 11; version++ {
for _, statement := range migrationStatements(version) {
if _, err := raw.ExecContext(ctx, statement); err != nil {
t.Fatalf("create v%d schema: %v", version, err)
}
}
}
if _, err := raw.ExecContext(ctx, `
INSERT INTO devices (id, name, created_at, updated_at) VALUES
('known', 'Known', 100, 100), ('unknown', 'Unknown', 100, 100);
INSERT INTO upstream_proxies (id, name, addr, created_at, updated_at)
VALUES ('route', 'Route', '127.0.0.1:1080', 100, 100);
INSERT INTO device_proxy_bindings (device_id, upstream_proxy_id, created_at, updated_at) VALUES
('known', 'route', 100, 100), ('unknown', 'route', 100, 100);
INSERT INTO vowifi_runtime (device_id, iccid, updated_at)
VALUES ('known', '89441000400128014257', 100);
PRAGMA user_version = 11;
`); err != nil {
t.Fatal(err)
}
if err := raw.Close(); err != nil {
t.Fatal(err)
}
database := openTestStore(t, path)
binding, err := database.DeviceProxyBinding(ctx, "89441000400128014257")
if err != nil || binding.DeviceID != "known" || binding.UpstreamProxyID != "route" {
t.Fatalf("migrated binding = %+v, %v", binding, err)
}
bindings, err := database.ListDeviceProxyBindings(ctx)
if err != nil || len(bindings) != 1 {
t.Fatalf("migrated bindings = %+v, %v; unknown ICCID binding must be dropped", bindings, err)
}
}
func TestMigration9NormalizesVoWiFiAirplanePolicy(t *testing.T) {
ctx := context.Background()
path := filepath.Join(t.TempDir(), "rf-safe-policy.db")
raw, err := sql.Open("sqlite", path)
if err != nil {
t.Fatal(err)
}
for version := 1; version <= 8; version++ {
for _, statement := range migrationStatements(version) {
if _, err := raw.ExecContext(ctx, statement); err != nil {
t.Fatalf("create v%d schema: %v", version, err)
}
}
}
if _, err := raw.ExecContext(ctx, `
INSERT INTO card_policies (
iccid, network_enabled, vowifi_enabled, airplane_enabled,
created_at, updated_at
) VALUES ('8900000000000000001', 0, 1, 0, 100, 100);
PRAGMA user_version = 8;
`); err != nil {
t.Fatal(err)
}
if err := raw.Close(); err != nil {
t.Fatal(err)
}
database := openTestStore(t, path)
policy, err := database.CardPolicy(ctx, "8900000000000000001")
if err != nil {
t.Fatal(err)
}
if !policy.VoWiFiEnabled || !policy.AirplaneEnabled || policy.NetworkEnabled {
t.Fatalf("migrated policy = %#v, want VoWiFi+airplane with data off", policy)
}
}
func TestMigration8DefaultsExistingDevicesToPCIeType(t *testing.T) {
ctx := context.Background()
path := filepath.Join(t.TempDir(), "device-type.db")
@@ -566,12 +646,12 @@ func TestProxyCredentialsAndCountryRules(t *testing.T) {
t.Fatalf("CountryRule() = %+v, %v", rule, err)
}
if err := database.UpsertDeviceProxyBinding(ctx, DeviceProxyBinding{
DeviceID: "ec20-1", UpstreamProxyID: "up-1",
DeviceID: "ec20-1", ICCID: "89441000400128014257", ProfileName: "Vodafone", UpstreamProxyID: "up-1",
}); err != nil {
t.Fatal(err)
}
binding, err := database.DeviceProxyBinding(ctx, "ec20-1")
if err != nil || binding.UpstreamProxyID != "up-1" {
binding, err := database.DeviceProxyBinding(ctx, "89441000400128014257")
if err != nil || binding.UpstreamProxyID != "up-1" || binding.DeviceID != "ec20-1" || binding.ProfileName != "Vodafone" {
t.Fatalf("DeviceProxyBinding() = %+v, %v", binding, err)
}
if err := database.DeleteUpstreamProxy(ctx, "up-1"); err != nil {
@@ -580,7 +660,7 @@ func TestProxyCredentialsAndCountryRules(t *testing.T) {
if _, err := database.CountryRule(ctx, "CN"); !errors.Is(err, ErrNotFound) {
t.Fatalf("country rule should cascade with upstream deletion, got %v", err)
}
if _, err := database.DeviceProxyBinding(ctx, "ec20-1"); !errors.Is(err, ErrNotFound) {
if _, err := database.DeviceProxyBinding(ctx, "89441000400128014257"); !errors.Is(err, ErrNotFound) {
t.Fatalf("device binding should cascade with upstream deletion, got %v", err)
}
}
@@ -709,14 +789,18 @@ func TestEventsPoliciesAndTraffic(t *testing.T) {
t.Fatal(err)
}
if err := database.UpsertCardPolicy(ctx, CardPolicy{
ICCID: "invalid", VoWiFiEnabled: true, AirplaneEnabled: true,
}); err == nil {
t.Fatal("invalid mutually exclusive card policy was accepted")
ICCID: "89860002", VoWiFiEnabled: true, AirplaneEnabled: true,
}); err != nil {
t.Fatalf("RF-safe VoWiFi policy was rejected: %v", err)
}
policy, err := database.CardPolicy(ctx, "89860001")
if err != nil || !policy.VoWiFiEnabled {
t.Fatalf("CardPolicy() = %+v, %v", policy, err)
}
safePolicy, err := database.CardPolicy(ctx, "89860002")
if err != nil || !safePolicy.VoWiFiEnabled || !safePolicy.AirplaneEnabled {
t.Fatalf("safe CardPolicy() = %+v, %v", safePolicy, err)
}
period := old.Truncate(time.Hour)
if err := database.UpsertTrafficBucket(ctx, TrafficBucket{
+144
View File
@@ -111,6 +111,150 @@ func migrationStatements(version int) []string {
`ALTER TABLE devices
ADD COLUMN device_type TEXT NOT NULL DEFAULT 'pcie_ec20_ec25'`,
}
case 9:
return []string{
// VoWiFi deliberately owns airplane mode. Earlier schemas treated
// these flags as mutually exclusive, which made the RF-safe state
// impossible to persist. Rebuild the table without changing rows.
`ALTER TABLE card_policies RENAME TO card_policies_v8`,
`CREATE TABLE card_policies (
iccid TEXT PRIMARY KEY,
network_enabled INTEGER NOT NULL DEFAULT 0 CHECK (network_enabled IN (0, 1)),
vowifi_enabled INTEGER NOT NULL DEFAULT 0 CHECK (vowifi_enabled IN (0, 1)),
airplane_enabled INTEGER NOT NULL DEFAULT 0 CHECK (airplane_enabled IN (0, 1)),
apn TEXT NOT NULL DEFAULT '',
ip_version TEXT NOT NULL DEFAULT '',
source TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)`,
`INSERT INTO card_policies (
iccid, network_enabled, vowifi_enabled, airplane_enabled,
apn, ip_version, source, created_at, updated_at
) SELECT
iccid, network_enabled, vowifi_enabled, airplane_enabled,
apn, ip_version, source, created_at, updated_at
FROM card_policies_v8`,
`UPDATE card_policies
SET airplane_enabled = 1, network_enabled = 0
WHERE vowifi_enabled = 1`,
`DROP TABLE card_policies_v8`,
}
case 10:
return []string{
`CREATE TABLE IF NOT EXISTS automatic_tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
device_id TEXT NOT NULL,
profile_iccid TEXT NOT NULL,
profile_aid TEXT NOT NULL DEFAULT '',
task_type TEXT NOT NULL CHECK (task_type IN ('sms', 'call', 'public_ip')),
environment TEXT NOT NULL CHECK (environment IN ('vowifi', 'cellular')),
interval_days INTEGER NOT NULL CHECK (interval_days BETWEEN 1 AND 365),
start_date TEXT NOT NULL,
run_time TEXT NOT NULL,
timezone TEXT NOT NULL DEFAULT 'Local',
payload_json TEXT NOT NULL DEFAULT '{}',
retry_count INTEGER NOT NULL DEFAULT 0 CHECK (retry_count BETWEEN 0 AND 10),
notify INTEGER NOT NULL DEFAULT 0 CHECK (notify IN (0, 1)),
next_run_at INTEGER NOT NULL,
last_run_at INTEGER NOT NULL DEFAULT 0,
last_status TEXT NOT NULL DEFAULT '',
last_error TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY (device_id) REFERENCES devices(id) ON DELETE CASCADE
)`,
`CREATE INDEX IF NOT EXISTS automatic_tasks_due_idx ON automatic_tasks(enabled, next_run_at, id)`,
`CREATE INDEX IF NOT EXISTS automatic_tasks_device_idx ON automatic_tasks(device_id, next_run_at, id)`,
`CREATE TABLE IF NOT EXISTS automatic_task_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id INTEGER NOT NULL,
device_id TEXT NOT NULL,
scheduled_at INTEGER NOT NULL,
started_at INTEGER NOT NULL DEFAULT 0,
finished_at INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL CHECK (status IN ('queued', 'running', 'success', 'failed')),
attempts INTEGER NOT NULL DEFAULT 0,
output TEXT NOT NULL DEFAULT '',
error TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY (task_id) REFERENCES automatic_tasks(id) ON DELETE CASCADE
)`,
`CREATE INDEX IF NOT EXISTS automatic_task_runs_task_idx ON automatic_task_runs(task_id, id DESC)`,
`CREATE INDEX IF NOT EXISTS automatic_task_runs_status_idx ON automatic_task_runs(status, id)`,
}
case 11:
return []string{
`CREATE TABLE IF NOT EXISTS sms_send_attempts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
device_id TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL
)`,
`CREATE INDEX IF NOT EXISTS sms_send_attempts_created_idx
ON sms_send_attempts(created_at, id)`,
}
case 12:
return []string{
`ALTER TABLE device_proxy_bindings RENAME TO device_proxy_bindings_v11`,
`CREATE TABLE device_proxy_bindings (
iccid TEXT PRIMARY KEY,
device_id TEXT NOT NULL,
profile_name TEXT NOT NULL DEFAULT '',
upstream_proxy_id TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY (device_id) REFERENCES devices(id) ON DELETE CASCADE,
FOREIGN KEY (upstream_proxy_id) REFERENCES upstream_proxies(id) ON DELETE CASCADE
)`,
// A legacy device-wide binding is safe to preserve only when the
// currently observed ICCID is known. It then becomes one profile binding
// instead of leaking onto every future profile used by that device.
`INSERT OR IGNORE INTO device_proxy_bindings (
iccid, device_id, profile_name, upstream_proxy_id, created_at, updated_at
)
SELECT COALESCE(NULLIF(v.iccid, ''), NULLIF(d.iccid, '')),
b.device_id, '', b.upstream_proxy_id, b.created_at, b.updated_at
FROM device_proxy_bindings_v11 b
LEFT JOIN vowifi_runtime v ON v.device_id = b.device_id
LEFT JOIN device_runtime d ON d.device_id = b.device_id
WHERE COALESCE(NULLIF(v.iccid, ''), NULLIF(d.iccid, '')) IS NOT NULL`,
`DROP TABLE device_proxy_bindings_v11`,
`CREATE INDEX device_proxy_bindings_proxy_idx
ON device_proxy_bindings(upstream_proxy_id)`,
`CREATE INDEX device_proxy_bindings_device_idx
ON device_proxy_bindings(device_id, iccid)`,
}
case 13:
return []string{
`CREATE TABLE IF NOT EXISTS card_apn_profiles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
iccid TEXT NOT NULL,
apn TEXT NOT NULL,
ip_version TEXT NOT NULL DEFAULT 'IPV4V6'
CHECK (ip_version IN ('IP', 'IPV6', 'IPV4V6')),
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
UNIQUE (iccid, apn, ip_version),
FOREIGN KEY (iccid) REFERENCES card_policies(iccid) ON DELETE CASCADE
)`,
`CREATE INDEX IF NOT EXISTS card_apn_profiles_iccid_idx
ON card_apn_profiles(iccid, id)`,
}
case 14:
return []string{
`ALTER TABLE card_apn_profiles ADD COLUMN username TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE card_apn_profiles ADD COLUMN password TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE card_apn_profiles ADD COLUMN proxy TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE card_apn_profiles ADD COLUMN mcc TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE card_apn_profiles ADD COLUMN mnc TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE card_apn_profiles ADD COLUMN roaming_ip_version TEXT NOT NULL DEFAULT 'IP'
CHECK (roaming_ip_version IN ('IP', 'IPV6', 'IPV4V6'))`,
`ALTER TABLE card_apn_profiles ADD COLUMN auth_type TEXT NOT NULL DEFAULT 'NONE'
CHECK (auth_type IN ('NONE', 'PAP', 'CHAP', 'PAP_OR_CHAP'))`,
}
default:
return nil
}
+59 -3
View File
@@ -124,6 +124,45 @@ type PhoneAssociation struct {
UpdatedAt time.Time
}
type AutomaticTask struct {
ID int64 `json:"id"`
Name string `json:"name"`
Enabled bool `json:"enabled"`
DeviceID string `json:"device_id"`
ProfileICCID string `json:"profile_iccid"`
ProfileAID string `json:"profile_aid"`
TaskType string `json:"task_type"`
Environment string `json:"environment"`
IntervalDays int `json:"interval_days"`
StartDate string `json:"start_date"`
RunTime string `json:"run_time"`
Timezone string `json:"timezone"`
Payload json.RawMessage `json:"payload"`
RetryCount int `json:"retry_count"`
Notify bool `json:"notify"`
NextRunAt time.Time `json:"next_run_at"`
LastRunAt time.Time `json:"last_run_at,omitempty"`
LastStatus string `json:"last_status"`
LastError string `json:"last_error"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type AutomaticTaskRun struct {
ID int64 `json:"id"`
TaskID int64 `json:"task_id"`
DeviceID string `json:"device_id"`
ScheduledAt time.Time `json:"scheduled_at"`
StartedAt time.Time `json:"started_at,omitempty"`
FinishedAt time.Time `json:"finished_at,omitempty"`
Status string `json:"status"`
Attempts int `json:"attempts"`
Output string `json:"output"`
Error string `json:"error"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type SMSMessage struct {
ID int64
MessageID string
@@ -263,11 +302,12 @@ type CountryRule struct {
UpdatedAt time.Time
}
// DeviceProxyBinding selects the SOCKS5 upstream used by one device's whole
// VoWiFi runtime. The IKE/IPsec transport uses this route and IMS/SMS then
// travel inside that tunnel.
// DeviceProxyBinding selects the SOCKS5 upstream for exactly one eSIM profile.
// ICCID is globally unique, while one proxy may serve profiles on many devices.
type DeviceProxyBinding struct {
DeviceID string
ICCID string
ProfileName string
UpstreamProxyID string
CreatedAt time.Time
UpdatedAt time.Time
@@ -448,6 +488,22 @@ type CardPolicy struct {
UpdatedAt time.Time
}
type CardAPNProfile struct {
ID int64
ICCID string
APN string
Username string
Password string
Proxy string
MCC string
MNC string
IPVersion string
RoamingIPVersion string
AuthType string
CreatedAt time.Time
UpdatedAt time.Time
}
type TrafficBucket struct {
DeviceID string
Bucket string
+21 -17
View File
@@ -358,9 +358,11 @@ func upstreamProxy(row rowScanner) (UpstreamProxy, error) {
func (s *Store) UpsertDeviceProxyBinding(ctx context.Context, value DeviceProxyBinding) error {
value.DeviceID = strings.TrimSpace(value.DeviceID)
value.ICCID = strings.TrimSpace(value.ICCID)
value.ProfileName = strings.TrimSpace(value.ProfileName)
value.UpstreamProxyID = strings.TrimSpace(value.UpstreamProxyID)
if value.DeviceID == "" || value.UpstreamProxyID == "" {
return errors.New("device proxy binding requires device and upstream proxy IDs")
if value.DeviceID == "" || value.ICCID == "" || value.UpstreamProxyID == "" {
return errors.New("profile proxy binding requires device ID, ICCID, and upstream proxy ID")
}
now := time.Now().UTC()
createdAt := value.CreatedAt
@@ -373,28 +375,30 @@ func (s *Store) UpsertDeviceProxyBinding(ctx context.Context, value DeviceProxyB
}
_, err := s.db.ExecContext(ctx, `
INSERT INTO device_proxy_bindings (
device_id, upstream_proxy_id, created_at, updated_at
) VALUES (?, ?, ?, ?)
ON CONFLICT(device_id) DO UPDATE SET
iccid, device_id, profile_name, upstream_proxy_id, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(iccid) DO UPDATE SET
device_id = excluded.device_id,
profile_name = excluded.profile_name,
upstream_proxy_id = excluded.upstream_proxy_id,
updated_at = excluded.updated_at
`, value.DeviceID, value.UpstreamProxyID, createdAt.Unix(), updatedAt.Unix())
`, value.ICCID, value.DeviceID, value.ProfileName, value.UpstreamProxyID, createdAt.Unix(), updatedAt.Unix())
if err != nil {
return fmt.Errorf("upsert proxy binding for device %q: %w", value.DeviceID, err)
return fmt.Errorf("upsert proxy binding for ICCID %q: %w", value.ICCID, err)
}
return nil
}
func (s *Store) DeviceProxyBinding(ctx context.Context, deviceID string) (DeviceProxyBinding, error) {
func (s *Store) DeviceProxyBinding(ctx context.Context, iccid string) (DeviceProxyBinding, error) {
return deviceProxyBinding(s.db.QueryRowContext(
ctx,
deviceProxyBindingSelect+` WHERE device_id = ?`,
strings.TrimSpace(deviceID),
deviceProxyBindingSelect+` WHERE iccid = ?`,
strings.TrimSpace(iccid),
))
}
func (s *Store) ListDeviceProxyBindings(ctx context.Context) ([]DeviceProxyBinding, error) {
rows, err := s.db.QueryContext(ctx, deviceProxyBindingSelect+` ORDER BY device_id`)
rows, err := s.db.QueryContext(ctx, deviceProxyBindingSelect+` ORDER BY device_id, profile_name COLLATE NOCASE, iccid`)
if err != nil {
return nil, fmt.Errorf("list device proxy bindings: %w", err)
}
@@ -413,26 +417,26 @@ func (s *Store) ListDeviceProxyBindings(ctx context.Context) ([]DeviceProxyBindi
return values, nil
}
func (s *Store) DeleteDeviceProxyBinding(ctx context.Context, deviceID string) error {
func (s *Store) DeleteDeviceProxyBinding(ctx context.Context, iccid string) error {
result, err := s.db.ExecContext(
ctx,
`DELETE FROM device_proxy_bindings WHERE device_id = ?`,
strings.TrimSpace(deviceID),
`DELETE FROM device_proxy_bindings WHERE iccid = ?`,
strings.TrimSpace(iccid),
)
if err != nil {
return fmt.Errorf("delete proxy binding for device %q: %w", deviceID, err)
return fmt.Errorf("delete proxy binding for ICCID %q: %w", iccid, err)
}
return requireAffected(result)
}
const deviceProxyBindingSelect = `
SELECT device_id, upstream_proxy_id, created_at, updated_at
SELECT device_id, iccid, profile_name, upstream_proxy_id, created_at, updated_at
FROM device_proxy_bindings`
func deviceProxyBinding(row rowScanner) (DeviceProxyBinding, error) {
var value DeviceProxyBinding
var createdAt, updatedAt int64
err := row.Scan(&value.DeviceID, &value.UpstreamProxyID, &createdAt, &updatedAt)
err := row.Scan(&value.DeviceID, &value.ICCID, &value.ProfileName, &value.UpstreamProxyID, &createdAt, &updatedAt)
if errors.Is(err, sql.ErrNoRows) {
return DeviceProxyBinding{}, ErrNotFound
}
+155 -3
View File
@@ -369,9 +369,6 @@ func (s *Store) UpsertCardPolicy(ctx context.Context, value CardPolicy) error {
default:
return fmt.Errorf("unsupported card policy IP version %q", value.IPVersion)
}
if value.VoWiFiEnabled && value.AirplaneEnabled {
return errors.New("VoWiFi and airplane mode cannot both be enabled")
}
now := time.Now().UTC()
createdAt := value.CreatedAt
if createdAt.IsZero() {
@@ -468,6 +465,161 @@ func cardPolicy(row rowScanner) (CardPolicy, error) {
return value, nil
}
func (s *Store) UpsertCardAPNProfile(ctx context.Context, value CardAPNProfile) (CardAPNProfile, error) {
value.ICCID = strings.TrimSpace(value.ICCID)
value.APN = strings.TrimSpace(value.APN)
value.IPVersion = strings.ToUpper(strings.TrimSpace(value.IPVersion))
if value.ICCID == "" || value.APN == "" {
return CardAPNProfile{}, errors.New("card APN profile ICCID and APN are required")
}
if value.IPVersion == "" {
value.IPVersion = "IPV4V6"
}
switch value.IPVersion {
case "IP", "IPV6", "IPV4V6":
default:
return CardAPNProfile{}, fmt.Errorf("unsupported card APN profile IP version %q", value.IPVersion)
}
value.RoamingIPVersion = strings.ToUpper(strings.TrimSpace(value.RoamingIPVersion))
if value.RoamingIPVersion == "" {
value.RoamingIPVersion = "IP"
}
switch value.RoamingIPVersion {
case "IP", "IPV6", "IPV4V6":
default:
return CardAPNProfile{}, fmt.Errorf("unsupported card APN roaming IP version %q", value.RoamingIPVersion)
}
value.AuthType = strings.ToUpper(strings.TrimSpace(value.AuthType))
if value.AuthType == "" {
value.AuthType = "NONE"
}
switch value.AuthType {
case "NONE", "PAP", "CHAP", "PAP_OR_CHAP":
default:
return CardAPNProfile{}, fmt.Errorf("unsupported card APN authentication type %q", value.AuthType)
}
value.Username = strings.TrimSpace(value.Username)
value.Proxy = strings.TrimSpace(value.Proxy)
value.MCC = strings.TrimSpace(value.MCC)
value.MNC = strings.TrimSpace(value.MNC)
now := time.Now().UTC().Unix()
var createdAt, updatedAt int64
err := s.db.QueryRowContext(ctx, `
INSERT INTO card_apn_profiles (
iccid, apn, username, password, proxy, mcc, mnc,
ip_version, roaming_ip_version, auth_type, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(iccid, apn, ip_version) DO UPDATE SET
username = excluded.username, password = excluded.password,
proxy = excluded.proxy, mcc = excluded.mcc, mnc = excluded.mnc,
roaming_ip_version = excluded.roaming_ip_version,
auth_type = excluded.auth_type, updated_at = excluded.updated_at
RETURNING id, iccid, apn, username, password, proxy, mcc, mnc,
ip_version, roaming_ip_version, auth_type, created_at, updated_at
`, value.ICCID, value.APN, value.Username, value.Password, value.Proxy, value.MCC, value.MNC,
value.IPVersion, value.RoamingIPVersion, value.AuthType, now, now).Scan(
&value.ID, &value.ICCID, &value.APN, &value.Username, &value.Password,
&value.Proxy, &value.MCC, &value.MNC, &value.IPVersion,
&value.RoamingIPVersion, &value.AuthType, &createdAt, &updatedAt,
)
if err != nil {
return CardAPNProfile{}, fmt.Errorf("upsert card APN profile: %w", err)
}
value.CreatedAt = time.Unix(createdAt, 0).UTC()
value.UpdatedAt = time.Unix(updatedAt, 0).UTC()
return value, nil
}
func (s *Store) ListCardAPNProfiles(ctx context.Context, iccid string) ([]CardAPNProfile, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT id, iccid, apn, username, password, proxy, mcc, mnc,
ip_version, roaming_ip_version, auth_type, created_at, updated_at
FROM card_apn_profiles WHERE iccid = ? ORDER BY id
`, strings.TrimSpace(iccid))
if err != nil {
return nil, fmt.Errorf("list card APN profiles: %w", err)
}
defer rows.Close()
values := make([]CardAPNProfile, 0)
for rows.Next() {
var value CardAPNProfile
var createdAt, updatedAt int64
if err := rows.Scan(&value.ID, &value.ICCID, &value.APN, &value.Username,
&value.Password, &value.Proxy, &value.MCC, &value.MNC, &value.IPVersion,
&value.RoamingIPVersion, &value.AuthType, &createdAt, &updatedAt); err != nil {
return nil, fmt.Errorf("scan card APN profile: %w", err)
}
value.CreatedAt = time.Unix(createdAt, 0).UTC()
value.UpdatedAt = time.Unix(updatedAt, 0).UTC()
values = append(values, value)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate card APN profiles: %w", err)
}
return values, nil
}
func (s *Store) CardAPNProfileByAPN(ctx context.Context, iccid, apn, ipVersion string) (CardAPNProfile, error) {
profiles, err := s.ListCardAPNProfiles(ctx, iccid)
if err != nil {
return CardAPNProfile{}, err
}
for _, profile := range profiles {
if strings.EqualFold(profile.APN, strings.TrimSpace(apn)) &&
strings.EqualFold(profile.IPVersion, strings.TrimSpace(ipVersion)) {
return profile, nil
}
}
return CardAPNProfile{}, ErrNotFound
}
func (s *Store) UpdateCardAPNProfile(ctx context.Context, value CardAPNProfile) (CardAPNProfile, error) {
value.ICCID = strings.TrimSpace(value.ICCID)
value.APN = strings.TrimSpace(value.APN)
value.Username = strings.TrimSpace(value.Username)
value.Proxy = strings.TrimSpace(value.Proxy)
value.MCC = strings.TrimSpace(value.MCC)
value.MNC = strings.TrimSpace(value.MNC)
value.IPVersion = strings.ToUpper(strings.TrimSpace(value.IPVersion))
value.RoamingIPVersion = strings.ToUpper(strings.TrimSpace(value.RoamingIPVersion))
value.AuthType = strings.ToUpper(strings.TrimSpace(value.AuthType))
if value.ID < 1 || value.ICCID == "" || value.APN == "" {
return CardAPNProfile{}, errors.New("card APN profile ID, ICCID, and APN are required")
}
now := time.Now().UTC().Unix()
var createdAt, updatedAt int64
err := s.db.QueryRowContext(ctx, `
UPDATE card_apn_profiles SET
apn = ?, username = ?, password = ?, proxy = ?, mcc = ?, mnc = ?,
ip_version = ?, roaming_ip_version = ?, auth_type = ?, updated_at = ?
WHERE id = ? AND iccid = ?
RETURNING id, iccid, apn, username, password, proxy, mcc, mnc,
ip_version, roaming_ip_version, auth_type, created_at, updated_at
`, value.APN, value.Username, value.Password, value.Proxy, value.MCC, value.MNC,
value.IPVersion, value.RoamingIPVersion, value.AuthType, now, value.ID, value.ICCID).Scan(
&value.ID, &value.ICCID, &value.APN, &value.Username, &value.Password,
&value.Proxy, &value.MCC, &value.MNC, &value.IPVersion,
&value.RoamingIPVersion, &value.AuthType, &createdAt, &updatedAt,
)
if errors.Is(err, sql.ErrNoRows) {
return CardAPNProfile{}, ErrNotFound
}
if err != nil {
return CardAPNProfile{}, fmt.Errorf("update card APN profile: %w", err)
}
value.CreatedAt = time.Unix(createdAt, 0).UTC()
value.UpdatedAt = time.Unix(updatedAt, 0).UTC()
return value, nil
}
func (s *Store) DeleteCardAPNProfile(ctx context.Context, iccid string, id int64) error {
result, err := s.db.ExecContext(ctx, `DELETE FROM card_apn_profiles WHERE iccid = ? AND id = ?`, strings.TrimSpace(iccid), id)
if err != nil {
return fmt.Errorf("delete card APN profile: %w", err)
}
return requireAffected(result)
}
func (s *Store) UpsertTrafficBucket(ctx context.Context, value TrafficBucket) error {
return s.writeTrafficBucket(ctx, value, false)
}
+88
View File
@@ -0,0 +1,88 @@
package store
import (
"context"
"errors"
"fmt"
"strings"
"time"
)
const SMSRateWindow = time.Hour
// SMSRateReservation is the durable result of claiming one global outbound
// SMS slot. The quota is shared by every device, SIM, transport, and caller.
type SMSRateReservation struct {
Allowed bool
Limit int
Used int
Remaining int
ResetAt time.Time
}
// ReserveSMSSend atomically claims one slot in the rolling one-hour window.
// It intentionally records submission attempts separately from SMS history so
// deleting a conversation cannot reset the global safety limit.
func (s *Store) ReserveSMSSend(
ctx context.Context,
deviceID string,
limit int,
now time.Time,
) (SMSRateReservation, error) {
if limit < 1 {
return SMSRateReservation{}, errors.New("SMS hourly limit must be positive")
}
if now.IsZero() {
now = time.Now().UTC()
} else {
now = now.UTC()
}
cutoff := now.Add(-SMSRateWindow).Unix()
result, err := s.db.ExecContext(ctx, `
INSERT INTO sms_send_attempts (device_id, created_at)
SELECT ?, ?
WHERE (
SELECT COUNT(*) FROM sms_send_attempts WHERE created_at > ?
) < ?
`, strings.TrimSpace(deviceID), now.Unix(), cutoff, limit)
if err != nil {
return SMSRateReservation{}, fmt.Errorf("reserve global SMS send slot: %w", err)
}
affected, err := result.RowsAffected()
if err != nil {
return SMSRateReservation{}, fmt.Errorf("read global SMS reservation result: %w", err)
}
status, err := s.smsRateStatus(ctx, limit, cutoff)
if err != nil {
return SMSRateReservation{}, err
}
status.Allowed = affected == 1
if status.Allowed {
// Old rows are irrelevant to enforcement. Pruning after the atomic claim
// keeps the hot index compact without creating a delete-before-insert race.
_, _ = s.db.ExecContext(ctx, `DELETE FROM sms_send_attempts WHERE created_at <= ?`, now.Add(-7*24*time.Hour).Unix())
}
return status, nil
}
func (s *Store) smsRateStatus(ctx context.Context, limit int, cutoff int64) (SMSRateReservation, error) {
var used int
var earliest *int64
if err := s.db.QueryRowContext(ctx, `
SELECT COUNT(*), MIN(created_at)
FROM sms_send_attempts
WHERE created_at > ?
`, cutoff).Scan(&used, &earliest); err != nil {
return SMSRateReservation{}, fmt.Errorf("read global SMS rate status: %w", err)
}
remaining := limit - used
if remaining < 0 {
remaining = 0
}
status := SMSRateReservation{Limit: limit, Used: used, Remaining: remaining}
if earliest != nil {
status.ResetAt = time.Unix(*earliest, 0).UTC().Add(SMSRateWindow)
}
return status, nil
}
+58
View File
@@ -0,0 +1,58 @@
package store
import (
"context"
"sync"
"sync/atomic"
"testing"
"time"
)
func TestReserveSMSSendIsGlobalAndRolling(t *testing.T) {
database := openTestStore(t, ":memory:")
now := time.Unix(1_800_000_000, 0).UTC()
first, err := database.ReserveSMSSend(context.Background(), "ec20_1", 2, now)
if err != nil || !first.Allowed || first.Used != 1 || first.Remaining != 1 {
t.Fatalf("first reservation = %+v, %v", first, err)
}
second, err := database.ReserveSMSSend(context.Background(), "ec20_2", 2, now.Add(time.Second))
if err != nil || !second.Allowed || second.Used != 2 || second.Remaining != 0 {
t.Fatalf("second reservation = %+v, %v", second, err)
}
blocked, err := database.ReserveSMSSend(context.Background(), "another-device", 2, now.Add(2*time.Second))
if err != nil || blocked.Allowed || blocked.Used != 2 || !blocked.ResetAt.Equal(now.Add(SMSRateWindow)) {
t.Fatalf("blocked reservation = %+v, %v", blocked, err)
}
afterWindow, err := database.ReserveSMSSend(context.Background(), "ec20_1", 2, now.Add(SMSRateWindow+time.Second))
if err != nil || !afterWindow.Allowed || afterWindow.Used != 1 {
t.Fatalf("reservation after rolling window = %+v, %v", afterWindow, err)
}
}
func TestReserveSMSSendCannotExceedLimitConcurrently(t *testing.T) {
database := openTestStore(t, ":memory:")
now := time.Unix(1_800_000_000, 0).UTC()
const limit = 10
const callers = 40
var allowed atomic.Int32
var wait sync.WaitGroup
for index := 0; index < callers; index++ {
wait.Add(1)
go func(index int) {
defer wait.Done()
result, err := database.ReserveSMSSend(context.Background(), "device", limit, now)
if err != nil {
t.Errorf("reservation %d: %v", index, err)
return
}
if result.Allowed {
allowed.Add(1)
}
}(index)
}
wait.Wait()
if got := allowed.Load(); got != limit {
t.Fatalf("allowed reservations = %d, want %d", got, limit)
}
}
+3 -2
View File
@@ -13,7 +13,7 @@ import (
_ "modernc.org/sqlite"
)
const schemaVersion = 8
const schemaVersion = 14
var ErrNotFound = errors.New("store: not found")
@@ -121,7 +121,8 @@ func migrate(ctx context.Context, db *sql.DB) error {
// already contain an additive column. Remaining statements in the
// migration are still safe and must be applied.
duplicateAdditiveColumn := (nextVersion == 7 && strings.Contains(statement, "ADD COLUMN modem_imei")) ||
(nextVersion == 8 && strings.Contains(statement, "ADD COLUMN device_type"))
(nextVersion == 8 && strings.Contains(statement, "ADD COLUMN device_type")) ||
(nextVersion == 14 && strings.Contains(statement, "ADD COLUMN"))
if duplicateAdditiveColumn && strings.Contains(strings.ToLower(err.Error()), "duplicate column name") {
continue
}
+31 -9
View File
@@ -222,14 +222,29 @@ func (adapter *EC20Adapter) readHomePLMN(
}
// Exact assigned HPLMN prefixes are data, not an MNC-length heuristic. The
// target Vodafone UK SIM is 234/15. Unknown assignments remain fail-closed.
for prefix, mncLength := range map[string]int{"23415": 2} {
if strings.HasPrefix(imsi, prefix) {
return imsi[:3], imsi[3 : 3+mncLength], nil
}
if mcc, mnc, ok := assignedHomePLMN(imsi); ok {
return mcc, mnc, nil
}
return "", "", efErr
}
func assignedHomePLMN(imsi string) (mcc, mnc string, ok bool) {
assignments := []struct {
prefix string
mncLength int
}{
{prefix: "20404", mncLength: 2}, // Vodafone NL core; some Lebara subscriptions.
{prefix: "23415", mncLength: 2}, // Vodafone UK.
{prefix: "23487", mncLength: 2}, // Lebara Mobile UK.
}
for _, assignment := range assignments {
if strings.HasPrefix(imsi, assignment.prefix) {
return imsi[:3], imsi[3 : 3+assignment.mncLength], true
}
}
return "", "", false
}
func validConfiguredHomePLMN(imsi, mcc, mnc string) bool {
if !validDigits(mcc, 3, 3) || !validDigits(mnc, 2, 3) {
return false
@@ -658,15 +673,22 @@ func (adapter *EC20Adapter) Restore(
if snapshot.OperatingMode < 0 {
return errors.New("vocat: invalid EC20 radio snapshot")
}
targetMode := snapshot.OperatingMode
if snapshot.PureAirplanePolicy {
// VoWiFi teardown is intentionally fail-closed. Even if the modem was in
// CFUN=1 before setup, disabling VoWiFi must leave RF off until the user
// explicitly turns airplane mode off through the separate control.
targetMode = 4
}
currentMode, err := adapter.readOperatingMode(ctx, deviceID)
if err != nil {
return err
}
if currentMode != snapshot.OperatingMode {
if currentMode != targetMode {
if _, err := adapter.execute(
ctx,
deviceID,
fmt.Sprintf("AT+CFUN=%d", snapshot.OperatingMode),
fmt.Sprintf("AT+CFUN=%d", targetMode),
); err != nil {
return fmt.Errorf("restore EC20 operating mode: %w", err)
}
@@ -675,11 +697,11 @@ func (adapter *EC20Adapter) Restore(
if err != nil {
return err
}
if currentMode != snapshot.OperatingMode {
if currentMode != targetMode {
return fmt.Errorf(
"vocat: EC20 restore reported CFUN=%d, expected %d",
currentMode,
snapshot.OperatingMode,
targetMode,
)
}
@@ -697,7 +719,7 @@ func (adapter *EC20Adapter) Restore(
return errors.New("vocat: EC20 snapshot has active data in RF-off mode")
}
desiredCIDs := checkpoint.activeCIDs
if !adapter.options.RestoreCellularData {
if !adapter.options.RestoreCellularData || targetMode == 0 || targetMode == 4 {
desiredCIDs = nil
}
if err := adapter.reconcileActiveCIDs(
+16 -4
View File
@@ -416,6 +416,20 @@ func TestEC20AdapterReadsExplicitHomePLMNAndKnownAssignmentFallback(
}
}
func TestAssignedHomePLMNIncludesLebaraUKCores(t *testing.T) {
tests := map[string]string{
"204040123456789": "204/04",
"234150123456789": "234/15",
"234870123456789": "234/87",
}
for imsi, want := range tests {
mcc, mnc, ok := assignedHomePLMN(imsi)
if got := mcc + "/" + mnc; !ok || got != want {
t.Errorf("assignedHomePLMN(%q) = %q, %v; want %q", imsi, got, ok, want)
}
}
}
func TestEC20AdapterRadioTransactionRestoresCFUNAndPDPContexts(
t *testing.T,
) {
@@ -440,16 +454,14 @@ func TestEC20AdapterRadioTransactionRestoresCFUNAndPDPContexts(
lines: []string{"+CGACT: 1,0", "+CGACT: 2,0"},
},
{command: "AT+CFUN?", lines: []string{"+CFUN: 4"}},
{command: "AT+CFUN=1"},
{command: "AT+CFUN?", lines: []string{"+CFUN: 1"}},
{command: "AT+CFUN?", lines: []string{"+CFUN: 4"}},
{
command: "AT+CGACT?",
lines: []string{"+CGACT: 1,0", "+CGACT: 2,0"},
},
{command: "AT+CGACT=1,1"},
{
command: "AT+CGACT?",
lines: []string{"+CGACT: 1,1", "+CGACT: 2,0"},
lines: []string{"+CGACT: 1,0", "+CGACT: 2,0"},
},
},
}
+10 -1
View File
@@ -110,7 +110,7 @@ func (provider *Provider) Start(ctx context.Context, request vowifi.TunnelReques
}()
group := uint16(dhMODP2048)
legacyFirst := request.Identity.HomeMCC == "234" && request.Identity.HomeMNC == "15"
legacyFirst := legacyIKEProfile(request.Identity.HomeMCC, request.Identity.HomeMNC)
if legacyFirst {
group = dhMODP1024
}
@@ -544,6 +544,15 @@ func (provider *Provider) Start(ctx context.Context, request vowifi.TunnelReques
return session, nil
}
func legacyIKEProfile(mcc, mnc string) bool {
// Vodafone's UK and Netherlands ePDGs use the legacy group-2/SHA-1-first
// proposal ordering. Some Lebara UK subscriptions carry a 204-04 IMSI from
// that Vodafone NL core; treating them as a generic modern network causes
// IKE_SA_INIT to fail before EAP-AKA even begins.
plmn := strings.TrimSpace(mcc) + strings.TrimLeft(strings.TrimSpace(mnc), "0")
return plmn == "23415" || plmn == "2044"
}
func buildInitialEAPOnlyAuth(
idi payload,
requestedIDr payload,
+18
View File
@@ -16,6 +16,24 @@ var errFirstAuthObserved = errors.New("test: first IKE_AUTH observed")
type constantReader struct{ value byte }
func TestLegacyIKEProfileIncludesVodafoneHostedLebaraCore(t *testing.T) {
for _, item := range []struct {
mcc string
mnc string
}{
{mcc: "234", mnc: "15"},
{mcc: "204", mnc: "04"},
{mcc: "204", mnc: "004"},
} {
if !legacyIKEProfile(item.mcc, item.mnc) {
t.Errorf("legacyIKEProfile(%q, %q) = false", item.mcc, item.mnc)
}
}
if legacyIKEProfile("234", "87") {
t.Fatal("Lebara's 234-87 core must use the modern IKE profile")
}
}
func (reader constantReader) Read(destination []byte) (int, error) {
for index := range destination {
destination[index] = reader.value
+6 -1
View File
@@ -205,8 +205,13 @@ func (relay *sessionRelay) terminalError() error {
func (relay *sessionRelay) Close() error {
relay.cancel()
// ReceiveSessionPacket implementations normally observe the canceled
// context through a short read deadline. Close the transport as an explicit
// wake-up as well: a socket implementation that is stuck in Read must not
// hold teardown (and the associated TUN interface) indefinitely.
transportErr := relay.transport.Close()
<-relay.done
return relay.terminalErrorIfFailure()
return errors.Join(relay.terminalErrorIfFailure(), transportErr)
}
func (relay *sessionRelay) terminalErrorIfFailure() error {
+50 -6
View File
@@ -22,12 +22,13 @@ type fakeSentPacket struct {
}
type fakeSessionTransport struct {
incoming chan fakeSessionPacket
sent chan fakeSentPacket
closed chan struct{}
once sync.Once
readers atomic.Int32
maxReads atomic.Int32
incoming chan fakeSessionPacket
sent chan fakeSentPacket
closed chan struct{}
ignoreContext bool
once sync.Once
readers atomic.Int32
maxReads atomic.Int32
}
func newFakeSessionTransport() *fakeSessionTransport {
@@ -81,6 +82,18 @@ func (transport *fakeSessionTransport) ReceiveSessionPacket(
}
}
defer transport.readers.Add(-1)
if transport.ignoreContext {
select {
case packet := <-transport.incoming:
if packet.err != nil {
return 0, false, packet.err
}
copy(buffer, packet.data)
return len(packet.data), packet.ike, nil
case <-transport.closed:
return 0, false, net.ErrClosed
}
}
select {
case packet := <-transport.incoming:
if packet.err != nil {
@@ -96,6 +109,37 @@ func (transport *fakeSessionTransport) ReceiveSessionPacket(
return 0, false, net.ErrClosed
}
}
func TestSessionRelayCloseInterruptsStuckTransportRead(t *testing.T) {
transport := newFakeSessionTransport()
transport.ignoreContext = true
relay := newSessionRelay(
transport,
legacyTestSuite(),
ikeKeys{},
[8]byte{1},
[8]byte{2},
true,
time.Hour,
)
deadline := time.Now().Add(time.Second)
for transport.readers.Load() == 0 && time.Now().Before(deadline) {
time.Sleep(time.Millisecond)
}
if transport.readers.Load() == 0 {
t.Fatal("relay did not enter the transport read")
}
done := make(chan error, 1)
go func() { done <- relay.Close() }()
select {
case err := <-done:
if err != nil {
t.Fatalf("close relay: %v", err)
}
case <-time.After(time.Second):
t.Fatal("relay Close did not interrupt the transport read")
}
}
func (transport *fakeSessionTransport) Close() error {
transport.once.Do(func() { close(transport.closed) })
return nil
+82 -10
View File
@@ -7,7 +7,6 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"net"
"os"
"os/exec"
@@ -21,6 +20,8 @@ import (
const userspaceTunnelMTU = 1380
const userspaceTunnelPollInterval = 100 * time.Millisecond
type linuxUserspaceInstaller struct {
ipCommand string
}
@@ -30,6 +31,7 @@ type linuxUserspaceHandle struct {
config ChildSAConfig
tunnel *espTunnel
tun *os.File
tunFD int
relay NATTPacketRelay
runContext context.Context
@@ -93,6 +95,7 @@ func (installer linuxUserspaceInstaller) Install(
config: cloneChildSAConfig(config),
tunnel: tunnel,
tun: tun,
tunFD: int(tun.Fd()),
relay: config.Relay,
runContext: runContext,
cancel: cancel,
@@ -128,6 +131,15 @@ func openLinuxTUN(name string) (*os.File, string, error) {
_ = unix.Close(descriptor)
return nil, "", fmt.Errorf("ike: create TUN interface: %w", err)
}
// A blocking TUN read is not guaranteed to wake when another goroutine
// closes the descriptor on Linux. Keep the descriptor non-blocking and use
// poll below so cancellation can always drain the data-plane workers before
// the interface is released. Without this, a failed session can retain the
// TUN forever and every automatic reconnect fails with EBUSY.
if err := unix.SetNonblock(descriptor, true); err != nil {
_ = unix.Close(descriptor)
return nil, "", fmt.Errorf("ike: make TUN interface cancellable: %w", err)
}
file := os.NewFile(uintptr(descriptor), "/dev/net/tun:"+request.Name())
if file == nil {
_ = unix.Close(descriptor)
@@ -475,7 +487,7 @@ func (handle *linuxUserspaceHandle) copyTUNToRelay() {
defer handle.wait.Done()
buffer := make([]byte, 65535)
for {
count, err := handle.tun.Read(buffer)
count, err := readTUNPacket(handle.runContext, handle.tunFD, buffer)
if err != nil {
if handle.runContext.Err() == nil && !errors.Is(err, os.ErrClosed) {
handle.fail(fmt.Errorf("ike: read TUN packet: %w", err))
@@ -520,7 +532,7 @@ func (handle *linuxUserspaceHandle) copyRelayToTUN() {
// without allowing a forged datagram to tear down the CHILD_SA.
continue
}
if err := writeFull(handle.tun, cleartext); err != nil {
if err := writeTUNPacket(handle.runContext, handle.tunFD, cleartext); err != nil {
if handle.runContext.Err() == nil && !errors.Is(err, os.ErrClosed) {
handle.fail(fmt.Errorf("ike: write TUN packet: %w", err))
}
@@ -529,17 +541,74 @@ func (handle *linuxUserspaceHandle) copyRelayToTUN() {
}
}
func writeFull(destination io.Writer, packet []byte) error {
count, err := destination.Write(packet)
if err != nil {
return err
func readTUNPacket(ctx context.Context, descriptor int, buffer []byte) (int, error) {
for {
if err := ctx.Err(); err != nil {
return 0, err
}
ready, err := pollTUN(ctx, descriptor, unix.POLLIN)
if err != nil {
return 0, err
}
if !ready {
continue
}
count, err := unix.Read(descriptor, buffer)
if errors.Is(err, unix.EINTR) || errors.Is(err, unix.EAGAIN) || errors.Is(err, unix.EWOULDBLOCK) {
continue
}
return count, err
}
if count != len(packet) {
return io.ErrShortWrite
}
func writeTUNPacket(ctx context.Context, descriptor int, packet []byte) error {
for written := 0; written < len(packet); {
if err := ctx.Err(); err != nil {
return err
}
ready, err := pollTUN(ctx, descriptor, unix.POLLOUT)
if err != nil {
return err
}
if !ready {
continue
}
count, err := unix.Write(descriptor, packet[written:])
if errors.Is(err, unix.EINTR) || errors.Is(err, unix.EAGAIN) || errors.Is(err, unix.EWOULDBLOCK) {
continue
}
if err != nil {
return err
}
if count == 0 {
return errors.New("ike: zero-length TUN write")
}
written += count
}
return nil
}
func pollTUN(ctx context.Context, descriptor int, events int16) (bool, error) {
if err := ctx.Err(); err != nil {
return false, err
}
poll := []unix.PollFd{{Fd: int32(descriptor), Events: events}}
count, err := unix.Poll(poll, int(userspaceTunnelPollInterval/time.Millisecond))
if errors.Is(err, unix.EINTR) {
return false, nil
}
if err != nil {
return false, err
}
if count == 0 {
return false, nil
}
if poll[0].Revents&(unix.POLLERR|unix.POLLHUP|unix.POLLNVAL) != 0 {
return false, os.ErrClosed
}
return poll[0].Revents&events != 0, nil
}
func (handle *linuxUserspaceHandle) fail(err error) {
handle.mu.Lock()
notify := false
@@ -583,9 +652,12 @@ func (handle *linuxUserspaceHandle) Close(ctx context.Context) error {
handle.mu.Unlock()
handle.cancelRun()
// Workers use a non-blocking, polled TUN descriptor and therefore leave on
// cancellation without requiring a cross-goroutine close. Wait first so no
// blocked syscall can retain the interface after Close returns.
handle.wait.Wait()
cleanupErr := handle.cleanupNetwork(ctx)
handle.closeTUN()
handle.wait.Wait()
// A terminal data-plane error is delivered exactly once through Failures.
// Close reports only teardown errors so the orchestrator does not record
// the same runtime cause again as a cleanup failure.
+17
View File
@@ -156,6 +156,10 @@ func (session *Session) watchOutgoingCall(call *imsCall, key sipTransactionKey)
case <-session.refreshContext.Done():
return
case <-timer.C:
if session.callWasTerminated(call.callID) {
session.finishCall(call.callID, "ended", 0, "")
return
}
session.finishCall(call.callID, "failed", 0, "SIP INVITE transaction timed out")
return
case response := <-call.responses:
@@ -193,6 +197,11 @@ func (session *Session) watchOutgoingCall(call *imsCall, key sipTransactionKey)
}
session.setCallMediaReady(call.callID)
session.setCallState(call.callID, "active")
} else if session.callWasTerminated(call.callID) {
// CANCEL normally causes the pending INVITE transaction to finish
// with 487 Request Terminated. It is the expected response to our
// local hang-up, not a new network rejection.
session.finishCall(call.callID, "ended", response.StatusCode, response.Reason)
} else {
session.finishCall(call.callID, "failed", response.StatusCode, response.Reason)
}
@@ -241,6 +250,7 @@ func (session *Session) HangupCall(ctx context.Context, id string) error {
state := call.public.State
direction := call.public.Direction
request, respond := call.invite, call.respond
call.terminated = true
session.callMu.Unlock()
if direction == "incoming" && state == "ringing" && request != nil && respond != nil {
response, err := buildSIPResponseWithBody(request, 486, session.fromTag, nil)
@@ -264,6 +274,13 @@ func (session *Session) HangupCall(ctx context.Context, id string) error {
return err
}
func (session *Session) callWasTerminated(id string) bool {
session.callMu.Lock()
defer session.callMu.Unlock()
call := session.calls[id]
return call != nil && call.terminated
}
func (session *Session) handleCallRequest(request *sipRequest, respond func([]byte) error) bool {
switch request.Method {
case "INVITE":
+31
View File
@@ -4,6 +4,7 @@ import (
"context"
"strings"
"testing"
"time"
"vocat/internal/vowifi"
)
@@ -80,6 +81,36 @@ func TestRejectedOutgoingCallRetainsSIPReason(t *testing.T) {
}
}
func TestCancelledOutgoingInviteDoesNotBecomeFailedOn487(t *testing.T) {
call := &imsCall{
public: vowifi.Call{ID: "cancelled", State: "dialing"},
callID: "cancelled",
responses: make(chan *sipResponse, 1),
terminated: true,
}
session := &Session{
calls: map[string]*imsCall{call.callID: call},
transactions: make(map[sipTransactionKey]chan *sipResponse),
refreshContext: context.Background(),
}
key := sipTransactionKey{callID: call.callID, cseq: 1, method: "INVITE"}
go session.watchOutgoingCall(call, key)
call.responses <- &sipResponse{StatusCode: 487, Reason: "Request Terminated"}
deadline := time.Now().Add(time.Second)
for time.Now().Before(deadline) {
calls := session.Calls()
if len(calls) == 1 && calls[0].EndedAt != nil {
if calls[0].State != "ended" || calls[0].SIPCode != 487 {
t.Fatalf("cancelled INVITE = %#v", calls[0])
}
return
}
time.Sleep(time.Millisecond)
}
t.Fatal("cancelled INVITE did not reach a terminal state")
}
func TestValidCallNumber(t *testing.T) {
if !validCallNumber("+447700900000") || validCallNumber("12\r\nBYE") {
t.Fatal("call number validation mismatch")
+22 -20
View File
@@ -24,13 +24,13 @@ type digestChallenge struct {
}
type digestCredentials struct {
Username string
Password []byte
AUTS string
URI string
Method string
CNonce string
NC uint32
Username string
AKAResponse []byte
AUTS string
URI string
Method string
CNonce string
NC uint32
}
func parseDigestChallenge(value string, proxy bool) (digestChallenge, error) {
@@ -146,7 +146,7 @@ func parseAuthDirectives(value string) (map[string]string, error) {
}
type akaMaterial struct {
password []byte
response []byte
auts []byte
ck []byte
ik []byte
@@ -156,7 +156,7 @@ func clearAKAMaterial(material *akaMaterial) {
if material == nil {
return
}
zeroBytes(material.password)
zeroBytes(material.response)
zeroBytes(material.auts)
zeroBytes(material.ck)
zeroBytes(material.ik)
@@ -193,7 +193,7 @@ func authenticateAKA(
return akaMaterial{}, err
}
return akaMaterial{
password: res,
response: res,
ck: append([]byte(nil), result.CK...),
ik: append([]byte(nil), result.IK...),
}, nil
@@ -231,7 +231,7 @@ func extractRES(result vowifi.AKAResult) ([]byte, error) {
func newDigestCredentials(
username string,
password []byte,
akaResponse []byte,
uri string,
method string,
nc uint32,
@@ -241,12 +241,12 @@ func newDigestCredentials(
return digestCredentials{}, fmt.Errorf("ims: create digest cnonce: %w", err)
}
return digestCredentials{
Username: username,
Password: password,
URI: uri,
Method: method,
CNonce: hex.EncodeToString(cnonceBytes),
NC: nc,
Username: username,
AKAResponse: akaResponse,
URI: uri,
Method: method,
CNonce: hex.EncodeToString(cnonceBytes),
NC: nc,
}, nil
}
@@ -255,7 +255,7 @@ func buildDigestAuthorization(challenge digestChallenge, credentials digestCrede
response := digestResponse(
credentials.Username,
challenge.Realm,
credentials.Password,
credentials.AKAResponse,
credentials.Method,
credentials.URI,
challenge.Nonce,
@@ -290,7 +290,7 @@ func buildDigestAuthorization(challenge digestChallenge, credentials digestCrede
func digestResponse(
username string,
realm string,
password []byte,
akaResponse []byte,
method string,
uri string,
nonce string,
@@ -300,7 +300,9 @@ func digestResponse(
) string {
ha1Hash := md5.New()
_, _ = ha1Hash.Write([]byte(username + ":" + realm + ":"))
_, _ = ha1Hash.Write(password)
// AKAv1-MD5 is mandated by the IMS server challenge (3GPP TS 33.203).
// akaResponse is the short-lived USIM RES value, not a stored password.
_, _ = ha1Hash.Write(akaResponse)
ha1 := hex.EncodeToString(ha1Hash.Sum(nil))
ha2 := md5Hex(method + ":" + uri)
if qop == "" {
+11 -11
View File
@@ -64,8 +64,8 @@ func TestAuthenticateAKAMapsNonceToTypedChallenge(t *testing.T) {
if err != nil {
t.Fatalf("authenticateAKA() error = %v", err)
}
if !reflect.DeepEqual(material.password, []byte{0xde, 0xad, 0xbe, 0xef}) {
t.Fatalf("password = %x, want deadbeef", material.password)
if !reflect.DeepEqual(material.response, []byte{0xde, 0xad, 0xbe, 0xef}) {
t.Fatalf("response = %x, want deadbeef", material.response)
}
if len(aka.challenges) != 1 {
t.Fatalf("challenge count = %d, want 1", len(aka.challenges))
@@ -97,12 +97,12 @@ func TestAuthenticateAKAReturnsSynchronizationEvidence(t *testing.T) {
if err != nil {
t.Fatalf("authenticateAKA() error = %v", err)
}
if !reflect.DeepEqual(material.auts, auts) || len(material.password) != 0 {
if !reflect.DeepEqual(material.auts, auts) || len(material.response) != 0 {
t.Fatalf("material = %#v", material)
}
}
func TestBuildDigestAuthorizationCarriesAUTSWithEmptyPassword(t *testing.T) {
func TestBuildDigestAuthorizationCarriesAUTSWithEmptyResponse(t *testing.T) {
authorization := buildDigestAuthorization(
digestChallenge{
Realm: "ims.example",
@@ -111,13 +111,13 @@ func TestBuildDigestAuthorizationCarriesAUTSWithEmptyPassword(t *testing.T) {
QOP: "auth",
},
digestCredentials{
Username: "[email protected]",
Password: nil,
AUTS: "AAECAwQFBgcICQoLDA0=",
URI: "sip:ims.example",
Method: "REGISTER",
CNonce: "cnonce",
NC: 1,
Username: "[email protected]",
AKAResponse: nil,
AUTS: "AAECAwQFBgcICQoLDA0=",
URI: "sip:ims.example",
Method: "REGISTER",
CNonce: "cnonce",
NC: 1,
},
)
directives, err := parseAuthDirectives(strings.TrimPrefix(authorization, "Digest "))
+12 -12
View File
@@ -405,7 +405,7 @@ func dialSIP(
type authenticationState struct {
challenge digestChallenge
password []byte
response []byte
auts string
cnonce string
nc uint32
@@ -633,13 +633,13 @@ func (session *Session) register(ctx context.Context, expires int) (*sipResponse
if session.auth != nil {
session.auth.nc++
credentials := digestCredentials{
Username: session.identity.private,
Password: session.auth.password,
AUTS: session.auth.auts,
URI: "sip:" + session.identity.domain,
Method: "REGISTER",
CNonce: session.auth.cnonce,
NC: session.auth.nc,
Username: session.identity.private,
AKAResponse: session.auth.response,
AUTS: session.auth.auts,
URI: "sip:" + session.identity.domain,
Method: "REGISTER",
CNonce: session.auth.cnonce,
NC: session.auth.nc,
}
authorization = buildDigestAuthorization(session.auth.challenge, credentials)
if session.auth.challenge.Proxy {
@@ -686,7 +686,7 @@ func (session *Session) register(ctx context.Context, expires int) (*sipResponse
}
credentials, err := newDigestCredentials(
session.identity.private,
material.password,
material.response,
"sip:"+session.identity.domain,
"REGISTER",
1,
@@ -698,7 +698,7 @@ func (session *Session) register(ctx context.Context, expires int) (*sipResponse
auts := base64.StdEncoding.EncodeToString(material.auts)
session.auth = &authenticationState{
challenge: challenge,
password: append([]byte(nil), material.password...),
response: append([]byte(nil), material.response...),
auts: auts,
cnonce: credentials.CNonce,
}
@@ -1021,8 +1021,8 @@ func (session *Session) clearAuthentication() {
if session.auth == nil {
return
}
for index := range session.auth.password {
session.auth.password[index] = 0
for index := range session.auth.response {
session.auth.response[index] = 0
}
session.auth = nil
}
+42 -18
View File
@@ -63,33 +63,57 @@ func (mapper ATMapper) resolve(
if mapper.Store == nil || mapper.Devices == nil {
return "", errors.New("vowifi AT mapper is not configured")
}
if entry, err := mapper.Devices.Get(configuredID); err == nil && entry.Discovered {
return entry.ID, nil
}
config, err := mapper.Store.Device(ctx, configuredID)
if err != nil {
return "", err
}
// Score every physical candidate before choosing one. Returning the first
// partial match made two EC20s on the same hub vulnerable to iteration order:
// a stale USB path on one configured row could win before the other
// candidate's AT port, QMI node, or live IMEI was considered. The result was
// two logical devices issuing APDUs to the same SIM.
bestID := ""
bestScore := 0
for _, entry := range mapper.Devices.List() {
if !entry.Discovered {
continue
}
candidate := entry.Candidate
switch {
case config.ATPort != "" &&
(config.ATPort == candidate.ATPort.Path ||
config.ATPort == candidate.ATPort.OpenPath()):
return entry.ID, nil
case config.USBPath != "" && config.USBPath == candidate.USBPath:
return entry.ID, nil
case config.ControlDevice != "" &&
(config.ControlDevice == candidate.QMIControl ||
config.ControlDevice == candidate.ATPort.OpenPath()):
return entry.ID, nil
case config.ModemIMEI != "" && entry.Snapshot != nil &&
config.ModemIMEI == strings.TrimSpace(entry.Snapshot.IMEI):
return entry.ID, nil
score := physicalMatchScore(configuredID, config, entry)
if score > bestScore {
bestID = entry.ID
bestScore = score
}
}
if bestID != "" {
return bestID, nil
}
return "", device.ErrNotFound
}
func physicalMatchScore(configuredID string, config store.Device, entry device.Device) int {
candidate := entry.Candidate
score := 0
// A live modem identity is the strongest evidence and must override stale
// Linux node names or a USB topology saved before devices were rearranged.
if config.ModemIMEI != "" && entry.Snapshot != nil &&
strings.EqualFold(strings.TrimSpace(config.ModemIMEI), strings.TrimSpace(entry.Snapshot.IMEI)) {
score += 10000
}
if config.ATPort != "" &&
(config.ATPort == candidate.ATPort.Path || config.ATPort == candidate.ATPort.OpenPath()) {
score += 300
}
if config.ControlDevice != "" &&
(config.ControlDevice == candidate.QMIControl || config.ControlDevice == candidate.ATPort.OpenPath()) {
score += 300
}
if config.USBPath != "" && config.USBPath == candidate.USBPath {
score += 100
}
// Discovery IDs are not persistent user IDs. Treat an exact text match only
// as a weak hint so it cannot override physical identity evidence.
if entry.ID == configuredID {
score += 25
}
return score
}
+85
View File
@@ -87,3 +87,88 @@ func TestATMapperResolvesConfiguredIDByStableATPath(t *testing.T) {
t.Fatalf("ExecuteSensitiveAT physical ID = %q", devices.sensitiveID)
}
}
func TestATMapperScoresAllCandidatesBeforeUsingStaleUSBPath(t *testing.T) {
database := testStore(t)
if err := database.UpsertDevice(context.Background(), store.Device{
ID: "ec20_1",
Name: "EC20 1",
ATPort: "/dev/ttyUSB2",
ControlDevice: "/dev/cdc-wdm0",
// Simulate metadata left from a formerly swapped hub mapping.
USBPath: "/sys/bus/usb/devices/1-6",
ModemIMEI: "111111111111111",
}); err != nil {
t.Fatal(err)
}
devices := &fakeATDevices{entries: []device.Device{
{
ID: "quectel-0125-1-6",
Discovered: true,
Candidate: modem.Candidate{
USBPath: "/sys/bus/usb/devices/1-6",
QMIControl: "/dev/cdc-wdm1",
ATPort: modem.Port{Path: "/dev/ttyUSB6"},
},
},
{
ID: "quectel-0306-1-5",
Discovered: true,
Candidate: modem.Candidate{
USBPath: "/sys/bus/usb/devices/1-5",
QMIControl: "/dev/cdc-wdm0",
ATPort: modem.Port{Path: "/dev/ttyUSB2"},
},
},
}}
mapper := ATMapper{Store: database, Devices: devices}
if _, err := mapper.ExecuteAT(context.Background(), "ec20_1", "AT+CIMI"); err != nil {
t.Fatal(err)
}
if devices.executedID != "quectel-0306-1-5" {
t.Fatalf("ExecuteAT physical ID = %q, want coherent AT/QMI candidate", devices.executedID)
}
}
func TestATMapperPrefersLiveIMEIOverAllStalePaths(t *testing.T) {
database := testStore(t)
if err := database.UpsertDevice(context.Background(), store.Device{
ID: "ec20_1",
Name: "EC20 1",
ATPort: "/dev/ttyUSB2",
ControlDevice: "/dev/cdc-wdm0",
USBPath: "/sys/bus/usb/devices/1-5",
ModemIMEI: "222222222222222",
}); err != nil {
t.Fatal(err)
}
devices := &fakeATDevices{entries: []device.Device{
{
ID: "old-paths",
Discovered: true,
Candidate: modem.Candidate{
USBPath: "/sys/bus/usb/devices/1-5",
QMIControl: "/dev/cdc-wdm0",
ATPort: modem.Port{Path: "/dev/ttyUSB2"},
},
Snapshot: &device.Snapshot{IMEI: "111111111111111"},
},
{
ID: "live-imei",
Discovered: true,
Candidate: modem.Candidate{
USBPath: "/sys/bus/usb/devices/2-3",
QMIControl: "/dev/cdc-wdm4",
ATPort: modem.Port{Path: "/dev/ttyUSB10"},
},
Snapshot: &device.Snapshot{IMEI: "222222222222222"},
},
}}
mapper := ATMapper{Store: database, Devices: devices}
if _, err := mapper.ExecuteAT(context.Background(), "ec20_1", "AT+CIMI"); err != nil {
t.Fatal(err)
}
if devices.executedID != "live-imei" {
t.Fatalf("ExecuteAT physical ID = %q, want live IMEI candidate", devices.executedID)
}
}
+4 -3
View File
@@ -27,15 +27,16 @@ func (resolver ProxyResolver) Resolve(
return vowifi.ProxyRoute{}, errors.New("vowifi proxy resolver: store is nil")
}
deviceID := strings.TrimSpace(request.DeviceID)
if deviceID == "" {
iccid := strings.TrimSpace(request.ICCID)
if deviceID == "" || iccid == "" {
return vowifi.ProxyRoute{Mode: vowifi.ProxyModeDirect}, nil
}
binding, err := resolver.Store.DeviceProxyBinding(ctx, deviceID)
binding, err := resolver.Store.DeviceProxyBinding(ctx, iccid)
if errors.Is(err, store.ErrNotFound) {
return vowifi.ProxyRoute{Mode: vowifi.ProxyModeDirect}, nil
}
if err != nil {
return vowifi.ProxyRoute{}, fmt.Errorf("resolve proxy binding for device %s: %w", deviceID, err)
return vowifi.ProxyRoute{}, fmt.Errorf("resolve proxy binding for ICCID %s: %w", iccid, err)
}
upstream, err := resolver.Store.UpstreamProxy(ctx, binding.UpstreamProxyID)
if err != nil {
+24 -2
View File
@@ -21,7 +21,7 @@ func testStore(t *testing.T) *store.Store {
return database
}
func TestProxyResolverUsesDeviceBinding(t *testing.T) {
func TestProxyResolverUsesICCIDProfileBinding(t *testing.T) {
database := testStore(t)
if err := database.UpsertDevice(context.Background(), store.Device{ID: "ec20", Name: "EC20"}); err != nil {
t.Fatal(err)
@@ -38,13 +38,15 @@ func TestProxyResolverUsesDeviceBinding(t *testing.T) {
}
if err := database.UpsertDeviceProxyBinding(context.Background(), store.DeviceProxyBinding{
DeviceID: "ec20",
ICCID: "89441000400128014257",
ProfileName: "Vodafone UK",
UpstreamProxyID: "clash",
}); err != nil {
t.Fatal(err)
}
route, err := (ProxyResolver{Store: database}).Resolve(
context.Background(),
vowifi.ProxyRequest{DeviceID: "ec20", HomeMCC: "234", HomeMNC: "15"},
vowifi.ProxyRequest{DeviceID: "ec20", ICCID: "89441000400128014257", HomeMCC: "234", HomeMNC: "15"},
)
if err != nil {
t.Fatal(err)
@@ -57,6 +59,26 @@ func TestProxyResolverUsesDeviceBinding(t *testing.T) {
}
}
func TestProxyResolverDoesNotLeakBindingToAnotherProfileOnSameDevice(t *testing.T) {
database := testStore(t)
if err := database.UpsertDevice(context.Background(), store.Device{ID: "ec20", Name: "EC20"}); err != nil {
t.Fatal(err)
}
if err := database.UpsertUpstreamProxy(context.Background(), store.UpstreamProxy{ID: "proxy", Name: "Proxy", Addr: "127.0.0.1:1080", Enabled: true}); err != nil {
t.Fatal(err)
}
if err := database.UpsertDeviceProxyBinding(context.Background(), store.DeviceProxyBinding{DeviceID: "ec20", ICCID: "89441000400128014257", ProfileName: "A", UpstreamProxyID: "proxy"}); err != nil {
t.Fatal(err)
}
route, err := (ProxyResolver{Store: database}).Resolve(context.Background(), vowifi.ProxyRequest{DeviceID: "ec20", ICCID: "89104100000028106378"})
if err != nil {
t.Fatal(err)
}
if route.Mode != vowifi.ProxyModeDirect {
t.Fatalf("route = %#v, want direct for unbound ICCID", route)
}
}
func TestProxyResolverDoesNotUseCountryRuleWithoutDeviceBinding(t *testing.T) {
database := testStore(t)
if err := database.UpsertUpstreamProxy(context.Background(), store.UpstreamProxy{
+93 -43
View File
@@ -106,8 +106,8 @@ func (orchestrator *Orchestrator) Subscribe(buffer int) (<-chan State, func()) {
}
// Enable executes one evidence-backed transaction. The order intentionally
// follows the working Linux/QMI path: live identity and home PLMN, AKA
// availability, ePDG derivation, runtime-owned RF off, cellular-data stop,
// follows the working Linux/QMI path: snapshot and disable cellular RF first,
// then read the live identity/home PLMN, verify AKA availability, derive ePDG,
// country proxy resolution, SWu tunnel, IMS registration, and SMS readiness.
func (orchestrator *Orchestrator) Enable(ctx context.Context) (State, error) {
if ctx == nil {
@@ -143,20 +143,37 @@ func (orchestrator *Orchestrator) Enable(ctx context.Context) (State, error) {
}
})
// A failed attempt deliberately retains the original radio checkpoint and
// keeps CFUN=4. Automatic retries must rebuild only the Wi-Fi/IKE/IMS layers;
// restoring CFUN=1 between attempts can briefly register on a visited network
// and trigger roaming/welcome SMS messages. Explicit Disable is the only path
// that restores the pre-VoWiFi radio mode.
orchestrator.mu.Lock()
retained := orchestrator.resources
orchestrator.mu.Unlock()
runtimeContext, runtimeCancel := context.WithCancel(context.Background())
resources := &runtimeResources{cancel: runtimeCancel}
if current.Phase == PhaseFailed && retained != nil && retained.radioChanged {
resources.radio = retained.radio
resources.radioChanged = true
}
orchestrator.mu.Lock()
orchestrator.resources = resources
orchestrator.mu.Unlock()
setupContext, stopSetup := mergedContext(ctx, runtimeContext)
defer stopSetup()
var err error
fail := func(stage Phase, cause error) (State, error) {
runtimeCancel()
cleanupErrors := orchestrator.cleanup(resources)
cleanupErrors := orchestrator.cleanupSessions(resources)
orchestrator.mu.Lock()
orchestrator.resources = nil
if resources.radioChanged {
orchestrator.resources = resources
} else {
orchestrator.resources = nil
}
orchestrator.mu.Unlock()
orchestrator.mutate(func(state *State) {
@@ -181,6 +198,28 @@ func (orchestrator *Orchestrator) Enable(ctx context.Context) (State, error) {
return orchestrator.State(), stageError
}
if !resources.radioChanged {
resources.radio, err = orchestrator.deps.Radio.Snapshot(setupContext, orchestrator.options.DeviceID)
if err != nil {
return fail(PhaseAccessReady, err)
}
orchestrator.mutate(func(state *State) {
state.PureAirplanePolicy = resources.radio.PureAirplanePolicy
})
// Mark the transaction before the mutating call: a provider may return
// an error after partially changing the modem.
resources.radioChanged = true
}
// RF-off is established before any SIM/AKA probing. Those operations are
// local UICC APDUs and remain available in CFUN=4; no serving-cell attach is
// required or permitted during VoWiFi setup.
if err := orchestrator.deps.Radio.EnterVoWiFiRFOff(setupContext, orchestrator.options.DeviceID); err != nil {
return fail(PhaseAccessReady, err)
}
if err := orchestrator.deps.Radio.StopCellularData(setupContext, orchestrator.options.DeviceID); err != nil {
return fail(PhaseAccessReady, err)
}
identity, err := orchestrator.deps.SIM.ReadIdentity(setupContext, orchestrator.options.DeviceID)
if err != nil {
return fail(PhaseSIMReady, err)
@@ -216,31 +255,9 @@ func (orchestrator *Orchestrator) Enable(ctx context.Context) (State, error) {
if err != nil {
return fail(PhaseAccessReady, err)
}
resources.radio, err = orchestrator.deps.Radio.Snapshot(setupContext, orchestrator.options.DeviceID)
if err != nil {
return fail(PhaseAccessReady, err)
}
orchestrator.mutate(func(state *State) {
state.PureAirplanePolicy = resources.radio.PureAirplanePolicy
})
// Mark the radio transaction before the first mutating call: a provider
// may return an error after partially changing the modem.
resources.radioChanged = true
// Enter RF-off before reconciling PDP contexts. Some QMI-capable EC20
// firmware automatically owns CID 1 while CFUN=1 and rejects a direct
// CGACT=0 command even though the Linux data interface is down. CFUN=4
// tears down packet service at the baseband; StopCellularData then acts as
// a fail-closed verification and removes any context that unexpectedly
// survived RF-off.
if err := orchestrator.deps.Radio.EnterVoWiFiRFOff(setupContext, orchestrator.options.DeviceID); err != nil {
return fail(PhaseAccessReady, err)
}
if err := orchestrator.deps.Radio.StopCellularData(setupContext, orchestrator.options.DeviceID); err != nil {
return fail(PhaseAccessReady, err)
}
proxy, err := orchestrator.deps.Proxy.Resolve(setupContext, ProxyRequest{
DeviceID: orchestrator.options.DeviceID,
ICCID: strings.TrimSpace(identity.ICCID),
HomeMCC: strings.TrimSpace(identity.HomeMCC),
HomeMNC: strings.TrimSpace(identity.HomeMNC),
CountryCode: strings.ToUpper(strings.TrimSpace(identity.HomeCountryCode)),
@@ -484,14 +501,32 @@ func (orchestrator *Orchestrator) Reconnect(ctx context.Context) (State, error)
if !current.Enabled && current.Phase == PhaseIdle {
return current, ErrNotRunning
}
// Teardown during a reconnect is best-effort. Disable already releases the
// local IMS, tunnel, and radio resources, so a non-fatal cleanup error
// (e.g. the network rejecting SIP deregistration) must not block the
// rebuild — otherwise the device wedges in PhaseFailed. Only propagate
// errors that prevented the teardown itself (e.g. the operation lock).
if _, err := orchestrator.Disable(ctx); err != nil && !errors.Is(err, ErrCleanupIncomplete) {
if ctx == nil {
ctx = context.Background()
}
if err := orchestrator.lockOperation(ctx); err != nil {
return orchestrator.State(), err
}
orchestrator.mu.Lock()
resources := orchestrator.resources
orchestrator.mu.Unlock()
if resources != nil && resources.cancel != nil {
resources.cancel()
}
cleanupErrors := orchestrator.cleanupSessions(resources)
orchestrator.mutate(func(state *State) {
state.Phase = PhaseFailed
state.Enabled = true
state.Active = false
state.TunnelReady = false
state.IMSReady = false
state.SMSReady = false
state.LastReason = "reconnect_requested"
state.CleanupErrors = append([]string(nil), cleanupErrors...)
})
orchestrator.unlockOperation()
// Keep the radio checkpoint and CFUN=4 across a reconnect. Re-enabling RF
// for even a short window defeats airplane-first VoWiFi behavior.
return orchestrator.Enable(ctx)
}
@@ -679,6 +714,25 @@ func securityAuditFromEvidence(evidence TunnelEvidence) SecurityAudit {
}
func (orchestrator *Orchestrator) cleanup(resources *runtimeResources) []string {
if resources == nil {
return nil
}
cleanupErrors := orchestrator.cleanupSessions(resources)
if resources.radioChanged {
if err := orchestrator.cleanupCall(func(ctx context.Context) error {
return orchestrator.deps.Radio.Restore(ctx, orchestrator.options.DeviceID, resources.radio)
}); err != nil {
cleanupErrors = append(cleanupErrors, "restore radio: "+err.Error())
}
resources.radioChanged = false
}
return cleanupErrors
}
// cleanupSessions releases network-layer resources without restoring cellular
// RF. It is used while VoWiFi remains the desired policy, including failed
// automatic retries and manual reconnects.
func (orchestrator *Orchestrator) cleanupSessions(resources *runtimeResources) []string {
if resources == nil {
return nil
}
@@ -695,14 +749,6 @@ func (orchestrator *Orchestrator) cleanup(resources *runtimeResources) []string
}
resources.tunnel = nil
}
if resources.radioChanged {
if err := orchestrator.cleanupCall(func(ctx context.Context) error {
return orchestrator.deps.Radio.Restore(ctx, orchestrator.options.DeviceID, resources.radio)
}); err != nil {
cleanupErrors = append(cleanupErrors, "restore radio: "+err.Error())
}
resources.radioChanged = false
}
return cleanupErrors
}
@@ -802,10 +848,14 @@ func (orchestrator *Orchestrator) watchRuntimeFailure(
if !current {
return
}
cleanupErrors := orchestrator.cleanup(resources)
cleanupErrors := orchestrator.cleanupSessions(resources)
orchestrator.mu.Lock()
if orchestrator.resources == resources {
orchestrator.resources = nil
if resources.radioChanged {
orchestrator.resources = resources
} else {
orchestrator.resources = nil
}
}
orchestrator.mu.Unlock()
orchestrator.mutate(func(state *State) {
+60 -36
View File
@@ -366,11 +366,11 @@ func TestEnableUsesEvidenceBackedOrderAndDisableRollsBackInReverse(t *testing.T)
}
wantEnableCalls := []string{
"sim.identity",
"aka.ready",
"radio.snapshot",
"radio.rf_off",
"radio.stop_data",
"sim.identity",
"aka.ready",
"proxy.resolve",
"tunnel.start",
"tunnel.evidence",
@@ -422,26 +422,10 @@ func TestEnableFailuresCleanUpEveryAcquiredLayer(t *testing.T) {
{name: "identity", failCall: "sim.identity"},
{name: "aka", failCall: "aka.ready"},
{name: "radio snapshot", failCall: "radio.snapshot"},
{
name: "stop data can partially mutate",
failCall: "radio.stop_data",
wantCleanupTail: []string{"radio.restore"},
},
{
name: "rf off",
failCall: "radio.rf_off",
wantCleanupTail: []string{"radio.restore"},
},
{
name: "proxy",
failCall: "proxy.resolve",
wantCleanupTail: []string{"radio.restore"},
},
{
name: "tunnel start",
failCall: "tunnel.start",
wantCleanupTail: []string{"radio.restore"},
},
{name: "stop data can partially mutate", failCall: "radio.stop_data"},
{name: "rf off", failCall: "radio.rf_off"},
{name: "proxy", failCall: "proxy.resolve"},
{name: "tunnel start", failCall: "tunnel.start"},
{
name: "tunnel evidence",
mutate: func(environment *fakeEnvironment) {
@@ -449,12 +433,12 @@ func TestEnableFailuresCleanUpEveryAcquiredLayer(t *testing.T) {
environment.tunnelEvidence.ResponderAUTH = ResponderAUTHUnknown
},
wantError: ErrTunnelNotEstablished,
wantCleanupTail: []string{"tunnel.close", "radio.restore"},
wantCleanupTail: []string{"tunnel.close"},
},
{
name: "IMS start",
failCall: "ims.start",
wantCleanupTail: []string{"tunnel.close", "radio.restore"},
wantCleanupTail: []string{"tunnel.close"},
},
{
name: "IMS registration evidence",
@@ -462,12 +446,12 @@ func TestEnableFailuresCleanUpEveryAcquiredLayer(t *testing.T) {
environment.imsEvidence.Registered = false
},
wantError: ErrIMSNotRegistered,
wantCleanupTail: []string{"ims.close", "tunnel.close", "radio.restore"},
wantCleanupTail: []string{"ims.close", "tunnel.close"},
},
{
name: "SMS activation",
failCall: "ims.sms",
wantCleanupTail: []string{"ims.close", "tunnel.close", "radio.restore"},
wantCleanupTail: []string{"ims.close", "tunnel.close"},
},
{
name: "SMS evidence",
@@ -475,7 +459,7 @@ func TestEnableFailuresCleanUpEveryAcquiredLayer(t *testing.T) {
environment.smsEvidence.Ready = false
},
wantError: ErrSMSNotReady,
wantCleanupTail: []string{"ims.close", "tunnel.close", "radio.restore"},
wantCleanupTail: []string{"ims.close", "tunnel.close"},
},
}
@@ -501,6 +485,9 @@ func TestEnableFailuresCleanUpEveryAcquiredLayer(t *testing.T) {
state.TunnelReady || state.IMSReady || state.SMSReady {
t.Fatalf("failed state = %+v", state)
}
if environment.callCount("radio.restore") != 0 {
t.Fatalf("failed VoWiFi attempt re-enabled cellular RF: %#v", environment.callsSnapshot())
}
if len(test.wantCleanupTail) > 0 {
calls := environment.callsSnapshot()
if len(calls) < len(test.wantCleanupTail) {
@@ -701,6 +688,34 @@ func TestRetryAfterFailureCreatesANewAttempt(t *testing.T) {
if environment.callCount("tunnel.start") != 2 {
t.Fatalf("tunnel.start count = %d", environment.callCount("tunnel.start"))
}
if environment.callCount("radio.snapshot") != 1 || environment.callCount("radio.restore") != 0 {
t.Fatalf("retry must retain RF-off checkpoint: %#v", environment.callsSnapshot())
}
}
func TestFailedEnableRestoresRadioOnlyOnExplicitDisable(t *testing.T) {
environment := newFakeEnvironment()
environment.setFailure("tunnel.start", 1)
orchestrator := newTestOrchestrator(t, environment, false)
state, err := orchestrator.Enable(context.Background())
if err == nil || state.Phase != PhaseFailed || !state.Enabled {
t.Fatalf("Enable() = (%+v, %v)", state, err)
}
if environment.callCount("radio.restore") != 0 {
t.Fatalf("failed enable restored cellular RF: %#v", environment.callsSnapshot())
}
state, err = orchestrator.Disable(context.Background())
if err != nil {
t.Fatalf("Disable() error = %v", err)
}
if state.Phase != PhaseIdle || state.Enabled {
t.Fatalf("Disable() state = %+v", state)
}
if environment.callCount("radio.restore") != 1 {
t.Fatalf("explicit disable did not restore cellular RF: %#v", environment.callsSnapshot())
}
}
func TestReconnectClosesThenRebuildsTheRuntime(t *testing.T) {
@@ -719,7 +734,8 @@ func TestReconnectClosesThenRebuildsTheRuntime(t *testing.T) {
}
if environment.callCount("tunnel.start") != 2 ||
environment.callCount("tunnel.close") != 1 ||
environment.callCount("radio.restore") != 1 {
environment.callCount("radio.restore") != 0 ||
environment.callCount("radio.snapshot") != 1 {
t.Fatalf("calls = %#v", environment.callsSnapshot())
}
}
@@ -745,7 +761,8 @@ func TestReconnectToleratesCleanupFailureAndRebuilds(t *testing.T) {
}
if environment.callCount("ims.close") != 1 ||
environment.callCount("tunnel.close") != 1 ||
environment.callCount("radio.restore") != 1 ||
environment.callCount("radio.restore") != 0 ||
environment.callCount("radio.snapshot") != 1 ||
environment.callCount("tunnel.start") != 2 {
t.Fatalf("calls = %#v", environment.callsSnapshot())
}
@@ -781,7 +798,7 @@ func TestRuntimeTunnelFailureRevokesReadinessAndCleansEveryLayer(t *testing.T) {
}
calls := environment.callsSnapshot()
wantTail := []string{"ims.close", "tunnel.close", "radio.restore"}
wantTail := []string{"ims.close", "tunnel.close"}
if len(calls) < len(wantTail) ||
!reflect.DeepEqual(calls[len(calls)-len(wantTail):], wantTail) {
t.Fatalf("runtime failure cleanup tail = %#v", calls)
@@ -853,7 +870,7 @@ func TestSubscriptionPublishesOrderedEvidencePhases(t *testing.T) {
}
}
func TestCleanupAttemptsEveryLayerAndReportsAllErrors(t *testing.T) {
func TestFailedAttemptKeepsRadioOffUntilExplicitDisable(t *testing.T) {
environment := newFakeEnvironment()
environment.setFailure("ims.sms", 1)
environment.setFailure("ims.close", 1)
@@ -865,19 +882,26 @@ func TestCleanupAttemptsEveryLayerAndReportsAllErrors(t *testing.T) {
if err == nil {
t.Fatal("Enable() unexpectedly succeeded")
}
if len(state.CleanupErrors) != 3 {
if len(state.CleanupErrors) != 2 {
t.Fatalf("cleanup errors = %#v", state.CleanupErrors)
}
calls := environment.callsSnapshot()
wantTail := []string{"ims.close", "tunnel.close", "radio.restore"}
if !reflect.DeepEqual(calls[len(calls)-3:], wantTail) {
t.Fatalf("cleanup tail = %#v", calls[len(calls)-3:])
wantTail := []string{"ims.close", "tunnel.close"}
if !reflect.DeepEqual(calls[len(calls)-2:], wantTail) {
t.Fatalf("cleanup tail = %#v", calls[len(calls)-2:])
}
for _, text := range []string{"close IMS", "close tunnel", "restore radio"} {
for _, text := range []string{"close IMS", "close tunnel"} {
if !strings.Contains(err.Error(), text) {
t.Fatalf("error %q does not contain %q", err, text)
}
}
if environment.callCount("radio.restore") != 0 {
t.Fatalf("failed attempt restored RF unexpectedly: %#v", calls)
}
if _, disableErr := orchestrator.Disable(context.Background()); disableErr == nil ||
!strings.Contains(disableErr.Error(), "restore radio") {
t.Fatalf("Disable() error = %v, want retained radio restore failure", disableErr)
}
}
func TestDisableCleanupWarningStillSettlesIdle(t *testing.T) {
+39 -3
View File
@@ -211,12 +211,26 @@ func (manager *Manager) RequestEnabled(deviceID string, enabled bool) (vowifi.St
manager.mu.Lock()
item := manager.entries[deviceID]
item.desiredEnabled = enabled
if !enabled && item.busy {
item.disablePending = true
if item.busy {
manager.logger.Info(
"VoWiFi desired state updated while lifecycle operation is active",
"device_id", deviceID,
"enabled", enabled,
)
// The switch is a desired-state control, not a one-shot command. A user
// can change it again while a slow IKE/IMS transaction is still winding
// down. Accept the newest value and let runOperations reconcile the
// runtime after the current operation completes. Returning busy here used
// to let the database and runtime diverge (configured on, runtime idle).
if enabled {
item.disablePending = false
} else {
item.disablePending = true
}
cancel := item.operationCancel
state := item.orchestrator.State()
manager.mu.Unlock()
if cancel != nil {
if !enabled && cancel != nil {
cancel()
}
return state, nil
@@ -366,6 +380,7 @@ func (manager *Manager) startOperation(
manager.wg.Add(1)
manager.mu.Unlock()
manager.logger.Debug("VoWiFi lifecycle operation queued", "device_id", deviceID)
go manager.runOperations(deviceID, item, operation)
return item.orchestrator.State(), nil
}
@@ -376,6 +391,7 @@ func (manager *Manager) runOperations(
operation func(context.Context, *vowifi.Orchestrator) error,
) {
defer manager.wg.Done()
manager.logger.Debug("VoWiFi lifecycle worker started", "device_id", deviceID)
for {
ctx, cancel := context.WithTimeout(manager.ctx, manager.operationTimeout)
manager.mu.Lock()
@@ -389,6 +405,7 @@ func (manager *Manager) runOperations(
}
item.operationCancel = cancel
manager.mu.Unlock()
manager.logger.Debug("VoWiFi lifecycle operation executing", "device_id", deviceID)
err := operation(ctx, item.orchestrator)
cancel()
if err != nil &&
@@ -431,6 +448,25 @@ func (manager *Manager) runOperations(
}
continue
}
// Reconcile a switch change that arrived while the previous lifecycle
// operation was busy. Keep using the same worker so enable/disable can
// never overlap on the modem or tunnel resources.
if item.desiredEnabled && !state.Enabled {
manager.mu.Unlock()
operation = func(ctx context.Context, orchestrator *vowifi.Orchestrator) error {
_, err := orchestrator.Enable(ctx)
return err
}
continue
}
if !item.desiredEnabled && state.Enabled {
manager.mu.Unlock()
operation = func(ctx context.Context, orchestrator *vowifi.Orchestrator) error {
_, err := orchestrator.Disable(ctx)
return err
}
continue
}
item.busy = false
shouldRetry := item.desiredEnabled && state.Phase == vowifi.PhaseFailed
if !shouldRetry && state.Phase != vowifi.PhaseFailed {
+86 -2
View File
@@ -267,6 +267,89 @@ func TestManagerStopsAutomaticRetryWhenPolicyIsDisabled(t *testing.T) {
}
}
func TestManagerAcceptsRepeatedEnableWhileBusy(t *testing.T) {
manager := New(Options{OperationTimeout: time.Second})
t.Cleanup(func() { _ = manager.Close(context.Background()) })
if err := manager.Register(testOrchestrator(t, "ec20")); err != nil {
t.Fatal(err)
}
started := make(chan struct{})
release := make(chan struct{})
if _, err := manager.startOperation("ec20", false, func(context.Context, *vowifi.Orchestrator) error {
close(started)
<-release
return nil
}); err != nil {
t.Fatal(err)
}
<-started
if _, err := manager.RequestEnabled("ec20", true); err != nil {
t.Fatalf("repeated desired state was rejected: %v", err)
}
close(release)
deadline := time.Now().Add(time.Second)
for time.Now().Before(deadline) {
state, err := manager.State("ec20")
if err != nil {
t.Fatal(err)
}
if state.Phase == vowifi.PhaseSMSReady {
return
}
time.Sleep(time.Millisecond)
}
t.Fatal("desired enable was not reconciled after the active operation")
}
func TestManagerReEnablesWhenSwitchChangesDuringDisable(t *testing.T) {
manager := New(Options{OperationTimeout: time.Second})
t.Cleanup(func() { _ = manager.Close(context.Background()) })
if err := manager.Register(testOrchestrator(t, "ec20")); err != nil {
t.Fatal(err)
}
if _, err := manager.RequestEnabled("ec20", true); err != nil {
t.Fatal(err)
}
waitForPhase := func(want vowifi.Phase) {
t.Helper()
deadline := time.Now().Add(time.Second)
for time.Now().Before(deadline) {
state, err := manager.State("ec20")
if err != nil {
t.Fatal(err)
}
if state.Phase == want {
return
}
time.Sleep(time.Millisecond)
}
t.Fatalf("phase did not become %s", want)
}
waitForPhase(vowifi.PhaseSMSReady)
started := make(chan struct{})
release := make(chan struct{})
if _, err := manager.startOperation("ec20", false, func(ctx context.Context, orchestrator *vowifi.Orchestrator) error {
close(started)
<-release
_, err := orchestrator.Disable(ctx)
return err
}); err != nil {
t.Fatal(err)
}
<-started
manager.mu.Lock()
manager.entries["ec20"].desiredEnabled = false
manager.mu.Unlock()
if _, err := manager.RequestEnabled("ec20", true); err != nil {
t.Fatalf("enable while disable is active: %v", err)
}
close(release)
waitForPhase(vowifi.PhaseSMSReady)
}
func TestManagerRejectsUnknownDevice(t *testing.T) {
manager := New(Options{})
t.Cleanup(func() {
@@ -348,8 +431,9 @@ func TestManagerCoalescesReconnectWhileLifecycleOperationIsBusy(t *testing.T) {
if _, err := manager.RequestReconnect("ec20"); err != nil {
t.Fatalf("second queued reconnect error = %v", err)
}
if _, err := manager.RequestEnabled("ec20", true); !errors.Is(err, ErrOperationInProgress) {
t.Fatalf("non-reconnect operation error = %v, want ErrOperationInProgress", err)
if _, err := manager.RequestEnabled("ec20", true); err != nil {
close(release)
t.Fatalf("repeated desired enable was rejected: %v", err)
}
manager.mu.Lock()
pending := manager.entries["ec20"].reconnectPending
+1
View File
@@ -203,6 +203,7 @@ type ProxyRoute struct {
type ProxyRequest struct {
DeviceID string
ICCID string
HomeMCC string
HomeMNC string
CountryCode string
+8
View File
@@ -0,0 +1,8 @@
# Bundled country flags
These 4:3 SVG flags are vendored from
[`lipis/flag-icons` v7.3.2](https://github.com/lipis/flag-icons/tree/v7.3.2)
so the web UI does not depend on the device's emoji font or a remote CDN.
The artwork is distributed under the MIT License. See `LICENSE.txt` in this
directory.
+150
View File
@@ -0,0 +1,150 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" id="flag-icons-ad" viewBox="0 0 640 480">
<path fill="#d0103a" d="M0 0h640v480H0z"/>
<path fill="#fedf00" d="M0 0h435.2v480H0z"/>
<path fill="#0018a8" d="M0 0h204.8v480H0z"/>
<path fill="#c7b37f" d="M300.4 136.6c7.7 0 10.9 6.6 18.6 6.6 4.7 0 7.5-1.5 11.7-3.9 2.9-1.6 4.7-2.5 8-2.5 3.4 0 5.5 1 7.3 4 1 1.6 1.8 4.9 1.3 6.7a40 40 0 0 1-2.7 8.3c-.7 1.6-1.3 2.5-1.3 4.2 0 4.1 5.6 5.5 9.4 5.6.8 0 7.7 0 12-4.2-2.3-.1-4.9-2-4.9-4.3 0-2.6 1.8-4.3 4.3-5.1.5-.1 1.3.3 1.7 0 .7-.3.4-1 1-1.4 1.2-1 2-1.6 3.6-1.6q1.3-.1 2.5.7.5.7 1 .8c1.2 0 1.8-.8 3-.8a5 5 0 0 1 2.3.6c.6.3.6 1.5 1.4 1.5.4 0 2.4-.9 3.5-.9 2.2 0 3.4.8 4.8 2.5.4.5.6 1.4 1 1.4a6 6 0 0 1 4.8 3c.3.4.7 1.4 1.1 1.5.6.3 1 .2 1.7.7a6 6 0 0 1 2.8 4.8q-.1 1.2-.5 2.2c-1.8 6.5-6.3 8.6-10.8 14.3-2 2.4-3.5 4.3-3.5 7.4 0 .7 1 2.1 1.3 2.7-.2-1.4.5-3.2 2-3.3a4 4 0 0 1 4 3.6l-.3 1.8a10 10 0 0 1 4-1.4h1.9c3.3 0 7 1.9 9.3 3.8a21 21 0 0 1 7.3 16.8c-.8 5.2-.3 14.8-13.8 18.6 2.5 1 4.2 3 4.2 5.2a4.5 4.5 0 0 1-4.4 4.7 4 4 0 0 1-3.5-1.4c-2.8 2.8-3.3 5.7-3.3 9.7 0 2.4.4 3.8 1.4 6s1.8 3.5 3.7 5.1q1.3-2.4 4-2.6 2.7-.1 3.9 2.2c.2.5 0 .9.3 1.4.3.6.8.7 1.1 1.3.5 1 0 1.8.5 2.7.3.7.9.8 1.2 1.4.4 1 .5 1.6.5 2.7 0 3-2.7 5.2-5.7 5.2-1 0-1.4-.4-2.3-.3 1.7 1.7 3 2.5 4.3 4.5a18 18 0 0 1 3 10.3 22 22 0 0 1-2.8 11.2 20 20 0 0 1-7 8.5 35 35 0 0 1-16 6.4 74 74 0 0 1-11 1.4l-14.1.8c-7.2.4-12.2 1.5-17.3 6.6 2.4 1.7 4 3.5 4 6.4q-.2 4.7-4.7 6.2c-.7.2-1.2 0-1.9.4s-.7 1.3-1.4 1.7a6 6 0 0 1-3.8 1 8 8 0 0 1-6.4-2.5c-2.2 1.8-3 3.4-5.5 4.9-.8.4-1.2 1-2.1 1-1.5 0-2.2-1-3.4-1.8a23 23 0 0 1-4.4-4c-2.3 1.3-3.6 2.4-6.3 2.4a7 7 0 0 1-4-1c-.6-.5-.8-1.2-1.5-1.6s-1.3-.3-2.1-.7c-3-1.3-5-3.5-5-6.8 0-2.9 1.8-4.7 4.4-6-5-5-10-5.8-17-6.2l-14-.8c-4.4-.3-6.8-.7-11-1.4-3.3-.5-5.2-.7-8.2-2.1-10.2-4.8-16.8-11.3-18-22.5-.2-1-.2-1.5-.2-2.5 0-5.8 2.3-9.4 6.4-13.5-1-.3-1.7 0-2.8-.3-2.5-1-4.4-2.7-4.4-5.5q-.1-1.4.5-2.6c.4-.6 1-.7 1.2-1.4.2-1 0-1.6.4-2.5.3-.5.8-.6 1-1.2 1-1.9 2-3.4 4.1-3.4q2.7.1 3.8 2.5c1.8-.8 2.2-2.1 3.2-3.7a16 16 0 0 0 1.4-13.3c-.4-1.5-.6-2.5-1.8-3.7q-1.4 1.4-3.4 1.4c-2.9 0-5-2.5-5-5.3a5 5 0 0 1 3-4.6c-1.6-1.4-3-1.5-4.7-2.6-2.6-1.6-3.5-3.4-5.2-6-1.2-1.6-1.5-2.8-2-4.7a19 19 0 0 1-1-7.8c.6-5 1.5-8 4.6-11.9 1.8-2.3 3-3.7 5.8-4.9 2.3-1 3.7-1.7 6.2-1.7l2 .1a7 7 0 0 1 2.8.8c.4.2 1.1.9 1.1.4s-.3-.8-.3-1.3c0-2 1.5-4 3.6-4 1.5 0 2.1 1.4 2.9 2.7q.6-1 .7-2.3c0-3.4-1.9-5.2-4-7.9-4.7-5.8-10.5-8.5-10.5-16q0-3.2 3-4.9c.5-.3 1.3 0 1.8-.3s.4-1 .7-1.4q.7-.9 1.6-1.6c1-1 2-.6 3.1-1.5q.8-.7 1.2-1.4c1.3-1.6 2.5-2.4 4.6-2.4q1.3-.1 2.5.4l1 .5q.5-.5 1.5-1.1a4 4 0 0 1 2.2-.6c1.1 0 1.8.6 3 .6q.3-.2.8-.6c1-.7 1.5-1 2.7-1s1.8.3 2.8 1c1 .5 1 1.3 2 1.8q.8.3 1.5.4c2.6.9 4.5 2.6 4.5 5.3q.1 2.1-1.4 3.5c-.9.7-1.7.6-2.8 1a16 16 0 0 0 11.3 3.5c4.2 0 9.3-1.7 9.3-5.9 0-2-1-3-1.8-4.8a19 19 0 0 1-2.1-8.5c0-2.8.3-4.5 1.9-6.7s3.6-2.9 6.5-2.9"/>
<g fill="none" stroke="#703d29">
<path stroke-linejoin="round" stroke-width=".7" d="M272.4 159a4 4 0 0 0 2.4 2.4c.8.3 2.7.2 3.8-1.4 1-1.2 1-2.8.6-4a5 5 0 0 0-1.7-2.2z"/>
<path stroke-linecap="round" stroke-width=".7" d="M401 236.1c-1.2-2.9-4.3-1.6-4.4 0-.5 3.7 2.7 4.8 5 4.2a4 4 0 0 0 2.5-2q1-1.6.4-3.7l-.8-1.6-1.3-1.2q-1.2-.7-3.4-.6c-5.5 0-10.4 6.5-12 13.4-.6 2.2-1.3 7.3-.3 12a22 22 0 0 0 5.9 11.3 26 26 0 0 0 9.9 5.8 8 8 0 0 0 4 .1c3.2-.7 4.7-3.8 3-7-1.3-2.5-5.3-4-7.2-.6q-.3.5-.4 1.5c0 .9.4 2 1 2.4 1.5.9 3.8.6 3.7-2"/>
<path stroke-width=".8" d="M383.8 274a11 11 0 0 1 6.6-3.7q4.4-.4 8.2 2a19 19 0 0 1 10.8 17c0 3.6-1 7.5-2 9.4-.8 1.7-3 9-15.3 14-7.1 3-18 3.6-25.7 4-10.4.3-20 .7-25.5 7.6"/>
<g stroke-width=".7">
<path d="M386.4 285.7q-.4-1.5.8-3.3c1.2-1.6 3.7-2.1 6-1a7 7 0 0 1 2.5 2.2l1.1 1.6q1 1.7 1 2.5c2.5 7-1.4 14.5-6.5 17.6-4 2.4-8.7 3.4-14.4 4-2.5.4-4 .3-6.5.5h-16.8c-2.9.3-5 .4-7.6.8q-2.4.3-5.4 1-.9 0-1.8.4l-1.2.3q-5.5 1.6-9.8 4.2-1.3.7-2.5 1.7l-1.3 1.2c-2 2-3.9 4-4.4 6.7v1.6c0 1.8 1.4 4.3 5.4 5m5.5-170c.8 1.4 1.3 2.3.8 3.9q-.9 2.7-3.6 2.8c-4 0-6.3-4.8-4.5-7.8 3.2-5.3 9.3-2.3 15 .3-.3-1.3-.8-1.8-.7-3.5.1-4.2 3.2-6 4.5-10 .7-2.3 1-4.3-.7-6q-2.2-1.8-5.1-.6c-3.8 1.5-8.5 5.9-16.6 6-8.2-.1-12.8-4.5-16.7-6q-3-1.2-5.1.7c-1.7 1.6-1.4 3.6-.7 6 1.3 3.8 4.4 5.7 4.5 10 0 1.6-.4 2-.7 3.4 5.7-2.6 12-5.9 15-.3 1.7 3.2-.5 7.7-4.5 7.7q-2.7 0-3.6-2.7-.5-2.2.8-4"/>
<path stroke-linecap="round" d="M314.6 159.9a5 5 0 0 1 2.4 5c-.2 2.5-.8 3.1-2.8 4.5m2.4-3.8q0 2.2-2.3 3.1"/>
</g>
<path fill="#c7b37f" stroke="none" d="m276.7 153.3.7.5.8.8.5 1 .2.8v1.9l-.2.8-.5.6-.6.6-.9.5-1 .2-1 .2-1-.5-.9-.6-.5-.8-.4-1v-.4z"/>
<path stroke-linecap="round" stroke-width=".7" d="M275.2 157.2c-.3-1.7-2.2-2-3-1-1.1 1.5-.3 4 2 4.7a4 4 0 0 0 3.9-1.4c1-1.3.9-2.8.5-4a5 5 0 0 0-1.7-2.2c-2.7-2-7.1-1.6-8.6 2-1.8 4.4 2.2 7.8 6 10.3 4.6 3.2 10 3.8 14 3.8 9.2-.1 16.2-4.5 20.7-7q1.7-.9 2.7.2a2 2 0 0 1-.3 2.7"/>
<path stroke-width=".7" d="m248 281.2-2 .7-2 1.6-1 1.3-1.1 2-.5 1.5-.4 1.8-.2 1.4m19-10.1-.1 1.8-.3 1.2-1 2.2-1.3 1.8-1.5 1.2-1.1.5-1.6.4"/>
<path stroke-width=".8" d="M319.7 329.1c-.3 1.7-1.9 3.6-5.3 4.2l-.6.2"/>
<path stroke-width=".9" d="M404.2 276.2a18 18 0 0 1 5.6 13.5c0 3.6-1 7.5-2 9.4-.8 1.7-3 9-15.3 14a85 85 0 0 1-25.6 4c-10.3.3-19.8.7-25.4 7.3"/>
<path stroke-width=".6" d="M387.5 282.9c.8-1 3.5-2.4 5.8-1.1a6 6 0 0 1 2.3 2"/>
<path stroke-width=".9" d="m401.6 273.8 1.4.5a7 7 0 0 0 4 0c2.8-.8 4.6-3.4 3.2-6.9a6 6 0 0 0-1.8-2.1"/>
<path stroke-linecap="round" stroke-width=".7" d="M240.3 199.8c-2 1.1-3.3 1.4-4.8 3.1a28 28 0 0 0-2.6 6.8m46-51.7q-.1 2.7-3 3.2"/>
<path stroke-width=".6" d="M397.1 192a19 19 0 0 1 18.6 19.8c0 16-9.9 18.5-13.8 19.6"/>
<path stroke-width=".7" d="M398.4 192c8.1-.3 16.5 5.7 16.9 20.7.3 11.7-8 17-12 18"/>
<path stroke-width=".6" d="m393.8 248.4.1-1.6.6-2.5.7-2 .9-1.6 1-1.3m7.8-3.4v1.5l-.5 1-.7 1.1-.8.6-1.2.5h-1.1l-.8-.1m-14.3-52.8.3-1.7.8-1.6 1-1.5 1.6-2.2 1.4-1.4 2-2.2 2-1.9 1.1-1.3 1.5-1.9 1.4-2 .8-1.7.5-2.2.1-2.7-.2-.8m-12.3 128.2 1.6-.4 1.2-.6.7-.7.5-.8.3-1.2v-.9m-158.2-12.1h2.7l1.6-.6m5-36.5-.2 1.4-.4.6-.4.6-.7.5-.7.3-1 .1h-.6m9.9-15.5-.3 2.1-.5 1-.8 1.2-1.2.9-1.2.6-2.3.5m15.3-39.7-.5 1.3-.5 1-.8 1-1 1-1.2.5-1.1.3-.6-.1m.3-6.2v1"/>
<g stroke-width=".6">
<path stroke-linecap="round" d="M254.3 224a7 7 0 0 1-2.1 1.4m150.5 44.8.5.2c1.4.8 4.2-.2 3.4-2.4"/>
<path d="M397.8 239.6c1 1.3 2.9 1.7 4.4 1.3a4 4 0 0 0 2.5-2q1-1.6.4-3.7l-.9-1.6-1.3-1.5-.4-.2m6.4 34 .1-.7a4 4 0 0 0-1.3-3l-.8-.8m.4.5c0-1.8-1.5-3.2-3.4-3.5m-4.2 2.8-1.3-1a16 16 0 0 1-4.3-10.7c0-4.2 1.6-8.4 3.6-10M341.2 324l1.8-1.6 1.2-1 2.3-1.4 2.2-1 1.6-.5 3-.6 3.6-.6m-29.5 19.4a17 17 0 0 1-7.6 6.1 18 18 0 0 1-7.6-6.1"/>
<path stroke-linecap="round" d="M314.4 332.6a10 10 0 0 1-2.2 4.2"/>
<path d="m314.7 330.5-.4 2.2M312 337l-1 1-1.7.9-2 .6m-5.6-177.8c.3-.8.5-1.4.5-2.6-.1-4.2-3.2-6.1-4.5-10-.7-2.3-1-4.3.7-6q2.2-2 5-.6c4 1.5 8.6 5.8 16.7 6-8.1-.2-12.8-4.5-16.6-6-2-.8-3.8-1-5.3.5-1.7 1.6-1.2 3.8-.5 6.1 1.3 3.9 4.2 5.8 4.3 10q-.1 1.6-.5 2.6M320 148c8-.4 14.9-5.8 17.1-6.3 2-.4 3-.2 4.5 1.1-1.4-1.3-3-1.2-5-.5-3.8 1.5-8.4 5.8-16.6 6m79.6 112.9a16 16 0 0 1-6.2-12.4c0-4.1 1.7-8.4 3.6-10m-70 97.6c-1.3 2-4.3 5-7.6 6.2a18 18 0 0 1-7.6-6.2"/>
<path stroke-linecap="round" d="m306.7 163.7 2.3-1.3c1-.6 2.3-.5 2.9.2s.7 2-.2 2.8"/>
<path d="M294.7 169.3c5.5-1.2 10-3.6 13.4-5.5M340.3 328c.5.3.8 1 .8 1q.2.3.3.8c.3 1.5-.7 2.4-2 2.6-1.7.2-3-.8-3.5-2M294.4 169c5.5-1.1 10-3.6 13.4-5.5m97.6 106.9c-1 .4-1.6.3-3-.2l-1.8-1a21 21 0 0 1-8.4-9 19 19 0 0 1-1.7-4.6 12 12 0 0 1-.5-3.3 26 26 0 0 1 4.7-15.3c1.1-1.6 2.1-2.5 4.2-2.6m-143.7-39.3a7 7 0 0 1 2.7 5.7c0 3.1-2.6 8.2-9 10a8 8 0 0 1-6.3-.8"/>
<path d="M256.3 205.6q1.6 1 1.6 3.3 0 1.6-1.9 3.7a12 12 0 0 1-8.8 4q-3 .1-6-1.7a9 9 0 0 1-3.8-5.4"/>
<path d="M256.2 212.3q1.8 1.8 1.7 4.6.1 3.9-3.7 7-.8.8-2 1.5m129.5-22.1v3.5m-.3-4.4v5m.3-15.8v6.6m-.3-8v8.9m-1.9 82a19 19 0 0 1-4.2 5.6 20 20 0 0 1-5.8 4.1 25 25 0 0 1-6.6 2.2 33 33 0 0 1-6.8.9c-2.5 0-3.9 0-6.4-.2s-4-.6-6.7-.8c-2.2-.2-3.4-.4-5.6-.3a28 28 0 0 0-11 1.8c-2.6 1-5.7 3-6.3 3.8a22 22 0 0 0-6.4-3.8 22 22 0 0 0-5.1-1.4c-2.3-.4-3.5-.4-5.8-.4s-3.4.1-5.6.3c-2.6.3-4 .6-6.7.8-2.5.2-3.9.3-6.4.2a33 33 0 0 1-13.4-3 20 20 0 0 1-6.4-4.8m42.1 53.4 1.8-.2m30.3-2.4 1.8-.1 1.7-.7 1.2-.8 1.7-2 .3-.6.3-1.7v-.8m47-136.7c.7-2.6-.2-5.4-2.8-5.3m-132 46.5a8 8 0 0 1-3.5 4.7m3.6-46.7a7 7 0 0 1-3.6 4c-1.9.8-4 0-5.2-.8"/>
<path stroke-linecap="round" d="M243.8 202.4c1.5.8 3.1-.4 2.8-2.4a3 3 0 0 0-2.5-2.2"/>
<path d="M250.2 286.6q.3.5.8.8c.7.2 1.2.4 1.9-.5.8-1.1.3-2.8-.5-3.9a5 5 0 0 0-5.8-1q-1.2.6-2.6 2.2l-1.1 1.6q-1 1.7-1.1 2.4c-2 5.9.4 12 4.1 15.7"/>
<path stroke-linecap="round" d="m340.2 327.8.7.8.2.9c.3 1.5-.7 2.4-2 2.6-1.6.2-2.8-.8-3.3-2"/>
<path d="M389.4 154.8a7.4 7.4 0 0 1 6.3 7c0 4.4-1.5 6-3.8 9.2-2.5 3.4-10.7 9.6-10.7 16.7q-.2 6.4 4.3 8.4c2 1 4.3 0 5.4-1 2.6-2.4 1.5-6.5-1.2-7-3.2-.6-3.9 4.6-.7 4.3m17.9 69a4 4 0 0 0-3.6-3 3.7 3.7 0 0 0-3.7 3.7q0 1.6 1 2.6"/>
<path d="M383.9 195.1a7 7 0 0 0-2.7 5.7c0 3.1 2.6 8.2 9 10 2.4.7 4.8.6 6.2-.3m-156-10.3a9 9 0 0 0-4.8 3.5 17 17 0 0 0-2.2 12.7 16 16 0 0 0 2.3 5.6l1 1.2 1.2 1m64 92c4.9 2.1 8.4 3.7 11.4 8.5a10 10 0 0 1 1.2 4.9c0 2.7-1 5.7-3.3 7.6a8 8 0 0 1-6.7 2c-1.9-.2-3.7-1.6-4-2.6M254 224.1c2.7 2.2 3.9 4.2 3.9 7.5a8 8 0 0 1-4 7.5"/>
<path stroke-linecap="round" d="M251.5 236.4c4 5.1 6.3 8.1 6.4 14.1.1 5.7-1.7 9.6-5 13.7"/>
<path d="M329.8 169.3a4 4 0 0 0 1.5-2.2q.8-2.2-.2-4 1.3 2 .7 4c-.1 1-.8 1.5-1.6 2.3m51.5 86.1v16.2l-.1 2.5-.3 1.7"/>
<path d="M381.4 254v19.9l-.5 2.6m.5-43v14.6m.3-13.4v11.8m0-26.8v8.8m-.3-9.9v11m.3-19v3.5m-.3-4.2v5m-1.8 65.2-.4.7a19 19 0 0 1-4.1 5.7 20 20 0 0 1-5.9 4 25 25 0 0 1-6.5 2.2c-2.7.6-4.2.8-6.9.9-2.5 0-3.9 0-6.3-.2-2.7-.2-4.1-.5-6.8-.8-2.2-.2-3.4-.3-5.6-.3s-3.5 0-5.7.4a22 22 0 0 0-5.2 1.4c-2.7 1.1-5.7 3-6.4 3.8-.6-.8-3.7-2.7-6.3-3.8a22 22 0 0 0-5.2-1.4c-2.2-.4-3.5-.4-5.8-.4s-3.4.1-5.6.3c-2.6.3-4 .6-6.7.8-2.5.2-3.9.3-6.3.2a33 33 0 0 1-13.5-3 20 20 0 0 1-5.8-4.1l-2.5-2.8m-2-3.2a10 10 0 0 1-2.3 7.7c-.8.9-2.6 2.6-5 2.6-3.7 0-4.8-2.5-5-3.2"/>
<path d="M255.6 278.9q1 1 1.9 2.5c1 1.8.6 4.8-.1 6.2l-.3.4m-20.3 18q3.3 3.8 10.9 7.1c7.1 3 18.1 3.6 25.7 4 10 .3 19.3.7 25 7m17.3-4a12 12 0 0 1 4 5.5m-7.3 11.5-.7.7a8 8 0 0 1-6.6 2c-2-.2-3.8-1.6-4.3-2.6m-5.4-2.9.3.4a8 8 0 0 0 5.1 2.4m27 0a18 18 0 0 1-7.7 6.1 18 18 0 0 1-7.6-6.1l-.3-.5m15.6.4.7.7a8 8 0 0 0 6.7 2 6 6 0 0 0 4-2.5l.5-.7"/>
<path d="m339 336.6-.7 1.2-1.1 1-1.7.7h-1.6"/>
<path d="M343 325.3a8 8 0 0 1 2.4 2.9q.4 1 .5 2.3a6 6 0 0 1-1.5 4.2 8 8 0 0 1-5.4 2.4h-.4m.2-.2a7 7 0 0 1-5.2-2.2m63.7-67.9a24 24 0 0 1-4.8-6.4 19 19 0 0 1-1.7-4.5 12 12 0 0 1-.5-3.3 26 26 0 0 1 4.6-15.3c.7-.8 1.4-1.8 2.1-2.2m-1.3-75.9c2.5.2 4.8 3 4.8 5.7 0 3.8-1.3 5.5-4.4 9.3-2.6 3.2-10.6 9-10.3 14.5q.1 1.6 1.1 2.8m-3.2 3.5a7 7 0 0 0 2 1.4 5 5 0 0 0 4.3-.3M369 153a6 6 0 0 1 2.2 2.6c1.8 4.5-2.2 7.9-6 10.4a21 21 0 0 1-8.3 3.3"/>
<path d="M364.6 161.6a4 4 0 0 1-3.1-1.5l-.7-1m-15 4.9-1.2-1q-1.7-1.4-.8-4.4c.6-1.9 3.7-7.2 3.8-10.9.2-5.6-2-9-5.3-10.2"/>
<path stroke-linecap="round" d="m347.3 146.5-.1 2-.6 2.2-1 3-1 1.9-.8 1.9-.4 1.3-.2 1 .1.9m38 126.3.6.8c.7 1 3.2 3 5.5 3 3.7 0 4.6-2.6 4.7-3.2.5-2.9-.5-3.6-2-4.5 0 0-.8-.4-1.9-.2"/>
<path d="M237 274.4a7 7 0 0 1-3.7 0c-2.9-.9-5.2-3.6-4-7m13.4-31.8q.4.5.4 1c.4 3.8-2.8 4.8-5 4.2a6 6 0 0 1-3-2.3 5 5 0 0 1-.7-2.3m22-23.6q.9.7 1.3 1.7m-1.1-8.5q.8.7 1.1 1.3"/>
<path stroke-linecap="round" d="M257.9 210.5a9 9 0 0 1-1.6 2.4 12 12 0 0 1-8.8 4q-3 .1-6-1.7a10 10 0 0 1-4-5.6"/>
<path d="M255.4 195.3a8 8 0 0 1 2.4 3.4"/>
<path stroke-linecap="round" d="M257.8 203.2c-.9 3-3.5 6.6-8.6 7.9-2.4.6-5.6-.2-6.6-1"/>
<path d="M240 202.6c.3 2.6 2 4.6 5.4 4.6 4.7.1 7.6-6.7 3.4-11.5"/>
<path stroke-linecap="round" d="M229.4 225.5q1 1.3 2.4 2.4a17 17 0 0 0 6 3.3m5.2.5c4.2-.5 6.6-3.7 6-7.3-.3-2.8-2.8-5-4.6-5.1"/>
<path d="M249.8 188.1c1.9 0 3 1.6 2.9 3"/>
<path stroke-linecap="round" d="M249.4 163a12 12 0 0 0 5 5.9m144.2 31c1.7 2.3.6 7-4 7a5 5 0 0 1-4.5-2.5"/>
<path d="M381.7 169.1V185"/>
<path stroke-linecap="round" d="M243.8 202.3c1.4 1 3.3-.7 2.5-2.6-.5-1.2-2.2-2.6-4.7-.9-2.8 1.9-2 7.8 3.2 7.9 4.7 0 7.6-6.8 3.4-11.6-4-4.6-11.3-3.6-16 .2A21 21 0 0 0 225 207a23 23 0 0 0 0 9.2 21 21 0 0 0 3 7.5l1.3 1.7c.8.8 1 1.2 2 2a15 15 0 0 0 10.4 3.7c4.6-.2 7.3-3.4 6.8-7.3-.4-3.8-4.2-5.7-6.7-3.9-1.7 1.2-2.3 4.9.7 5.8 1.6.5 3.1-1.7 2-3M374 150.9q4-2.2 6.3 1a10 10 0 0 1 1.6 7.2 9 9 0 0 1-3.5 5.8"/>
<path stroke-linecap="round" d="M380.5 152c3.1-2 6.5-1.1 8.3 1.6 1.3 2 1.7 3.6 1.6 6.1a11 11 0 0 1-5.7 9.2"/>
<path d="M395 159.2c2.6.2 4.6 2.5 4.6 5.1 0 3.8-1 5.5-4 9.3-2.7 3.3-10.6 9-10.4 14.6 0 2.1 1.8 4 3.3 4.2"/>
<path stroke-linecap="round" d="M395.4 202.3c-1.5 1-3.3-.6-2.5-2.4.5-1.2 2.2-2.8 4.7-1.1 2.7 1.9 2 7.8-3.3 7.9-4.7 0-8-6.6-3.4-11.6 4-4.6 11.7-3.7 16.5.1 2 1.6 6.1 6 7 12 1 7 .9 15.6-6.4 21-3 2.1-7 3.1-10.6 3-4.6-.2-7.3-3.5-6.8-7.4.5-3.8 4-5.4 6.7-3.9s2.3 5.4-.7 5.8c-1.7.2-3.1-1.7-2-3"/>
<path d="M392.9 199.9c.8-3.5 3.7-3.8 6.2-3.8 6.5.1 11.1 8 11.2 15.5 0 9.5-4 15.2-11 15.5-1.9 0-5-.8-5-3"/>
<path stroke-linecap="square" d="M397 198.3c6.9 1.6 9.3 7.8 9.3 13.8 0 4.9-.5 11.6-10 13.9"/>
<path d="M408.4 265.3a3.9 3.9 0 1 0-6.3 2.4"/>
<path stroke-linecap="round" d="M394.4 259.4c1.4 2 3 4.1 6.3 6m-1.3 10.5c-3.2-2.2-9.5-5-15-2.2a8 8 0 0 0-4.4 4.4 10 10 0 0 0 1.8 9.5c.9 1 2.7 2.6 5 2.7 3.8 0 4.7-2.6 4.8-3.2.4-2.8-1.2-3.9-2-4.1-.7-.3-2.8-.2-3.2 1.3q-.3.9.2 2"/>
<path stroke-linecap="round" d="M340.5 328.4c1 2.2-.2 3.2-1.6 3.4-2.2.3-3.3-1.4-3.4-3a4.4 4.4 0 0 1 4.3-4.7c2.3 0 4.1 1.5 5 3.5q.5 1 .5 2.4a6 6 0 0 1-1.4 4.1 8 8 0 0 1-5.4 2.5c-4.2.1-7.5-3.8-7.5-7.8 0-7.7 11.4-12 16-13a84 84 0 0 1 17.9-2.4c3.5-.1 6.2 0 10.1-.5 3.5-.3 5.4-.5 9-1.3a27 27 0 0 0 12.6-6.4c2.9-2.7 4.5-4.5 5.9-8.2a17 17 0 0 0-1.3-13.9 14 14 0 0 0-10.3-6.8c-3.7-.5-7 1.1-9 4.8-1 1.8-.6 4.8.1 6.2a6 6 0 0 0 4.8 3c3.8 0 4.7-2.6 4.8-3.2.4-2.8-1.2-3.9-2-4.2-.7-.2-2.8-.1-3.2 1.4q-.3.9.2 2"/>
<path stroke-linecap="round" d="M337.2 316.2c-4.8 2.1-8.4 3.7-11.4 8.5a10 10 0 0 0-1.2 4.9c0 2.7 1.1 5.7 3.3 7.6a8 8 0 0 0 6.7 2c2-.2 3.7-1.6 4-2.6"/>
<path d="M385.1 224.1c-2.3.8-3.9 4.2-3.9 7.5a8 8 0 0 0 4 7.5"/>
<path stroke-linecap="round" d="M387.6 236.4c-4 5.1-6.3 8.1-6.4 14.1 0 5.7 1.7 9.6 5.1 13.7"/>
<path d="m365.9 152 .3-.5c1.7-2.4 4.7-3.1 6.9-1.5 2.6 2 3.3 5.4 2.6 9q-.9 3.3-4 5.5"/>
<path stroke-linecap="round" d="M265.1 150.8q-3.9-1.9-6.3 1a9 9 0 0 0-1.6 7.2c.6 2.7 1.4 3.8 3.5 5.8"/>
<path d="M258.6 152a6 6 0 0 0-8.3 1.6 9 9 0 0 0-1.6 6.1c.2 4.2 2.8 7.6 5.8 9.2"/>
<path d="M249.7 154.8a7 7 0 0 0-6 6.6c0 4.5 1 6.3 3.5 9.6 2.5 3.4 10.7 9.6 10.7 16.7q.2 6.4-4.3 8.4c-2 1-4.3 0-5.4-1-2.6-2.4-1.5-6.5 1.2-7 3.3-.6 3.9 4.6.7 4.3"/>
<path d="M244 159.2c-2.5.2-5 2.3-5 5 0 3.8 1.5 5.6 4.6 9.4 2.6 3.3 10.1 9 9.9 14.5 0 2-1.5 4.6-2.9 4.3"/>
<path stroke-linecap="round" d="M238 236.1c1.3-2.9 4.4-1.6 4.6 0 .4 3.7-2.8 4.8-5.1 4.2a4 4 0 0 1-2.5-2 5 5 0 0 1-.4-3.7l.9-1.6 1.2-1.2q1.3-.7 3.4-.6c5.5 0 10.4 6.5 12 13.4.6 2.2 1.3 7.3.3 12a22 22 0 0 1-5.8 11.3 26 26 0 0 1-10 5.8 7 7 0 0 1-3.9.1c-2.8-.9-4.6-3.5-3.2-7 1.2-2.6 5.4-4 7.3-.6q.3.5.4 1.5c0 .9-.4 2-1 2.4-1.4.9-3.7.6-3.6-2"/>
<path d="M233.8 270.4c1 .4 1.6.3 2.9-.2l1.8-1c2.6-1.5 5.6-3.8 8.4-9.1a19 19 0 0 0 1.7-4.5q.4-1.6.6-3.3a26 26 0 0 0-4.8-15.3c-1.1-1.6-2-2.5-4.2-2.6m-9.5 31a3.9 3.9 0 1 1 6.3 2.3"/>
<path d="M232.2 261.4a4 4 0 0 1 3.7-3 3.7 3.7 0 0 1 3.6 3.7 4 4 0 0 1-1 2.6"/>
<path d="M239.4 261.3a16 16 0 0 0 6.2-12.4c0-4.1-1.6-8.4-3.6-10"/>
<path stroke-linecap="round" d="M244.7 259.4a17 17 0 0 1-6.3 6"/>
<path d="M254.6 273.7q-1.4-3.2-5.8-3.5-4.3-.3-8.2 1.9a19 19 0 0 0-10.8 17 25 25 0 0 0 2 9.5c.9 1.6 3 9 15.3 14a86 86 0 0 0 25.7 3.9c10.4.4 20 .8 25.6 7.6"/>
<path stroke-linecap="round" d="M239.7 275.9c3.3-2.2 9.5-5 15.1-2.2a8 8 0 0 1 4.3 4.4 10 10 0 0 1-1.8 9.5c-.9 1-2.7 2.6-5 2.7-3.8 0-4.7-2.6-4.8-3.2-.4-2.8 1.2-3.9 2-4.2.7-.2 2.8-.1 3.2 1.4q.4.9-.2 2"/>
<path d="M252.7 285.7q.6-1.6-.8-3.3a5 5 0 0 0-6-1q-1.1.6-2.4 2.2-.7.7-1.2 1.6-1 1.7-1 2.5c-2.5 7 1.5 14.4 6.5 17.6 4.4 2.8 8.8 3.6 14.4 4 2.5.3 4 .3 6.5.5h16.8c3 .3 5.1.4 7.6.8q2.5.3 5.4 1 .9 0 1.8.4l1.2.3q5.5 1.6 9.8 4.2 1.3.7 2.5 1.7l1.3 1.2c2 2 4 4 4.4 6.7v1.6c0 1.8-1.4 4.3-5.3 5"/>
<path d="M298.6 328.4c-1 2.2.2 3.2 1.6 3.4 2.2.3 3.3-1.4 3.5-3a4.4 4.4 0 0 0-4.4-4.7 6 6 0 0 0-5 3.5 7 7 0 0 0-.5 2.4 6 6 0 0 0 1.4 4.1 8 8 0 0 0 5.4 2.5c4.2.1 7.5-3.8 7.5-7.8 0-7.7-11.4-12-16-13a84 84 0 0 0-17.9-2.4c-3.5-.1-6.2 0-10.1-.5-3.5-.3-5.4-.5-9-1.3a27 27 0 0 1-12.5-6.4 17 17 0 0 1-4.7-22 14 14 0 0 1 10.3-6.9q5.9-.7 9 4.8c1 1.8.6 4.8-.1 6.2a6 6 0 0 1-4.8 3c-3.8 0-4.7-2.6-4.8-3.2-.4-2.8 1.2-3.9 2-4.2.7-.2 2.8-.1 3.2 1.4q.3.9-.2 2"/>
<path stroke-linecap="round" d="m273.3 152-.4-.5c-1.7-2.4-4.7-3.1-6.9-1.5-2.6 2-3.3 5.4-2.5 9a9 9 0 0 0 4 5.5"/>
<path d="M366.8 159.6c-4 4.4-8.1 5.8-14.1 6-2 0-5.5-.6-7.6-2.1-1.3-1-2.8-2.6-1.9-5.5.6-1.9 3.7-7.2 3.8-10.9.3-5.6-1.9-8.7-5.3-9.9-6.2-2.2-13 4-17 5.4-2.1.7-3.2.8-5.1.8-2 0-3-.1-5.2-.8-4-1.4-10.7-7.6-17-5.4-3.4 1.2-5.5 4.3-5.3 10 .1 3.6 3.2 9 3.8 10.8 1 2.9-.5 4.5-1.9 5.5-2 1.5-5.7 2.1-7.5 2-6-.1-10.1-1.5-14.1-5.9"/>
<path stroke-linecap="round" d="M297.3 314.4c.8.3.2-.2 5.3 2a22 22 0 0 1 11.3 8.9 11 11 0 0 1 .9 7.3"/>
<path d="M297.7 336a8 8 0 0 0 3.2.9c4.2.1 7.5-3.8 7.5-7.8 0-2.8-1.5-5.2-3.6-7"/>
<path stroke-linecap="round" d="M298.6 328.4c-1 2.3.4 3.5 1.8 3.7 2.2.2 3.4-1.4 3.6-3a5 5 0 0 0-2.2-4.2"/>
<path d="M390.1 154.8c3.2 0 6 3.6 6 7.2 0 4.3-2.2 6.9-3.9 8.8q-1.9 2.3-4.4 4.7"/>
<path stroke-linecap="round" d="M386.3 151.4a9 9 0 0 1 2.8 2.4c1.3 2 1.7 3.7 1.6 6.2-.2 4.2-3.2 7.1-6 9m-4.7-17.6.6.7c1.9 2.2 2 5.4 1.6 7.2a8 8 0 0 1-3.8 5.4m-5-14.4c2.6 2 3.4 5.4 2.5 9q-1 3.6-4.2 5.2m11.1 41.1c.3 1 .9 1.3 1.5 2a14 14 0 0 0 6.2 3.5q3.7.9 6.3-.9m-163 54q2 .1 3.3 2.3.3.4.4 1.5 0 1.5-1 2.2c-1.5 1-4 .5-4-2"/>
<path d="M241.5 231.3c5 1 9.7 6.9 11.2 13.3.6 2.3 1.3 7.3.3 12a22 22 0 0 1-6 11.4l-2.1 1.9-1 .7m-8-12.1c2 0 3.8 1.9 3.8 4a4 4 0 0 1-1 2.6"/>
<path d="M234.6 260.7c2.1 0 4.1 2 4.1 4.2a4 4 0 0 1-1.4 3"/>
<path stroke-linecap="round" d="M254 239.5a18 18 0 0 1 3.8 7.7m0 8.5a17 17 0 0 1-1.5 4 18 18 0 0 1-3.6 4.7"/>
<path d="M254.3 224.3q2.7 2.2 3.5 4.8"/>
<path stroke-linecap="round" d="M257.9 219.5a10 10 0 0 1-3.4 4.6m-9.2-17.2 2.2-.6 1.3-1 .8-1.1.7-1.8.3-1.5"/>
<path d="M241 199.3q-1.1.3-2.5.8a9 9 0 0 0-3.5 3 17 17 0 0 0-2.2 12.7 16 16 0 0 0 2.3 5.6l1 1.4c1.4 1.3 2.6 2 4.6 1.7"/>
<path stroke-linecap="round" d="M253 189.8c-.3 1.3-1 2.9-3 2.7"/>
<path d="M245.7 198.5c-2-1.9-6-2.4-10.1.2L234 200l-1.4 1.6a18 18 0 0 0-2.4 5c-.7 3-.7 5.6-.6 6.3q0 1.5.3 2.7 1 4.2 2.3 6.2c.9 1.5 3 5 7.7 5.4 1.8.1 4.8-.7 5-3"/>
<path stroke-linecap="round" d="M363.8 157c.3-1.6 2.3-1.9 3-1 1.2 1.6.4 4.2-2 4.9a4 4 0 0 1-3.8-1.4c-1-1.3-.9-2.8-.5-4q.4-1.2 1.7-2.2c2.7-2 7.1-1.6 8.6 2 1.8 4.4-2.2 7.8-6 10.3-4.6 3.2-10 3.8-14 3.7-9.2 0-16.1-4.4-20.7-7q-1.7-.7-2.7.3a2 2 0 0 0 .3 2.7"/>
<path stroke-linecap="round" d="M365.6 155.5c1 0 1.2.4 1.5.8 1.2 1.5.3 4.1-2 4.9m17.8 51.5c-3.5 3.8-.2 10.3 2.4 11.8.9.7 1.3.3 2 .7"/>
<path d="M383.1 205.4q-1.5 1-1.6 3.3a5 5 0 0 0 1.4 4 14 14 0 0 0 9.3 3.7q3 .1 6-1.7a9 9 0 0 0 3.8-5.4m-20.8 61.8-.2 2.5a19 19 0 0 1-2 7 19 19 0 0 1-4.2 5.6 20 20 0 0 1-5.9 4 25 25 0 0 1-6.5 2.3 44 44 0 0 1-13.2.6c-2.7-.2-4.1-.5-6.8-.8-2.2-.1-3.4-.3-5.6-.3a28 28 0 0 0-10.9 1.9c-2.7 1-5.7 3-6.4 3.8-.6-.9-3.7-2.8-6.3-3.8a22 22 0 0 0-5.2-1.5c-2.2-.4-3.5-.4-5.8-.4s-3.4.2-5.6.4c-2.6.2-4 .6-6.7.7-2.5.2-3.9.3-6.3.2a33 33 0 0 1-7-.8 25 25 0 0 1-6.5-2.2 20 20 0 0 1-5.8-4.1 19 19 0 0 1-4.2-5.7 19 19 0 0 1-2-6.9c-.2-1-.2-2.5-.2-2.5V169.3h123.2z"/>
</g>
<g fill="#c7b37f" stroke="#c7b37f">
<path stroke-width=".3" d="M248 285.6a2.5 2.5 0 1 1 5 0 2.5 2.5 0 0 1-5 0zM232.5 268q.2-2.1 1.8-2.3c1.6-.2 1.7 1 1.7 2.3q-.2 2-1.7 2.2-1.6-.2-1.8-2.2z"/>
<path stroke="none" d="M241.3 223.6q.2-1.7 1.7-1.8 1.6.2 1.7 1.8c.1 1.6-.7 1.8-1.7 1.8s-1.7-.8-1.7-1.8M272 158c0-1 .5-2 1.4-2q1.5 0 1.8 1.6c0 1-.5 2-1.4 2q-1.4 0-1.8-1.6"/>
</g>
<g stroke="#c7b37f" stroke-linecap="round" stroke-width=".6">
<path d="M239.3 234q-.6.1-.8.5-.4.3-.6.9l-.2 1.2m4.7 26.7 1-1 .6-1 .5-1 .7-1.3m-1.3 14-1.5.7-1.1.6-1.3.8-1.2 1m15-37.9-.8-.8-1-.8-.9-.8"/>
<path stroke-linecap="butt" d="m254.2 225-1.2.5-1.5.3"/>
<path d="M237.4 208.4q.2.8.5 1.5.3 1 .9 1.7a8 8 0 0 0 2.6 2.7l1.5.8m-1-5.8 1.3.6a7 7 0 0 0 3 .6l1.8-.1m7.2-40.7-2-1.2q-1.2-.7-2-1.5l-1.1-1.3-.8-1.3m7.5-4.6.6 1.7 1.4 2c1 1 1.7 1.3 2.8 2.2m1.4-6q.3.9.7 1.6t.8 1.2l1.3 1.3q1 .7 2 1.1"/>
</g>
<path fill="#703d29" stroke-width=".2" d="M333.3 151.6c0-1.7-1.7-1.8-2.4-1.8-1.8 0-2.3 1.1-4.6 2.3a12 12 0 0 1-6.7 2 12 12 0 0 1-6.7-2c-2.3-1.2-2.7-2.3-4.6-2.3a2.3 2.3 0 0 0-2.2 2.4v.9l.3.2q0-1 .5-1.7a2 2 0 0 1 1.6-.8c1.8 0 2.5 1.2 4.8 2.4 3 1.6 4.2 1.9 6.7 2a12 12 0 0 0 6.8-2c2.3-1.2 3-2.5 4.8-2.5q.9 0 1.3 1v.9l.2.1q.1-.3.2-1z"/>
</g>
<g fill="#703d29">
<path d="M264.4 294c.5-.5.9-.3 1-.6q0-.2-.3-.3l-.9-.2-.8-.4q-.2-.1-.5 0c-.1.4 1 .4.6 1.4l-.8 1.2-2.6 3-.2.1v-4.3l.1-1.8c.2-.4.8 0 .9-.4q0-.2-.3-.3t-1.1-.3l-1-.5q-.4-.1-.6 0l.1.3q.5.2.5 1v7.4q0 .7.2.7.1 0 .4-.3z"/>
<path d="M267.5 295.2c.3-1.1 1-.4 1-.8q.1-.2-.2-.3l-1.3-.4q-.6-.1-1.2-.4-.1 0-.4 0c-.1.5 1.1.5.8 1.5l-1.7 5.5c-.3 1-1 .6-1.1 1v.1l1.2.4 1.6.5h.3c.2-.4-1.2-.3-.7-1.7zm3.7 1q.3-.7.9-.4 1.6.6 1 2.5c-.2.6-.4 1.2-2 .8q-.6-.1-.6-.5l.7-2.3zm-2.8 5c-.5 1.4-1.2.8-1.3 1.2q0 .2.3.3l1.6.4.8.3h.4c.1-.5-1-.3-.7-1.5l.6-2q0-.6.6-.3.8.1.8.8l.3 2q.1 1.4 1 2 1 .1 1.4-.4l-.2-.2h-.3s-.3 0-.3-.3l-.7-3.6q.1-.2.8-.3a2 2 0 0 0 1-1.3c.1-.5.4-2.2-1.8-2.9l-2.1-.5-1.2-.4h-.3c-.1.5 1.1.4.7 1.7zm8.4 2.5c-.4 1.4-1.4.5-1.5 1q0 .3.3.3l1.5.3 1.4.4q.4.2.6-.1c0-.3-1.3-.3-1-1.8l1.3-5.2q0-.8.6-.5l1 .2c1.1.3.5 1.5 1 1.6q.2-.1.2-.6l.1-1v-.4l-3.3-.7-3.2-.8q-.2 0-.2.2l-.5 1.5q-.2.3 0 .4c.5.1.5-1.5 1.7-1.2l.9.2q.6 0 .4.8zm12.7-3.3c.4-.6.8-.5.9-.7q0-.2-.4-.3h-.9l-.9-.3q-.3-.1-.4.1c-.1.4 1 .2.8 1.3q0 .3-.6 1.3l-2 3.3-.3.2v-.2l-.7-4-.1-1.8c0-.5.7-.2.7-.5q.1-.2-.4-.3l-1.1-.1q-.6 0-1-.3-.4-.1-.6.1l.1.2q.7.2.7.9l1.3 7.3q.2.7.3.7t.4-.3zm.6 6.8q0 .3.2.5.8.4 1.7.7c1.4.2 2.6-.7 2.8-2.2.3-1.5-.3-2.1-1.4-2.9-1.3-.9-1.8-1.1-1.7-2q.3-1 1.4-1c1.8.3 1.6 2.6 1.8 2.6q.4 0 .3-.4l.2-1.6v-.4h-.6c-.4 0-.7-.5-1.6-.7q-2-.1-2.5 2-.1 1.6 1.2 2.4c1.6 1.1 2.2 1.4 2 2.4q-.3 1.5-1.7 1.3c-1.2-.2-1.6-1.4-1.8-2.6q0-.3-.2-.3-.2.1-.2.5v1.7zm15.8-4.5c.3-.7.8-.6.8-.9q0-.2-.4-.2h-.9l-.9-.1q-.3 0-.4.2c0 .4 1 0 1 1.1q0 .3-.5 1.4l-1.8 3.5-.1.3-.1-.3-1.1-4-.3-1.6c0-.5.7-.3.7-.6q.1-.2-.4-.2h-1.2l-1-.2q-.4-.1-.6.1l.2.2q.6.2.7.8l2.1 7.1.4.7q.2.1.3-.4z"/>
<path d="M307.6 308.5c0 1.2-1 1-1 1.5q0 .2.3.1h2.2l.4-.1c0-.6-1.4.2-1.4-2v-4.2l.1-.1.2.1 5.1 6.3.3.1.2-.3v-6.7c0-1.3 1-1 1-1.3q0-.1-.3-.2h-2.3q-.2 0-.2.2c0 .4 1.3.2 1.3 1.3v4l-.1.4-.4-.3-4.2-5.3q-.1-.4-.4-.3h-1.8l-.2.1c0 .6 1.2-.2 1.2 2.1zM318 303c0-1.1.8-.7.8-1.1q.1-.2-.4-.2h-2.6s-.3 0-.3.2c0 .4 1.1 0 1.1 1.2v5.7c0 1.1-.8.8-.8 1.2q0 .1.2.2h2.8q.3 0 .3-.2c0-.4-1.2.2-1.2-1.3zm4.5 5.5c0 1.5-1.2 1-1.2 1.4q0 .3.4.2h3q.5 0 .5-.3c0-.3-1.4 0-1.4-1.4V303q-.1-.7.5-.6h1c1.2-.1.8 1.2 1.3 1.2q.2-.1.1-.6l-.1-1q0-.3-.2-.4l-3.3.1h-3.3l-.2.3-.1 1.6.1.4c.5 0 .2-1.6 1.4-1.6h.9q.5-.1.6.6v5.6zm6.3-2.2h-.4l.1-.5.7-2.2v-.2l.2.1 1 2.1.2.4q0 .3-.4.2zm1.8.5c.3 0 .3 0 .8 1l.2.8c0 .7-.7.6-.7 1q0 .2.4 0h1.2l1.3-.1q.4 0 .4-.2c0-.4-.6 0-1-.7l-3.4-7-.3-.4q-.2 0-.3.4L327 309c-.2.7-.8.7-.7 1h2.3q.4 0 .5-.3c.1-.3-1.2 0-1.3-.9l.2-1q.3-1 .6-.8l2.1-.2zm8.3-5c-.1-.8 0-.8 1.2-1 2-.2 1.4 1.3 2 1.2q.2-.1 0-.6l-.1-1.1q0-.2-.3-.2-1.4 0-2.4.3l-2.8.4q-.3 0-.3.2c.1.5 1.3 0 1.4 1l.7 5.5c.2 1.5-.7 1-.6 1.5q0 0 .2 0l1.4-.1 1.2-.1q.5 0 .5-.3c0-.3-1.2.1-1.4-1.2l-.2-1.7q-.2-1 .3-1h.8c1.1-.2 1 1.1 1.3 1q.4-.2.1-.5l-.3-2.1q-.1-.4-.2-.3c-.3 0-.1 1.1-1 1.2l-.7.1q-.6.2-.6-.5zm4 2.8c.4 2.3 2.1 3.7 4.2 3.3 3.4-.7 3.5-3.6 3.2-5.3-.5-2.5-2.3-3.7-4.4-3.3-2.5.5-3.5 2.7-3 5.3m1.1-1c-.3-1.6 0-3.4 1.7-3.7 1.4-.3 3 .8 3.4 3.4.3 2 0 3.6-1.8 4s-3-2-3.3-3.6zm8.3-4.1q0-.9.6-.9 1.6-.2 2.1 1.6c.2.7.3 1.4-1.3 1.8q-.5.1-.8-.2l-.5-2.3zm0 5.7c.4 1.4-.5 1.3-.5 1.6q.2.3.4.1.8-.1 1.6-.4l1-.2q.3 0 .2-.2c0-.4-1 .3-1.3-1l-.5-2c0-.4-.2-.4.4-.5q.6-.3 1.1.3l1.3 1.6c.5.6 1 1.3 1.8 1.1q.9-.2 1-.9l-.2-.1-.3.1s-.3.1-.4 0l-2.4-2.9.5-.6q.4-.6.2-1.6c-.1-.5-.7-2.1-3-1.6l-2.1.6-1.2.2q-.3 0-.2.2c0 .5 1.1-.2 1.4 1zm8.7-2c.3 1.4-1 1.2-.9 1.6q.1.3.5.2l1.4-.5 1.5-.3q.5.1.4-.4c0-.3-1.3.4-1.7-1l-1.3-5.3q-.2-.7.3-.7l1-.2c1.1-.4 1.1 1 1.5.9s0-.5 0-.7l-.4-1s0-.3-.2-.2l-3.2.9-3.2.7v.3l.1 1.6q0 .4.3.4c.5-.1-.3-1.6 1-1.9l.8-.2q.6-.2.7.5zm5.5-7.3c-.3-1 .6-.9.4-1.3h-.3l-1.4.4-1.2.3s-.3 0-.3.2c.1.4 1.2-.2 1.5.8l1.6 5.6c.2 1-.6 1-.5 1.3q0 .2.2.1l1.1-.3 1.6-.4q.4 0 .3-.3c-.1-.3-1.1.5-1.5-.9zm2.3 2.7c.7 2.3 2.6 3.4 4.7 2.7 3.2-1.1 3-4.1 2.4-5.7-.8-2.4-2.8-3.3-4.8-2.7-2.4.9-3.2 3.2-2.3 5.7m1-1c-.6-1.7-.6-3.5 1.1-4 1.3-.5 3 .4 3.9 2.9.6 1.8.5 3.6-1.2 4.2-1.8.6-3.2-1.5-3.8-3.2zm7.6-5.5q-.2-.9.4-1 1.7-.3 2.4 1.4c.2.6.4 1.3-1.1 1.9q-.6.2-.8 0zm.8 5.6c.6 1.4-.4 1.4-.2 1.7q0 .3.4.1l1.5-.7.9-.2q.3-.1.2-.3c-.2-.4-1 .4-1.4-.8l-.8-1.9q-.4-.5.3-.7.6-.3 1.1.3l1.6 1.4c.5.5 1.1 1.1 2 .8.3-.2.9-.7.7-1l-.2-.1-.2.2h-.5l-2.8-2.5.4-.7a2 2 0 0 0 0-1.6c-.1-.6-1-2-3.1-1.2l-2 .9-1.2.4-.2.2c.2.4 1.1-.4 1.6.8l2 5z"/>
</g>
<g fill="#fedf00" transform="matrix(.64 0 0 .64 0 16)">
<path fill="#d52b1e" d="M412.7 249.3h82.1v82h-82.1z"/>
<path id="ad-a" fill="#fff" d="M451.2 313.8s0 3-.8 5.3c-1 2.7-1 2.7-1.9 4a13 13 0 0 1-3.8 4q-3 1.9-6 1.6c-5.4-.4-8-6.4-9.2-11.2-1.3-5.1-5-8-7.5-6q-2 1.7-.3 4.6a9 9 0 0 0 4.1 2.8l-2.9 3.7s-6.3-.8-7.5-7.4c-.5-2.5.7-7.1 4.9-8.5 5.3-1.8 8.6 2 10.3 5.2 2.2 4.4 3.2 12.4 9.4 11.2 3.4-.7 5-5.6 5-7.9l2.4-2.6 3.7 1.2z"/>
<use xlink:href="#ad-a" width="100%" height="100%" transform="matrix(-1 0 0 1 907.5 0)"/>
<path d="m461.1 279 10.8-11.7s1.6-1.3 1.6-3.4l-2.2.4-.5-1.2-.1-1.1 3-.7V260l.3-1.3-3.2.2.3-1.4.5-1 1.9-.4h1.9c1.8-3.4 9.2-6.4 14.4-1 3.8 4 3 11.2-2 13.2a6 6 0 0 1-6.8-1.1l2-4c2.7 1.7 5-.3 4.8-2.4-.2-2.7-2-4.3-4.3-4.5q-3.5-.1-5 3c-.6 1.3-.3 2.2-.5 3.6-.2 1.5 0 2.3-.5 3.8a9 9 0 0 1-2.4 3.6l-11 12-43 46.4-3.2-3z"/>
<path fill="#fff" d="M429.5 283s2.7 13.4 11.9 33.5c4.7-1.7 7.4-2.8 12.4-2.8s7.6 1 12.3 2.8A171 171 0 0 0 478 283l-24.2-31z"/>
<path d="m456.1 262.4 16.8 21.7s-2.2 10.5-9 26.3c-2.7-.6-5-1.1-7.8-1.3zm-4.7 0-16.8 21.7s2.2 10.5 9 26.3c2.7-.6 5-1.1 7.8-1.3z"/>
</g>
<g fill="#d52b1e">
<path fill="#fedf00" d="M322.3 175.5h52.6V228h-52.6z"/>
<path d="M329.7 175.5h7.8V228h-7.8zm15 0h7.8V228h-7.8zm15 0h7.9V228h-7.9z"/>
</g>
<g fill="#d52b1e" stroke="#d52b1e" stroke-width=".5">
<path fill="#fedf00" stroke="none" d="M264.3 273.5q.1 1.6 1.4 4.3c1 1.5.6 1.4 2.7 3.8a15 15 0 0 0 4 2.9 33 33 0 0 0 15 2.6q4-.2 6.6-.7a71 71 0 0 1 11-.6q2.2 0 4.7.6c3.5.7 7 2 7 2v-54.7h-52.6V271l.2 2.4z"/>
<path stroke-width=".3" d="m270.4 283.1 2.5 1.5 3.4 1.2v-52.2h-5.9zm29.2 2.4v-51.9h-5.8v52.8l5.8-.7zm11.7-51.9h-5.8v52.1q2.8.4 5.8 1zm-23.4 0V287s-3.8.2-5.8 0v-53.4z"/>
</g>
<g transform="matrix(.64 0 0 .64 0 16)">
<path fill="#fedf00" d="M585.5 402.4a21 21 0 0 1-2.2 6.6c-1.5 2.3-1 2.3-4.3 6a26 26 0 0 1-13 7 52 52 0 0 1-16.6 1.6q-6.3-.4-10.3-1c-3.8-.6-6.7-.9-11-1h-6.2a83 83 0 0 0-18.3 4.2V340h82.2v58.5z"/>
<g id="ad-b">
<path fill="#d52b1e" d="m524.6 347-.6.2-.8.8q-.5.5-1.2.8l-.6.5c-.3.3 0 .6-.3 1q-.1.5-.6 1t-1 1l-1.2 1-.3.1h-.6q-.5.4-.8.8l.3.6.8 1.4q.2.6.5.8.7.3 1.3.1 1 .2 2 .5l1.5.8q.6.4 1.3.5h1.8v.3l2 1-.1.4q-.2.5-.1.8 1 2.9 1.5 3.2.8.4 1.1 1.5l-.3.3q-1 .8-1.7 1.8c-.7 1.2-1.2 1.2-.3 2.8l1.5 2.4q.5 1 .8 2t.3 2l1 .3.7-.6.6-1.2v-1q-.3-.2-.2-.7c0-.4.5-.3.7-.6.3-.5-.4-.8-.7-1.1-.6-.7-1.4-.9-1.6-1.9q-.1-.3.4-.7l2-1.8q.4.2 1 .1l1.3.4q.8.1 1.2 0h.4l.1.6c.1 1-.1 3 .2 3.5l.3.6.2.6v2l-.2 1.7q0 .6-.5 1t-1 .7v1l1.1.5 1.3.3.7-.3.1-.6.5-.5q.7-.1.9-.1.2-.3 0-.8 0-.8-.3-1.6l-.1-2.8q0-.8.2-1.5c.1-1 .4-1.4.6-2.2q.3-1.3.4-2.5a24 24 0 0 0 10.1-.6q1.2 1 2.7 1.6v1q0 .4.2.7l.3.3q.4 0 .7-.2t.2-.7v-.7h1.8v1.1q.2.4.5.4h.6q.3-.4.3-1v-.7l1-.4v.9l-.3.9c-.2.6-.5.8-.8 1.4q-.4.8-1 1.5l-.6.7-.6.9-.9 1c-.7.6-1.2.2-2 .9l-.3 1 1.4.6 1.3.2.4-.2q0-.5.3-.8t.7-.4q.6 0 1-.2.4-.6.7-1.5a13 13 0 0 1 3-3.9l1.7-1.4q.4-.4.5-1l-.2-.6-.2-1c1.5.7 1 .7 1.2 1.4.3.6 0 1 .1 1.7.1.8.5 1.1.5 1.9q0 1.1-.3 2.3 0 1-.5 2a4 4 0 0 1-1.1 1.5l-.6.5-.1 1 1.1.4 1.6.4.4-.3c.2-.7 0-1.7.4-1.7q.6 0 .8-.3v-.7l.7-4.5.4-1.9.4-1.7c.7-2-.2-2.3-1-3.6q-.7-.9-.7-1.5v-5.7l.4-.2c1.2-.7 1.7-.9 2.4-2.5l.3-1.5v-1l-.4-1-.6-.8c-.7-1-1.7-1.1-2.7-1.5-1.5-.5-2.5-.4-4-.5-1.8-.2-2.7-.2-4.4 0-2 0-3.1.4-5.1.7l-4.9.4c-2.3 0-4.4-.5-5.8-.4-2.4.2-2.5.8-6.2 1.1l-3.8.2-2.2-.7c.9-.3 1.1-.5 1.5-1s.2-.7.6-1.1l.7-1-.9-.4h-1l-1.2.3-.8.6-2.2-1.2a9 9 0 0 0-3-.9zm2 11.8"/>
<g fill="none" stroke="#fedf00" stroke-linecap="round">
<path d="m568.8 359.5-.8.3q-1.2.5-2.6.5c-2.6.2-4.3-1.1-7-.9-1.4.1-2 1.2-3.5 1.6l-1.7.2.5-1s-1.2.3-2 .3l-1.6-.2 1-1-1.3-.2-1-.7 1.7-.3c1.5-.4 2-1.2 3.9-1.4 1.1 0 3 0 7.6.8 3 .5 4.4.2 5.5-.3q1-.5 1.1-1.8 0-1.2-.8-1.8-.2-.1-1.1-.4"/>
<path fill="#fcd900" stroke-linecap="butt" stroke-width=".5" d="M524.8 350.6q-.7 0-1.3.3-.6.5-1 1.1.7.3 1.2.3t.8-.5q.4-.5.4-1.2z"/>
<path d="m536 363.8 1 2.3c.2.8 0 1.2.2 2v1.6m6.8-7-.3 1.3-1 3.5v.7m-11-4c.9.2.6 3.3 1.9 4"/>
<path stroke-linecap="butt" d="m560.1 369.8.4-.3a8 8 0 0 0 2.7-1.8"/>
<path d="M552.4 368c3.5-.9 5.9-2.6 7.6-2.9m-4-1.5h.8c1.5-.3 1.7.6 2.7 1.2 1.9 1 2.1 2.3 4.3 3.4l.4.1.8.4"/>
<path fill="#fcd900" stroke-linecap="butt" stroke-width=".5" d="M517.7 354.5h.7l.8-.2q.4 0 .7.2.2 0 .3.3t.1.5q0 .3-.6.4-.3 0-.5-.3v-.4a1 1 0 0 1-.9 0z"/>
</g>
<path fill="#0065bd" d="m525.1 364.2-2-.9q.6-.2 1-.5.3-.6.5-1.3.1-1 .7-1.4t1.1-.1q.7.1.9.7 0 .8-.3 1.5-.2.8-.2 1.4t.4 1l-2-.4zm-1 1a.6.6 0 1 1 .7.5.6.6 0 0 1-.7-.6zm-1.7-16.6h-.2q-.4-.6-.6-1.2l-.3-1.2v-2q0-.5-.2-.9c0-.2-.4-.3-.3-.4q.1 0 .4 0 .5 0 1 .4t.6 1l.4 1.5.3.8.5.6-.7.8zm3.6 10.6 2.2 1a9 9 0 0 0 3.5-3.8c.9-1.8 1-2.7 1.4-4.4l-1.8-.5h-.4c-.5 1.8-.7 2.7-1.6 4.2q-1.2 2-2.6 3zm5 18.2.8-1.3 1.4-1.1h.4a9 9 0 0 1-.5 2.8l-.4 1-.5.5c-.5-.8-1.3-1.3-1.3-2zm33 1.8 1.4.6 1.5.9v.5l-1.5.2h-2.3l-.6-.4c.5-.7.8-1.6 1.4-1.8zm-9.8-2 1.4.5 1.5 1q0 .2 0 .4a9 9 0 0 1-2.7.3l-1-.1-.7-.3c.6-.7.9-1.7 1.5-1.8m-17.4 2.1 1.5.5 1.5 1v.5a9 9 0 0 1-2.8.2h-1l-.6-.4c.5-.7.8-1.6 1.4-1.8m-9-29.8q-1-.6-.6-1.6.2-.2.6-.4.2-.4 0-.8l-.1-1-.2-1q-.1-.8.4-1.6.5-.5.8-.6.2.3 0 .8 0 .6.3 1.2l.7 1.3q.3.7.4 1.4 0 .6-.2 1.2l-.6.8-.8.4z"/>
</g>
<use xlink:href="#ad-b" width="100%" height="100%" y="36.6"/>
</g>
<path fill="none" stroke="#703d29" stroke-width=".5" d="M264.1 175.5h52.6V228h-52.6zm58.2 0h52.6V228h-52.6zm-58 98q.1 1.6 1.4 4.3c1 1.5.6 1.4 2.7 3.8a15 15 0 0 0 4 2.9 33 33 0 0 0 15 2.6q4-.2 6.6-.7a71 71 0 0 1 11-.6q2.2 0 4.7.6c3.5.7 7 2 7 2v-54.7h-52.6V271l.2 2.4zm110.4 0a13 13 0 0 1-1.4 4.3c-1 1.5-.6 1.4-2.7 3.8a15 15 0 0 1-4 2.9c-1.3.7-2.3 1-4.4 1.6a33 33 0 0 1-10.6 1q-4-.3-6.5-.7l-7.2-.6H334q-2.2 0-4.7.6c-3.5.7-7 2-7 2v-54.8H375v37.5l-.2 2.4z"/>
</svg>

After

Width:  |  Height:  |  Size: 30 KiB

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-ae" viewBox="0 0 640 480">
<path fill="#00732f" d="M0 0h640v160H0z"/>
<path fill="#fff" d="M0 160h640v160H0z"/>
<path fill="#000001" d="M0 320h640v160H0z"/>
<path fill="red" d="M0 0h220v480H0z"/>
</svg>

After

Width:  |  Height:  |  Size: 266 B

+81
View File
@@ -0,0 +1,81 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" id="flag-icons-af" viewBox="0 0 640 480">
<g fill-rule="evenodd" stroke-width="1pt">
<path fill="#000001" d="M0 0h640v480H0z"/>
<path fill="#090" d="M426.7 0H640v480H426.7z"/>
<path fill="#bf0000" d="M213.3 0h213.4v480H213.3z"/>
</g>
<g fill="#fff" fill-rule="evenodd" stroke="#bd6b00" stroke-width=".5" transform="translate(1 27.3)scale(1.06346)">
<path d="M319.5 225.8h8.3c0 3.2 2 6.6 4.5 8.5h-16c2.5-2.2 3.2-5 3.2-8.5z"/>
<path stroke="none" d="m266.7 178.5 4.6 5 57 .2 4.6-5-14.6-.3-7-5h-23l-6.6 5.1z"/>
<path d="M290 172.7h19.7c2.6-1.4 3.5-5.9 3.5-8.4 0-7.4-5.3-11-10.5-11.2q-1.4-.1-1.9-1.3c-.5-1.6-.4-2.7-1-2.6-.4 0-.3 1-.7 2.4q-.6 1.3-2 1.6c-6.4.3-10.6 5-10.5 11.1.1 4 .6 6.4 3.4 8.4z"/>
<path stroke="none" d="M257.7 242.8H342l-7.5-6.1h-69.4z"/>
<path d="m296.4 219.7 1.5 4.6h3.5l-2.8-4.6zm-2 4.6 1 4.6h4l-1.5-4.6zm7 0 2.8 4.6h5.9l-4.6-4.6zm-34.5 10.4c3.1-2.9 5.1-5.3 5.1-8.8h7.6q0 3 1.8 3h7.7v-4.5h-5.6v-24.7c-.2-8.8 10.6-13.8 15-13.8h-26.3v-.8h55.3v.8H301c7.9 0 15.5 7.5 15.6 13.8v7h-1l-.1-6.9c0-6.9-8.7-13.3-15.7-13.1-6 .1-15.4 5.9-15.3 13v2.2l14.3.1-.1 2.5 2.2 1.4 4.5 1.4v3.8l3.2.9v3.7l3.8 1.7v3.8l2.5 1.5-.1 3.9 3.3 2.3h-7.8l4.9 5.5h-7.3l-3.6-5.5h-4.7l2.1 5.4h-5l-1.3-5.4h-6.2v5.8H267zm22.2-15v4.6h5.3l-1-4.6H289z"/>
<path fill="none" d="M289.4 211.7h3.3v7.6h-3.3z"/>
<path fill="none" d="M284.7 219.8h3.2v-5.6c0-2.4 2.2-4.9 3.2-5 1.2 0 2.9 2.3 3 4.8v5.8h3.4v-14.4h-12.8zm25.6 3.3h4v3.2h-4zm-2.4-5.3h4v3.1h-4zm-3.9-5.4h4v3.1h-4zm-3.3-4.5h4v3.1h-4z"/>
<path fill="none" d="m298 219.8 4.2.2 7.3 6.4v-3.8l-2.5-1.8v-3l-3.6-2v-3.3l-3.5-1.2V207l-1.7-1.5z"/>
<path d="M315.4 210.3h1v7.1h-1z"/>
<g id="af-a">
<path d="M257.3 186.5c-1.2-2-2.7 2.8-7.8 6.3-2.3 1.6-4 5.9-4 8.7q.1 3 0 5.8c-.1 1.1-1.4 3.8-.5 4.5 2.2 1.6 5.1 5.4 6.4 6.7 1.2 1 2.2-5.3 3-8 1-3 .6-6.7 3.2-9.4 1.8-2 6.4-3.8 6-4.6z"/>
<path fill="#bf0000" d="M257 201.9a10 10 0 0 0-1.6-2.6 6 6 0 0 0-2.4-1.8 5 5 0 0 1-2.4-1.5 4 4 0 0 1-.8-1.5v-2l-.3.3c-2.3 1.6-4 5.9-4 8.7v2.3q.2.8.6 1.3l1.1.8 2.7.7a7 7 0 0 1 2.6 2 11 11 0 0 1 1.8 2.6l.2-.8c.8-2.7.7-5.9 2.6-8.5z"/>
<path fill="none" d="M249.8 192.4c-.5 3.3 1.4 4.5 3.2 5.1 1.8.7 3.3 2.6 4 4.4m-11.7 1.5c.8 3 2.8 2.6 4.6 3.2s3.7 3 4.5 4.8"/>
<path d="m255.6 184.5 1-.6 17.7 29.9-1 .6z"/>
<path d="M257.5 183.3a2 2 0 1 1-4 0 2 2 0 1 1 4 0zm15.2-24h7.2v1.6h-7.2zm0 3.1h7.2v13.8h-7.2zm-.4-5h8c.2-2.7-2.5-5.6-4-5.6-1.6.1-4.1 3-4 5.6z"/>
<path fill="#bd6b00" stroke="none" d="M292.6 155.8c-1.5.6-2.7 2.3-3.4 4.3s-1 4.3-.6 6.1q.1 1 .5 1.5.3.5.6.5.5 0 .7-.3l.2-.8q-.2-3 .3-5.4a8 8 0 0 1 3-4.4q.4-.3.5-.7l-.3-.7q-.7-.5-1.5-.1m.2.4q.6-.2 1 .1l.1.2q0 .1-.3.4a8 8 0 0 0-3.1 4.6 17 17 0 0 0-.3 5.6l-.2.6s0 .1-.2 0q0 0-.4-.3l-.4-1.2q-.4-2.9.7-6 1.1-2.9 3-4z"/>
<path fill="#bd6b00" stroke="none" d="M295.2 157.7q-2.3 1.2-3 4.2a14 14 0 0 0-.3 5.9q.5 2 1.6 2 .5.2.8-.3t.2-1q-.6-2.5-.3-5.1.4-2.6 2.2-4.1.5-.4.5-.8l-.2-.6q-.7-.5-1.5-.2m.2.5q.6-.2 1 0l.1.3-.3.4a7 7 0 0 0-2.4 4.4q-.4 2.8.2 5.2 0 .6 0 .8l-.5.1c-.3 0-1-.5-1.2-1.7-.3-1.7-.2-3.9.3-5.7q.8-2.9 2.8-3.8"/>
<path d="M272.3 187.4h8v11h-8zm.5 17.4h7.7v2.4h-7.7zm-.2 4.1h8v8.7h-8zm-.6 10.5h8.7v4.9H272zm1.1-16.6h7l1.4-2.4h-9.6zm9.4-8.6.1-6h4.8a17 17 0 0 0-4.9 6z"/>
<path fill="none" d="M273.6 196.7c0 1.3 1.5.8 1.5.1v-5.6c0-1 2.4-.8 2.4-.1v6c0 1 1.7.9 1.6 0v-7c0-2.2-5.5-2.1-5.5-.1zm0 13.3h5.7v7h-5.7z"/>
<path d="M277.2 213h2v1h-2zm-3.5 0h2v1h-2zm2-3h1.5v3h-1.5zm0 4h1.5v3.1h-1.5zM244 139c.4 5.5-1.4 8.6-4.3 8.1-.8-3 1-5.1 4.3-8.1zm-6.5 12.3c-2.6-1.3-.7-11.5.3-15.8.7 5.5 2 13.3-.3 15.8z"/>
<path d="M238.4 151.8c4.4 1.5 8-3.2 9.1-8.7-3.6 5-9.5 5-9 8.7zm-3.3 5.1c-3.4-.9-1.4-11.7-.7-16 .7 4.5 3.1 14.5.7 16zm1.2-.3c.2-3.7 3.9-2.7 6.5-4.7-.5 2-2 5.2-6.5 4.7zm-4.2 5c-3.4-1-1.4-12.6-1.6-17.4 1 4.2 4.2 16.3 1.6 17.4zm1.6-.5c2.8.9 6.5-1 6.8-4.3-2.5 1.7-6.3.4-6.8 4.3z"/>
<path d="M229.5 166.7c-3.2.3-1.8-9.6-1.8-18.8 1.2 8.6 4.5 16.5 1.8 18.8z"/>
<path d="M230.7 166.3c2.2 1 6.1-.7 7.2-4.4-4 1.7-6.6 0-7.2 4.4zm25.6-22.2c-.6 4.9-2.6 7.7-5.5 7.2-.8-3 1.6-5 5.5-7.2zm-7.8 12.4c4.9.7 6.6-3 10-7.9-4.7 3.4-10.2 4-10 8z"/>
<path d="M247 156c-2.6-3.2 0-7.3 2-10.7-.4 5.1 1.3 8-2 10.7zm-1 5.3c-.4-3.2 5-3.9 7.4-5.6-.9 1.8-2 6.7-7.5 5.6z"/>
<path d="M244.8 161.3c-3.7-.4-2.2-6.7.5-10.1-1.1 4.8 2 8.1-.5 10.1z"/>
<path d="M242 166.6c-4.2-2-1.5-7.2 0-10.3-.6 4.1 2.8 7.2 0 10.2z"/>
<path d="M242.8 166c2.2 3 6.5-.8 7.4-5.2-3.7 3.1-6.5 2.6-7.4 5.3zm-9.6 20.3c-.4-4.3 2.8-12 .5-16.2-.3-.6.7-2.1 1.4-1.2 1 1.5 2 5.7 2.5 4.1s.5-4.6 2-5.2c1-.3 2.3-.6 1.9 1-.4 1.4-1.2 3.4-.3 3.5.5 0 2-2 3.3-3 1-.8 2.6.6 1 1.8-4.8 4-9.5 5.9-12.3 15.2zm-8.7 64.5c-.6 0-1.3-.3-.6.6 5.7 7 7.3 9 15.6 8 8.3-1.1 10.3-3.4 16.2-6.7a15 15 0 0 1 11.2-1c1.6.5 2.6.5 1.4-.7s-2.5-2.7-4-3.8a18 18 0 0 0-12.7-2.7c-6 1-11.1 4.9-17.2 6.4a25 25 0 0 1-9.9 0zm47.8 12.5c1 .2 1.7 2.2 2.3.9.8-2.3.2-4-.8-3.9-1.2.3-3.1 3-1.5 3z"/>
<path stroke="none" d="M220.6 183q-1.8-2 1-1.9c1.4 0 4.2 1 5.3.1 1-.7.5-3.7 1-5 .2-.9.7-2 2-.2 3.6 5.8 8 12.8 10 19.6 1 3.8 0 9.8-3.4 13.8 0-3.4-1.2-5.7-2.7-8.6-2-3.7-9.1-14-13.2-17.9z"/>
<path d="M235.5 213.4c4 0 4.7-5.3 4.7-6.8-2 .4-5.4 3.7-4.7 6.8zm34.5 51.9c2.8.6 2.7-6.2-.2-9.1 1.3 4.4-2 8.4.1 9zm-1.2-.1c.2 3.2-8-.4-10-3 4.8 2.1 9.8.4 10 3zm-3.5-4.6c.3 3.1-7 .3-9.3-2.1 4.9 1.6 9-.5 9.3 2zm1.3.4c2.9.7 2.4-6.4-.4-8.8 1.4 4.7-1.8 8.1.4 8.8zm-3-4.3c2.9.7 1.2-5.4-.9-7.8.4 4.4-1 7.5 1 7.8zm-1.5 0c.3 3.2-5.4.8-7.6-2.3 4.8 1.5 7.3-.3 7.6 2.3zm-1.5-2.5c1.8-1.3-.1-4.8-3.7-4.6.4 2.1 1.6 5.9 3.7 4.6zm14 14.7c.1 3.2-8 1.6-10.6-1.8 5.2 1 10.3-.8 10.5 1.8zm-32.4-5.8c.3 3.2-8.6-.4-10.8-3.4 4.7 1.6 10.5.8 10.8 3.4zm5.4 1.3c1.9-1.3-1.9-4.7-5-5.5.4 2.1 3 6.8 5 5.6zm.6 2.3c.2 2.9-9.5 1.3-12-1.4 8.3 1.5 11.7-1.1 12 1.4z"/>
<path d="M252.8 268.6c1 2.7-8.3 2-11.6.5 5.3 0 10.8-2.4 11.6-.5z"/>
<path d="M257.1 270.6c1 2.4-7.6 2.4-11.8 1 5.6 0 10.8-3.4 11.8-1zm6.3 1.3c1.6 2.9-7.6 3.1-10.5 1.7 5.2-.7 9.2-4 10.5-1.7zm-10.7-4.9c-2.9 1.8-2.7-3.6-5-7.3 3.6 3.3 7 5.6 5 7.3z"/>
<path d="M257.9 269c-2.4 2.1-4.4-5.3-6.6-9.5 3.6 4 8.8 7.7 6.6 9.4zm6.8 2c-2 2.4-8-7-10.2-12 3.3 3.9 11.8 10 10.2 12zm-5.8 7.2c-1 3.6-16.2-3.4-18-7.1 8.8 4.6 18.2 3.6 18 7zm-48.7-73.8c-.4-.5-1.4 0-1.2 1.1.3 1.5 2.5 9.2 6.3 11.8 2.7 2 17 5.1 23.4 6.5q5.3 1 8.9 5.3a94 94 0 0 0-3-9.8c-1.2-3-4.4-6.2-7.8-6.3-6.1-.3-14.1-.8-20-3.3a16 16 0 0 1-6.7-5.3z"/>
<path d="M245.5 234.9c2 1.4 4.1-3.7 1.7-8.6-.1 4.7-3.8 6.3-1.7 8.6z"/>
<path d="M247.4 239.6c2.7.8 3.5-4 1.8-7.8.3 4.1-4.3 6.6-1.8 7.8z"/>
<path d="M249.5 243.4c2.6 1.3 3.5-3.6 1.7-7.1.2 4.5-3.7 5.9-1.7 7z"/>
<path d="M248.4 243.7c-1 3-7-2.7-8-5.8 3.7 3.7 8.7 3.2 8 5.7z"/>
<path d="M245.7 239c-1.2 3-8.7-5-10.4-8.7 3.7 3.7 11.2 6.5 10.4 8.6z"/>
<path d="M244.2 234.3c-1.2 3.5-9.3-5.8-11.7-9.1 4 3.6 12.6 6.6 11.7 9.1zm-.3-3.4c3-.6-.1-3-3.7-6.9-.1 4.1.5 7 3.7 6.9z"/>
<path d="M239 228.5c1.3-1.3-1.1-1.9-4.1-5.3-.5 2.3 2.8 6.5 4.2 5.3zm14 15.2c1.6 1 2.6-2.3.7-5.2-.5 3.2-2.1 4-.7 5.2zm-34.2-20.3c-3.3 2-8.6-6-10-9.3 2.9 3.8 10.6 7.2 10 9.3z"/>
<path d="M221.7 228c-1.9 2-7.7-3.5-9.7-6.3 3 2.7 10.5 3 9.7 6.3z"/>
<path d="M224.8 232.2c-.6 2.8-9-3.5-11-6.5 3.6 3.5 11.6 3.2 11 6.5z"/>
<path d="M223.5 235.3c-1.3 2.5-8.2-3.8-9.9-7 4.3 3.6 11 4.5 10 7zM220 223c2.1-2.3 1.2-3.4-.4-7-.8 3.7-2.1 5.2.4 7zm2.9 4.3c4 .2 0-4.6-1-8.7.4 4.6-1 8.3 1 8.7z"/>
<path d="M225.4 231.1c2.7-.6 2-4.5-.2-9.2.5 5.1-2.3 8 .2 9.2zm-1 7.7c-1 3-8.8-4-10-6.8 4 3.4 10.7 4.5 10 6.8z"/>
<path d="M229.1 243.6c-1.1 3-9.3-3.2-11.8-6.6 4.9 4 12.4 3.6 11.8 6.6z"/>
<path d="M233.9 248.5c-1.3 4.3-9.9-2.6-12.4-6 5.4 4.2 13 3 12.4 6zm-8-11c2.3 1.1 3.2-5.4 1.9-10.1 0 5-4.7 8.8-2 10z"/>
<path d="M229.8 242.7c2.8.8 2-6.3-.5-11-.3 4.7-2.3 9 .5 11zm5 4.9c3 .1 1-6.1-1.6-9.6.4 4.5-1 9 1.6 9.6zm-5.5 2.6c-1 1.6-3.2-1.3-7-3.5 3.4 1 7.4 2 7 3.5zm-1.8-52.7c3-2.2.7-6.2 0-10-1 3.6-3.4 8.4 0 10zm0 5.3c-4.5-.5-3.8-6.1-4-9.7 1.4 4.9 5 5.7 4 9.8zm.6-.7c3.7-.2 3.5-4.4 3.7-8.6-1.9 3.9-4 4.5-3.7 8.6z"/>
<path d="M228 207.3c-3 .3-4.4-2.6-5-7 2.7 4.1 5.1 2.8 5 7zm1-.3c3.7.5 3-3.8 3-7-1.2 3-4.2 4-3 7z"/>
<path d="M223.2 205.2c.3 2.8 2.1 7.6 5 6.5 1.1-3.4-2.6-4.1-5-6.5z"/>
<path d="M229 212c-1.2-2.4 3-3.7 3.8-6.9.5 4.6.1 7.6-3.8 7zm-11.9-29.2c2.3-2.4.3-6.4-.4-10.2-1 3.6-2.5 8.4.4 10.2zm0 4.6c-4 .5-5-7.7-5.5-11.3 1.4 4.9 6 7 5.5 11.4zm.8 0c2.8-1.5 2.2-4.7 3-7-1.8 2.9-3.6 3.3-3 7z"/>
<path d="M217 192.8c-4.1.3-6.6-8.8-6.8-12.4 1.3 4.9 7.4 7.5 6.9 12.4zm.9-.2c4-.9 3.5-3.5 2.9-7.6-1.3 4.2-3.5 3.3-2.9 7.6z"/>
<path d="M217 198c-4.6.8-4.3-6.6-8-11.9 3.2 4 9 9 8 11.9zm1-.3c3.6.2 4-5.1 3.8-7.3-.9 2.2-5 4.2-3.7 7.4z"/>
<path d="M209.8 192.3c1.7 5.7 4.2 11.4 7.2 11 1.5-3.3-2.9-3.7-7.2-11z"/>
<path d="M218.1 202.4c-1.2-2.5 3-3.7 3.8-6.9.5 4.6.1 7.6-3.8 6.9zm-7.1-3.6c2.5 5.1 3.6 11 7 10.1 1.3-4-3.8-4.8-7-10.1z"/>
<path d="M218.7 208c-1.5-2.8 2.7-3.7 3.8-7.4.5 4.8 0 8.3-3.8 7.3zm7.2-34.5c2.4.6 5-2.1 4.1-6.2-2.8.6-4 3.2-4.1 6.2zm-7.9-2.1c.2 1.2 1.7 1.3 1.2-.4a5 5 0 0 1 0-3.4 8 8 0 0 0 0-4.6c-.4-1-1.8-.4-1.2.4s.7 2.8.2 3.7q-.7 2.2-.2 4.3zm22.9 16c-1 1.3-2.9.4-1.4-1.5 1.2-1.5 3-2.8 3-4.4.2-2 1.3-5 2.4-6.1s2.4.4 1.2 1.2c-1.3.8-2.2 4.4-2.1 5.8-.1 2-2 3.5-3.1 5zm-3-2.3c-1 1.4-2.4.5-1.6-1.7.7-1.5.8-3.5 1.6-4.6 1.2-1.7 3-3.1 4.1-4.2 1.2-1 2 0 1 1a27 27 0 0 0-3.3 4c-1.4 2.2-.8 4-1.8 5.5zm-15.7-7.2c-.1 2 1.5 2.4 1.4-.4 0-3-2.2-5.8-1-10.3.8-2.2.8-6.3.4-8.4s-2-.8-1.3.9c.6 2-.1 5.6-.6 7.5-1.5 5.4 1.2 8 1 10.7zm4.3-11c-.2 1.9-1.8 2-1.3-.5q.6-2.9 0-5.3c-.6-2.1-.4-5.7 0-7.2.5-1.6 2-.7 1.4.5a10 10 0 0 0-.3 5.9c.6 2 .5 4.8.2 6.7zM210.9 204c.8.9 2 .3 1-1-1-1-.7-1.2-1.3-2.4-.6-1.4-.5-2.1-1.2-3-.7-1-1.6 0-1 .7.8 1 .6 1.6 1 2.5 1 1.5.7 2.3 1.5 3.2zm20.4 24.6a9 9 0 0 1 4.4 6.7 16 16 0 0 0 2 7.1c-2-.5-3-3.7-3.3-6.8-.3-3.2-2-4.5-3-7zm5.1 5.9c1.7 3.1 4 4.3 4.2 6.6.2 2.7.4 2.8 1.1 5.4-2-.5-2.5-.7-3-4.7-.3-2.8-2.6-4.7-2.3-7.3z"/>
<path stroke="none" d="M289 263.3c1 1.8 2 4.5 4 4 0-1.3-2.1-2.3-4-4m3 .6c3.7 1.6 7 1.2 7.5 3.6-3.6.4-5-1-7.6-3.6zm-16.1-12.7a14 14 0 0 1 5 7.7 29 29 0 0 0 3.6 7.8 13 13 0 0 1-5.3-7.4c-.7-3-1.6-5.3-3.3-8zm3.1 0c2.8 2.2 5.4 4.8 6.2 7.9.8 2.9 1.3 5.1 3.2 8-3-1.9-4.1-4.7-5-7.8-.7-3-2.5-5.2-4.4-8zm9.2 7.3a1 1 0 0 1 .7-1.2l2.6-.8c1-.3 1.6.4 1.6.9v2q0 .9-.7.9-1.2 0-2.4.7-1 .5-1.5-.5zm10.6 0q0-1-.6-1.2a5 5 0 0 0-2.4-.4q-1.3 0-1.1.6v2.1c0 .8 0 .8.4 1q1.3-.1 2.5.6.9.4 1.1-.6z"/>
</g>
<use xlink:href="#af-a" width="100%" height="100%" x="-600" transform="scale(-1 1)"/>
<g stroke="none">
<path d="M328.5 286.6q-.1 1.8 1 3.1a19 19 0 0 0-13.8 1.1c-1.8.8-4-1-1.9-2.7 3-2.3 9.7-1 14.7-1.5m-57.5 0a7 7 0 0 1-.4 3c4.4-1.7 9.1-.2 13.6 1.6 3 1.3 3.3-1 2.8-1.7a7 7 0 0 0-5-2.9zm3.8-21.7q-2-.7-4 1.4c-4.3 4.2-9.4 8.3-13.5 11.6-1.5 1.3-3 3.7 3.4 6 .3.2 5 2 8 2 1.3 0 1.3 1.8 1 2.3-.5 1-.1 1.4-1.1 2.3-1.1 1 0 2.1 1 1.3 3.6-3.2 9.6-1.1 15.3.7 1.4.4 3.8.3 3.8-1.6s1.5-3.4 2.4-3.5c2.4.4 14 .5 17.5.1 2-.3 2.2 2.9 3.3 4 .8.9 3.7 1.1 5.8.2 4-1.8 10-1.8 12.5 0 1 .7 1.9 0 1.3-.7-.8-1-.7-1.6-1.1-2.4-1-2-.2-2.4.8-2.5 11-1.5 14.6-5.2 11.2-8.3-4.4-3.8-9.2-7.7-13.4-12.2-1.2-1.2-2-1.7-4.3-.7a67 67 0 0 1-25.3 5.9 76 76 0 0 1-24.6-5.8z"/>
<path fill="#bd6b00" d="m326.6 265.5-1.6.4c-9 3.2-17.2 5.4-25.7 5.4-8.3 0-17-2.4-24.9-5.6a2 2 0 0 0-1.5 0q-.8.2-1.3.7a116 116 0 0 1-11.8 10.3c-.7.5-.6 1.8.5 2.2 8.3 3 16.4 8.5 39.6 8.3 23.5-.2 31.8-5.6 39.2-8.1q.8-.3 1.3-1l.1-.8-.6-.8c-4.3-3.5-8.8-6.3-11.8-10.4q-.5-.7-1.5-.5zm0 .5q.9 0 1.1.3c3 4.3 7.7 7 11.9 10.5l.4.7v.4q-.3.5-1 .7c-7.6 2.6-15.7 8-39 8.2-23.2.2-31.2-5.3-39.5-8.3-.8-.4-.7-1.2-.4-1.4q6.4-4.9 11.8-10.4l1.1-.6h1.2a68 68 0 0 0 25 5.6c8.7 0 17-2.2 26-5.3l1.5-.4z"/>
<path d="M269.7 114.6c0-1.4 2-1.5 1.8.4-.3 2.3 4.5 8.3 4.9 12 .3 2.5-1.5 4.6-3.2 6a7 7 0 0 1-6.8.5c-.9-.8-1.7-3.3-1-4.3.2-.3 1.3 3.7 3.7 3.7 3.3 0 6-2.5 6-4.7.2-3.8-5.3-9.8-5.4-13.6m9.5 9.4c.6-.4 1.4 1.3.8 1.7s-1.5-1.3-.8-1.8zm1.5-3.5c-.3.2-.8 0-.7-.2a12 12 0 0 1 3.6-3.3c.4-.2 1 .4.8.7a11 11 0 0 1-3.7 2.8m12.6-10c.3-.6 2.1-1.3 2.6-1.7.4-.5.6.4.4.7-.3.7-1.9 1.7-2.6 1.8q-.6-.1-.4-.7zm4.3.3a8 8 0 0 1 2.5-3.4c.5-.3 1.3 0 1.1.4a9 9 0 0 1-2.9 3.3c-.3.3-.8 0-.7-.3m-3.7 2.7q-.3.5.1.8 1 .3 2 0c.6-.4.3-2.9-.5-1.6-.6.8-1 .6-1.6.8m-7.3 5.6c-1.3-1 .4-2.4 1.7-1.4 2.7 2-4 9.8-7.6 13.4-.7.7-1.3-1-.4-1.9a34 34 0 0 0 6.7-7.6c.4-.5.7-1.6-.4-2.5m15.3-6.6c.1-1-1.6 0-1.6-1.3 0-.7 1.9-1.2 2.7-.4 1.3 1.4.3 3.7-2 3.9-1.8 0-5 2.7-4.5 3.2.5.7 5.4 1.1 8.3.7 1.8-.3 1.4 1.3-.4 1.5s-3.2 0-4.8.6c-2 .5-2.8 3-3.9 4-.2.2-.8-.8-.6-1.2.8-1.2 2-3 3.4-3.6.8-.3-2.4-.4-3.4-.7-.8-.2-.6-1.3-.3-1.9.4-.8 3.4-3.9 4.7-3.8 1.1 0 2.3-.3 2.4-1m5 .2q.8-.9 1.5-1.8c.3-.3.9 0 .8.8-.1.7-1 1.2-1.5 1.7-.5.3-1-.4-.7-.7zm6.5-2.3c.9 0 1 1.6.2 1.8-.6.2-1-1.7-.2-1.8m-2.1 5c0 1.5.7 1.4 2 1.3s2.4 0 2.4-1.2c0-1.3-.7-2.5-1-1.6-.1.8-.3 2.2-.8 1.6-.4-.5-.2-.6-1 .2-.5.5-.5-.2-.8-.6-.2-.3-.8.2-.8.4zm-9.2 7.2c-.3 1.9 0 4.5.9 4.5 1.2 0 3.6-4 4.8-6.2.7-1.2 1.8-1.4 1.3-.1-.7 1.9-.6 6 0 7.2.4.6 3-.6 3.4-1.5.8-1.7.1-4.8.4-6.7.1-1.2 1.3-1.5 1.2-.3l-.1 7.5c0 1 2.9 2.4 3.3-.6.2-1.8 1.2-3.7 0-5.7-.8-1.3 1.1-1.2 2.1.6.7 1.2-.6 3.2-.5 4.7 0 2.4-1.8 3.8-3.1 3.8-1.2 0-2-1.5-3-1.5s-2.2 1.7-3 1.6c-3.6-.2-1.7-5.3-2.8-5.4-1.2 0-2.5 5-4 4.9-1.4-.2-3-4.2-2.3-5.8.5-1.6 1.5-2 1.4-1m16.9-8c-1.7-1 0-3.7.9-2.8 1.6 2 3.2 6.5 4.4 6.9.7.2.6-3.4 1.1-5 .4-1.3 1.8-.9 1.6.7-.1.5-2 6.4-1.8 6.6a47 47 0 0 1 3.3 7.8c.3 1.2-1.1.4-1.3.2-.9-1.4-2.4-6.5-2.4-6.2l-1.7 7.7c-.2 1-1.7.8-1.3-1 .3-1.4 2.3-8.3 2.2-8.6a17 17 0 0 0-5-6.3"/>
<path d="M322 131.2c-.4 0-1.2 1 1.2 1.5 3.1.6 6.6-.5 7.6-3.6 1.3-3.7 2-7.2 2.7-8.5.8-1.5 1.8-1.4 1-3.6-.5-1.7-1.5-1.2-1.7-.3-.5 2.3-2.6 10-3.3 11.3q-1.8 3.8-7.5 3.2"/>
<path d="M328.4 119c-.4-.7-1.2 0-1 .7a1 1 0 0 0 1.2 1c.7 0 2.2.1 2.2-1 0-.8-.7-1.5-1.1-.6q-.8 1.1-1.3 0zm.7-3c-.2.2 0 1.1.3 1a7 7 0 0 0 3.3-.8c.2-.2.1-.7-.2-.7-1 0-2.6 0-3.4.5m8.8 2.3c.8-1.2 2.8-1.3 2 .4l-6.3 12.3c-.8 1.4-1.4.7-.8-.4.7-1.4 4.9-12 5.1-12.3"/>
<path d="M330.2 133c-.2-.8-1.5-2-1.3.2.2 3.8 5.5 2.6 7 1.3s.3 4.3 2.2 4.9c1 .3 3-1.1 4-2.4 2.7-3.5 4.5-8.6 7-12 1-1.4-.5-2.4-1-1.3-2.4 3.8-5.2 11.6-8.3 13.6-2.5 1.6-1.7-2-1.8-3.2-.1-.8-1.1-2-2.4-.9a6 6 0 0 1-3.7 1.2c-.7 0-1.4 0-1.7-1.4"/>
<path d="M339.6 126c0-.3-1.1-.4-1 .7 0 .8 1 1 1.1 1 1.5-1.2-.3-.6-.1-1.8zm-2.3 4.4c-.3 0-.6 1 .2 1.1l3.9-.2c.4 0 .6-.9-.4-.8-1.2 0-2.7-.3-3.7 0zm-62-16.6c.5 0 1.6 1.4 1.5 1.9 0 .2-1.2 0-1.5-.3s-.2-1.6 0-1.6m-5.3 10.4c-1 .6.2 1.7 1 1.2 2.8-1.9 7-3.8 8-7.5.3-1.2 1.4-3.1 2.5-3.5 1-.5 2.6 1.9 3.6 0 .6-1 2.7.7 3.2-.4.6-1.3.3-2 .3-3.4 0-.8-.7-1-1.2.3q-.1 1-.1 1.6-.4.4-1 .2c-.2-.2 0-.7-.6-1q-.4-.1-.8.2c-.7 1.3-1 2.5-2.1 1-.9-1-1.4-3.1-2-.3-.2 1-1.7 2.4-2.6 2.4-1.1 0-.8-3-3.2-2.5-1.3.3-1.2 2.7-1 3.5.3 1.3 4 .4 3.7 1.2-.6 2.7-4.4 5.4-7.7 7m-22.7 13.2c-.1.5.5 1.7 1.1 1.8.6 0 1-1.3.8-1.8-.2-.3-1.8-.3-1.9 0m3.3 4.9c-.4-.4-1.6.7-.6 1.5.5.5 2.5 1.1 3 .2.8-1.2-.7-5.5 0-6 .5-.5 2.8 2.8 4 3 2.7.4 2-4.6 5-4.2 1.9.2 2.1-2.2 1.8-3.8-.2-1.5-2.6-3.6-3.7-4.6-1.4-1.2-2.1 1-1.2 1.6 1.2 1 3.3 2.9 3.6 4.1.1.6-1.4 1.8-2 1.5-1.4-.8-2.6-4-3.8-4.7-.4-.2-1.4.3-1 1.3.6 1.1 3 2.7 3.1 3.9.1 1-1 3.2-1.8 3.2s-3-2.7-3.7-4c-.4-.5-1.5-.5-1.7.4a22 22 0 0 0 .5 5.5c.2 1.6-.9 1.7-1.5 1.1m-4-8.6c-.4.4.8 1.2 1 1 .4-.4 2.1-2.3 1.8-3-.3-.6-2.6-2-3-1.3-.7 1.1 2.2 1.7 1.7 2zm4.1-8.4s.8 2.5 1.4 1.4c.4-.7-1.4-1.4-1.4-1.4m1.2 4c-.2 0-1 .7-.5 1 .8.4 2.9.8 2.4-.7-.3-.9 3.2 0 2.3-2.4a4 4 0 0 0-1.7-1.7c-.4 0-1.5.5-.8.9.5.2 2 1.1 1.5 1.7-.7.6-1.1-.3-1.9-.1-.4 0-.1 1.2-.4 1.5 0 .2-.7-.4-.9-.3zm5.5-9.5a4 4 0 0 0-1.2 2q.1.5.5.5a3 3 0 0 0 1.2-1.9c0-.3-.2-.8-.5-.6m2.8-.3c-.8-1 1-2.6 1.7-.5.5 1.3 5.5 7.9 6.5 10.1.8 1.5 0 2.1-.9 1-2.5-3.2-4.6-7.2-7.3-10.6m5.2.1c.9-1 2.7-3 2.2-4s-1.5-1-1.7-.7c-1 1.3.8 1 .5 1.4q-.8 1.3-1.3 2.6c-.1.3.1.9.3.7m77.8 3.2c-.7-.5.6-3 1.5-2 2.3 2.7 3.4 11.6 4.1 18.3 0 0-1 .9-1 .7 0-3.5-1.5-14.4-4.6-17m-53.1-8.6c-.8-1.8 1.1-2.4 1.4-1.2 1.3 5.8 4.5 10.2 7 14.1.7 1.2 0 2-1.7.8-1.2-.8-2.5-3.9-3-4-1.2-.2-3.8 5-9.1 3.5-1.4-.4-1.3-4.5-1.4-6.3 0-.9 1-1 1 0 0 1.7 0 5.2 2.1 5.4 1.8 0 5.6-2.4 6.4-4.4s-1.9-5.9-2.7-8z"/>
<path d="M344.6 138.4c.4-1.2 6.1-10.8 6.9-12.9.4-1 2 1.8.4 3.3-1.4 1.2-5.5 8-6.3 10.4-.4 1-1.4.5-1-.8"/>
<path d="M354.3 129.3c1-4 3.6.6 1.3 2.8-3.4 3.4-4.5 9.9-10 10.9-1.4.3-4-.7-4.8-1.3-.3-.2.2-1.6 1.1-.9 1.3 1 4.1 1.3 5.6.1a25 25 0 0 0 6.8-11.6m-57 12.7c-.3.3-1 .3-1.1.7-.3 1.4 0 2.2-.3 3.6s-1.3 1.4-1.2.3c0-1.4 1.3-3.5.4-3.6-.6-.1-1-.9-.4-1.3q1.5-.9 2.4-.4.5.3.2.7"/>
<path d="M296.5 140c-1.4 1.4-2.8 1.9-4.1 3.5-.6.6-.5 1.5-.9 2.4-.3.9-1.4 1-1.7.9-.5-.4-.4-2-1-1.2s-.9 2-1.7 2-2-1.5-1.3-1.5c2.3-.3 2.2-2 3-2.2 1-.1 1 1.5 1.7 1.2.4-.2.7-2.1 1.2-2.6 1.5-1.6 2.7-2.4 4.3-3.6.7-.6 1.3.5.5 1.2zm5.3 5c-1.2.2-1 1.7-.6 1.8.5.3 1.4.4 1.7-1.3.2-.7.3 3.5 1.8 1.9 1-1 3.1.2 4-1 .7-.9 1-1.5.4-2.7-.2-.3-1-.2-1 .7s-.5 1.7-1.3 1.6c-.4-.1.2-1.9-.2-2.4a1 1 0 0 0-.7 0c-.3.4.3 2.2-.6 2.4-1.2.2-.6-1.2-1-1.4-1.7-.8-1.8.2-2.5.3zm9-3c.9-.2.6-.2 2-1.3.5-.4.6.8.5 1.3 0 .7-1 .2-1.3.9-.4.9-.2 3-.4 3.8 0 .4-.8.4-.8 0-.2-1 .1-2 0-3.3 0-.4-.5-1.1 0-1.3zm-5-2.5q-.2 1.3-.2 2.3c0 .5 1 .2 1 .1 0-.8.2-2 0-2.3q-.5-.3-.8-.1"/>
<path d="m299.5 130.2-1.4 5.6-2-3.8v3.9l-4.4-5.2 1.5 5.6-4-3.4 2.2 3.8-7-4.5 4.4 5.2-5.6-2.8 4 3.4-9-3.4 8.7 4.3a29 29 0 0 1 12.6-2.6q7.5.1 12.5 2.6l8.8-4.3-9 3.4 4-3.4-5.5 2.8 4.3-5.2-7 4.5 2.2-3.8-4 3.3 1.5-5.5-4.3 5.2V132l-2 3.8z"/>
</g>
</g>
<path fill="#fff" d="m311.3 295-.3 2.6h-.4l-.1-1.8-.5-1.6-.5-1.3-1-1.4.8-2.2a7 7 0 0 1 1.5 2.4 9 9 0 0 1 .5 3.2m7-4.2q0 1-.5 1.5-.3.5-1.3.7l.4 1.5v2l-.1 1.3h-.4l-.1-1.3-.2-1-.4-1-.7-1.4-1-1.7.6-2 1 1q.4.3 1 .3 1.2 0 1.2-1.3h.4v1.4m6.4 4.8-.5 2.1q-.6 0-.8-.7l-.4-1.3-.1-1.7-1 .2a2 2 0 0 1-1.3-.4 1 1 0 0 1-.5-1q0-1.4.7-2.3.8-1 1.5-1.1.7 0 1 .4l.3.9v2q0 1.3.3 1.9 0 .4.8 1m-2-3.5q0-.9-.8-.8l-.6.1q-.2.1-.2.3 0 .5 1 .5zm8.7 3-.3 2.6q-.8-.5-1.4-2l-1.3-4.1-1.8 5.5-.8.7v-2.5q.9-1 1-1.5l.8-1.7.5-2.7h.4l.9 2.7q.3 1 .9 1.6l1 1.4"/>
<path fill="#bf0000" d="M350.8 319.4q.6.6.7 1.2l.4 1.6-.8.1-1-1.5-1.1-1.2-1.7-1.5-2-1.7q-.6-.3-.6-.5l-.3-.8-.2-1.6 2.7 2.2 2.5 2.2zm-9.5-5.8-.2 2H338l.3-2zm8.4 8.9-7.6 2.3-1.3-2 6.5-2-.7-.8-.9-.6a1 1 0 0 1-.4 1l-1 .6a3 3 0 0 1-1.8 0 2 2 0 0 1-1.3-.7 4 4 0 0 1-.7-2.2q0-1.5.9-1.8 1.1-.3 3 .7a8 8 0 0 1 3 2.4zm-5.8-4-.8-.3h-.6l-.5.3v.6l.5.2h.6l.4-.3zm-8-1.6-.5 2-3.2-.3.5-2zm7.5 7.7-1.7.4H340l-1.5-.4q-.5.8-1.5 1.2l-1.6.6-1.2.3-1-2 1.1-.3 1.3-.4.9-.5-1-.5h-.9l-.2.3h-.5q-.8-1.2-.3-2c.5-.8.9-.8 2-1a7 7 0 0 1 2.6-.2q1.2.1 1.5.9.2.3.2.7l-.4 1.2h1.1l1.7-.3zm-8 1.8-1.6.3a3 3 0 0 1-2.2-.4 6 6 0 0 1-1.7-2.6l-.8-2.2a2 2 0 0 0-.8-1l-.9-.5.6-2.1q.9.4 1.4 1l1 1.7.5 1.5 1.1 2.2q.5.4 1 .3l1.7-.2zm-7-7.5-1 1.9-3-.7 1-1.9zm1.8 8.4-7.5.7-.4-2 6.2-.7-.6-.8-1-.6.5-2q1 .6 1.6 1.3.5.8.8 2.1zm-6 1-2.2-.2-1.7-.5-1.3.4h-3.7l-1.2-.3q-.4-.3-.8-1a4 4 0 0 1-1.5 1l-1.7.1h-1.7l.2-2.1h1.7q1.2.1 2.1-.4a2 2 0 0 0 1.3-1.8l.7.1-.1 1.3q0 .4.3.7.4.3 1 .3h1.5q1.5 0 2-.2.9-.2 1-1.1l.1-.4s.3 0 .5-.2l.5-.2v.7l-.3 1.1 2 .5q.1-.3-.1-.7l-.3-.6.1-.3.3-.2 1-.9.5 1v1zm-11.3-8.7-2 1.3-1.3-.9-1.4 1-1.9-1 1.8-1.3 1.5.8 1.5-1 1.8 1m-3 8.2-7.3-1.2.8-2 6.2 1q0-.6-.2-1l-.5-.8 1.6-1.7q.6.8.7 1.6t-.5 2.1zm-6.1-1-1.6-.3q-1.3-.3-1.5-1.2-.3-.9.8-2.8l1.2-2q.4-.7.3-1.2l-.3-.7 2.2-1.6q.4.8.3 1.4 0 .8-.7 1.8l-.8 1.4a6 6 0 0 0-.9 2.2q0 .6.5.7l1.6.4zm-3.8-8-2.5 1.1-1.8-1.7 2.6-1zm-1 6.6-1.6 1.4-1.7.6-2.4-.1-2.8-.7a8 8 0 0 1-3.4-2q-.9-1.2 0-2.2a7 7 0 0 1 2-1.6q1.1-.7 3.8-1.6l.4.5-2.8 1.2q-.8.4-1.3 1t.2 1.6a11 11 0 0 0 6.3 2.2q1.8 0 2.3-.7.4-.4.5-1l.2-1.6 2.5-1.5-.1 1.5a4 4 0 0 1-1 1.6z"/>
</svg>

After

Width:  |  Height:  |  Size: 19 KiB

+14
View File
@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-ag" viewBox="0 0 640 480">
<defs>
<clipPath id="ag-a">
<path fill-opacity=".7" d="M-79.7 0H603v512H-79.7z"/>
</clipPath>
</defs>
<g fill-rule="evenodd" clip-path="url(#ag-a)" transform="translate(74.7)scale(.9375)">
<path fill="#fff" d="M-79.7 0H603v512H-79.7z"/>
<path fill="#000001" d="M-79.6 0H603v204.8H-79.7z"/>
<path fill="#0072c6" d="M21.3 203.2h480v112h-480z"/>
<path fill="#ce1126" d="M603 .1V512H261.6L603 0zM-79.7.1V512h341.3L-79.7 0z"/>
<path fill="#fcd116" d="M440.4 203.3 364 184l64.9-49-79.7 11.4 41-69.5-70.7 41L332.3 37l-47.9 63.8-19.3-74-21.7 76.3-47.8-65 13.7 83.2L138.5 78l41 69.5-77.4-12.5 63.8 47.8L86 203.3z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 743 B

+29
View File
@@ -0,0 +1,29 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" id="flag-icons-ai" viewBox="0 0 640 480">
<defs>
<path id="ai-b" fill="#f90" d="M271 87c1.5 3.6 6.5 7.6 7.8 9.6-1.7 2-2 1.8-1.8 5.4 3-3.1 3-3.5 5-3 4.2 4.2.8 13.3-2.8 15.3-3.4 2.1-2.8 0-8 2.6 2.3 2 5.1-.3 7.4.3 1.2 1.5-.6 4.1.4 6.7 2-.2 1.8-4.3 2.2-5.8 1.5-5.4 10.4-9.1 10.8-14.1 1.9-.9 3.7-.3 6 1-1.1-4.6-4.9-4.6-5.9-6-2.4-3.7-4.5-7.8-9.6-9-3.8-.7-3.5.3-6-1.4-1.6-1.2-6.3-3.4-5.5-1.6"/>
</defs>
<clipPath id="ai-a">
<path d="M0 0v120h373.3v120H320zm320 0H160v280H0v-40z"/>
</clipPath>
<path fill="#012169" d="M0 0h640v480H0z"/>
<path stroke="#fff" stroke-width="50" d="m0 0 320 240m0-240L0 240"/>
<path stroke="#c8102e" stroke-width="30" d="m0 0 320 240m0-240L0 240" clip-path="url(#ai-a)"/>
<path stroke="#fff" stroke-width="75" d="M160 0v280M0 120h373.3"/>
<path stroke="#c8102e" stroke-width="50" d="M160 0v280M0 120h373.3"/>
<path fill="#012169" d="M0 240h320V0h106.7v320H0z"/>
<path fill="#fff" d="M424 191.8c0 90.4 9.7 121.5 29.3 142.5a179 179 0 0 0 35 30 180 180 0 0 0 35-30c19.5-21 29.3-52.1 29.3-142.5-14.2 6.5-22.3 9.7-34 9.5a78 78 0 0 1-30.3-9.5 78 78 0 0 1-30.3 9.5c-11.7.2-19.8-3-34-9.5"/>
<g transform="matrix(1.96 0 0 2.002 -40.8 62.9)">
<use xlink:href="#ai-b"/>
<circle cx="281.3" cy="91.1" r=".8" fill="#fff" fill-rule="evenodd"/>
</g>
<g transform="matrix(-.916 -1.77 1.733 -.935 563.4 829)">
<use xlink:href="#ai-b"/>
<circle cx="281.3" cy="91.1" r=".8" fill="#fff" fill-rule="evenodd"/>
</g>
<g transform="matrix(-1.01 1.716 -1.68 -1.031 925.4 -103.2)">
<use xlink:href="#ai-b"/>
<circle cx="281.3" cy="91.1" r=".8" fill="#fff" fill-rule="evenodd"/>
</g>
<path fill="#9cf" d="M440 315.1a78 78 0 0 0 13.3 19.2 179 179 0 0 0 35 30 180 180 0 0 0 35-30 78 78 0 0 0 13.2-19.2z"/>
<path fill="#fdc301" d="M421.2 188.2c0 94.2 10.2 126.6 30.6 148.5a187 187 0 0 0 36.5 31.1 186 186 0 0 0 36.4-31.1c20.4-21.9 30.6-54.3 30.6-148.5-14.8 6.8-23.3 10.1-35.5 10-11-.3-22.6-5.7-31.5-10-9 4.3-20.6 9.7-31.5 10-12.3.1-20.7-3.2-35.6-10m4 5c14 6.5 22 9.6 33.5 9.4a76 76 0 0 0 29.6-9.4c8.4 4 19.3 9.2 29.6 9.4 11.5.2 19.4-3 33.4-9.4 0 89-9.6 119.6-28.8 140.2a176 176 0 0 1-34.2 29.4 176 176 0 0 1-34.3-29.4c-19.2-20.6-28.7-51.3-28.7-140.2z"/>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" id="flag-icons-al" viewBox="0 0 640 480">
<path fill="red" d="M0 0h640v480H0z"/>
<path id="al-a" fill="#000001" d="M272 93.3c-4.6 0-12.3 1.5-12.2 5-13-2.1-14.3 3.2-13.5 8q2-2.9 3.9-3.1 2.5-.3 5.4 1.4a22 22 0 0 1 4.8 4.1c-4.6 1.1-8.2.4-11.8-.2a17 17 0 0 1-5.7-2.4c-1.5-1-2-2-4.3-4.3-2.7-2.8-5.6-2-4.7 2.3 2.1 4 5.6 5.8 10 6.6 2.1.3 5.3 1 8.9 1s7.6-.5 9.8 0c-1.3.8-2.8 2.3-5.8 2.8s-7.5-1.8-10.3-2.4c.3 2.3 3.3 4.5 9.1 5.7 9.6 2 17.5 3.6 22.8 6.5a37 37 0 0 1 10.9 9.2c4.7 5.5 5 9.8 5.2 10.8 1 8.8-2.1 13.8-7.9 15.4-2.8.7-8-.7-9.8-2.9-2-2.2-3.7-6-3.2-12 .5-2.2 3.1-8.3.9-9.5a274 274 0 0 0-32.3-15.1c-2.5-1-4.5 2.4-5.3 3.8a50 50 0 0 1-36-23.7c-4.2-7.6-11.3 0-10.1 7.3 1.9 8 8 13.8 15.4 18s17 8.2 26.5 8c5.2 1 5.1 7.6-1 8.9-12.1 0-21.8-.2-30.9-9-6.9-6.3-10.7 1.2-8.8 5.4 3.4 13.1 22.1 16.8 41 12.6 7.4-1.2 3 6.6 1 6.7-8 5.7-22.1 11.2-34.6 0-5.7-4.4-9.6-.8-7.4 5.5 5.5 16.5 26.7 13 41.2 5 3.7-2.1 7.1 2.7 2.6 6.4-18.1 12.6-27.1 12.8-35.3 8-10.2-4.1-11 7.2-5 11 6.7 4 23.8 1 36.4-7 5.4-4 5.6 2.3 2.2 4.8-14.9 12.9-20.8 16.3-36.3 14.2-7.7-.6-7.6 8.9-1.6 12.6 8.3 5.1 24.5-3.3 37-13.8 5.3-2.8 6.2 1.8 3.6 7.3a54 54 0 0 1-21.8 18c-7 2.7-13.6 2.3-18.3.7-5.8-2-6.5 4-3.3 9.4 1.9 3.3 9.8 4.3 18.4 1.3s17.8-10.2 24.1-18.5c5.5-4.9 4.9 1.6 2.3 6.2-12.6 20-24.2 27.4-39.5 26.2-6.7-1.2-8.3 4-4 9 7.6 6.2 17 6 25.4-.2 7.3-7 21.4-22.4 28.8-30.6 5.2-4.1 6.9 0 5.3 8.4-1.4 4.8-4.8 10-14.3 13.6-6.5 3.7-1.6 8.8 3.2 9 2.7 0 8.1-3.2 12.3-7.8 5.4-6.2 5.8-10.3 8.8-19.9 2.8-4.6 7.9-2.4 7.9 2.4-2.5 9.6-4.5 11.3-9.5 15.2-4.7 4.5 3.3 6 6 4.1 7.8-5.2 10.6-12 13.2-18.2 2-4.4 7.4-2.3 4.8 5-6 17.4-16 24.2-33.3 27.8-1.7.3-2.8 1.3-2.2 3.3l7 7c-10.7 3.2-19.4 5-30.2 8l-14.8-9.8c-1.3-3.2-2-8.2-9.8-4.7-5.2-2.4-7.7-1.5-10.6 1 4.2 0 6 1.2 7.7 3.1 2.2 5.7 7.2 6.3 12.3 4.7 3.3 2.7 5 4.9 8.4 7.7l-16.7-.5c-6-6.3-10.6-6-14.8-1-3.3.5-4.6.5-6.8 4.4 3.4-1.4 5.6-1.8 7.1-.3 6.3 3.7 10.4 2.9 13.5 0l17.5 1.1c-2.2 2-5.2 3-7.5 4.8-9-2.6-13.8 1-15.4 8.3a17 17 0 0 0-1.2 9.3q1.1-4.6 4.9-7c8 2 11-1.3 11.5-6.1 4-3.2 9.8-3.9 13.7-7.1 4.6 1.4 6.8 2.3 11.4 3.8q2.4 7.5 11.3 5.6c7 .2 5.8 3.2 6.4 5.5 2-3.3 1.9-6.6-2.5-9.6-1.6-4.3-5.2-6.3-9.8-3.8-4.4-1.2-5.5-3-9.9-4.3 11-3.5 18.8-4.3 29.8-7.8l7.7 6.8q2.3 1.5 3.8 0c6.9-10 10-18.7 16.3-25.3 2.5-2.8 5.6-6.4 9-7.3 1.7-.5 3.8-.2 5.2 1.3 1.3 1.4 2.4 4.1 2 8.2-.7 5.7-2.1 7.6-3.7 11s-3.6 5.6-5.7 8.3c-4 5.3-9.4 8.4-12.6 10.5-6.4 4.1-9 2.3-14 2-6.4.7-8 3.8-2.8 8.1 4.8 2.6 9.2 2.9 12.8 2.2 3-.6 6.6-4.5 9.2-6.6 2.8-3.3 7.6.6 4.3 4.5-5.9 7-11.7 11.6-19 11.5-7.7 1-6.2 5.3-1.2 7.4 9.2 3.7 17.4-3.3 21.6-8 3.2-3.5 5.5-3.6 5 1.9-3.3 9.9-7.6 13.7-14.8 14.2-5.8-.6-5.9 4-1.6 7 9.6 6.6 16.6-4.8 19.9-11.6 2.3-6.2 5.9-3.3 6.3 1.8 0 6.9-3 12.4-11.3 19.4 6.3 10.1 13.7 20.4 20 30.5l19.2-214L320 139c-2-1.8-8.8-9.8-10.5-11-.7-.6-1-1-.1-1.4s3-.8 4.5-1c-4-4.1-7.6-5.4-15.3-7.6 1.9-.8 3.7-.4 9.3-.6a30 30 0 0 0-13.5-10.2c4.2-3 5-3.2 9.2-6.7a86 86 0 0 1-19.5-3.8 37 37 0 0 0-12-3.4zm.8 8.4c3.8 0 6.1 1.3 6.1 2.9s-2.3 2.9-6.1 2.9-6.2-1.5-6.2-3c0-1.6 2.4-2.8 6.2-2.8"/>
<use xlink:href="#al-a" width="100%" height="100%" transform="matrix(-1 0 0 1 640 0)"/>
</svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-am" viewBox="0 0 640 480">
<path fill="#d90012" d="M0 0h640v160H0z"/>
<path fill="#0033a0" d="M0 160h640v160H0z"/>
<path fill="#f2a800" d="M0 320h640v160H0z"/>
</svg>

After

Width:  |  Height:  |  Size: 228 B

+13
View File
@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-ao" viewBox="0 0 640 480">
<g fill-rule="evenodd" stroke-width="1pt">
<path fill="red" d="M0 0h640v243.6H0z"/>
<path fill="#000001" d="M0 236.4h640V480H0z"/>
</g>
<path fill="#ffec00" fill-rule="evenodd" d="M228.7 148.2c165.2 43.3 59 255.6-71.3 167.2l-8.8 13.6c76.7 54.6 152.6 10.6 174-46.4 22.2-58.8-7.6-141.5-92.6-150z"/>
<path fill="#ffec00" fill-rule="evenodd" d="m170 330.8 21.7 10.1-10.2 21.8-21.7-10.2zm149-99.5h24v24h-24zm-11.7-38.9 22.3-8.6 8.7 22.3-22.3 8.7zm-26-29.1 17.1-16.9 16.9 17-17 16.9zm-26.2-39.8 22.4 8.4-8.5 22.4-22.4-8.4zM316 270l22.3 8.9-9 22.2-22.2-8.9zm-69.9 70 22-9.3 9.5 22-22 9.4zm-39.5 2.8h24v24h-24zm41.3-116-20.3-15-20.3 14.6 8-23-20.3-15h24.5l8.5-22.6 7.8 22.7 24.7-.3-19.6 15.3z"/>
<path fill="#fe0" fill-rule="evenodd" d="M336 346.4c-1.2.4-6.2 12.4-9.7 18.2l3.7 1c13.6 4.8 20.4 9.2 26.2 17.5a8 8 0 0 0 10.2.7s2.8-1 6.4-5c3-4.5 2.2-8-1.4-11.1-11-8-22.9-14-35.4-21.3"/>
<path fill="#000001" fill-rule="evenodd" d="M365.3 372.8a4.3 4.3 0 1 1-8.7 0 4.3 4.3 0 0 1 8.6 0zm-21.4-13.6a4.3 4.3 0 1 1-8.7 0 4.3 4.3 0 0 1 8.7 0m10.9 7a4.3 4.3 0 1 1-8.7 0 4.3 4.3 0 0 1 8.7 0"/>
<path fill="#fe0" fill-rule="evenodd" d="M324.5 363.7c-42.6-24.3-87.3-50.5-130-74.8-18.7-11.7-19.6-33.4-7-49.9 1.2-2.3 2.8-1.8 3.4-.5 1.5 8 6 16.3 11.4 21.5A5288 5288 0 0 1 334 345.6c-3.4 5.8-6 12.3-9.5 18z"/>
<path fill="#ffec00" fill-rule="evenodd" d="m297.2 305.5 17.8 16-16 17.8-17.8-16z"/>
<path fill="none" stroke="#000" stroke-width="3" d="m331.5 348.8-125-75.5m109.6 58.1L274 304.1m18.2 42.7L249.3 322"/>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-aq" viewBox="0 0 640 480">
<path fill="#3a7dce" d="M0 0h640v480H0z"/>
<path fill="#fff" d="M157.7 230.8c-3.5-7.8-3.5-7.8-3.5-15.6-1.8 0-2 .3-3 0-1.1-.3-1.5 7.2-4.8 5.8-.5-.8 2.4-6.2-.7-8.5-1-.7.2-5.2-.2-7.2 0 0-4 2.4-7-5.8-1.5-2.2-3.5 2-3.5 2s.9 2.4-.7 3c-2.2-1.8-3.9-.8-6.7-3.4s.6-5.4-4.8-7.5c3.5-9.8 3.5-7.9 12.2-11.8-5.2-4-5.2-4-8.7-9.8-5.2-2-7-4-12.2-7.8-7-9.9-10.5-29.5-10.5-43.2 4.4-4.6 10.5 15.7 19.2 21.6l12.2 5.9c7 3.9 8.7 7.8 14 11.7l15.6 6c7 5.8 10.5 13.6 15.7 15.6 5.7 0 6.8-3.7 8.6-3.9 10.3-.6 15.5-2 17.5-5.5 2.1-2.8 7 1.6 21-4.3l-1.7-7.9s3.7-3.4 8.7-2c-.1-3.5-.5-13 4.5-17.4-3-3.5 1.8-9 2-10.7-1.4-8.6 1.4-8.7 2-11.3.6-2.5-2.4-1.7-1.6-5.2.9-3.5 6-4.3 6.6-7.2.7-2.9-1.1-14.3-1.3-16.8 9.4-2.8 12.4-11.4 15.7-7.8C264 70 265.8 66 276.3 66c1.4-3.6-3.9-6.7-1.8-7.9 3.5-.5 6.1-.2 10.2 5.7 1.3 2 1.6-2.7 2.9-3.2s4.4-.5 4.9-2.8c.5-2.4 1.2-5.6 3-9.5 1.4-3.2 2.5 1.3 3.8 7.5 7.4.3 24 2.1 31 4.3 5.2 1.5 8.7-1.5 13.7-2.2 3.7 4.2 7.2 1 9.2 10 2.7 4.8 7.3.4 8.3 1.8 5.8 18.1 25.8 5.9 27.4 6.2 2.5 0 5.6 8 7.7 7.9 3.2-.6 2.3-3.1 5.2-2.1-.8 6.8 5.6 14.6 5.6 19.7 0 0 1.5.9 3-.6 1.4-1.6 2.7-5.4 4-5.3 3 .5 22 6 25.8 7.9 1.7 3.5 3.3 5.3 6.8 4.7 2.8 2.1.8 5 2.4 5.1 3.5-2 4.7-4 8.2-2.1 3.5 2 7 5.9 8.7 9.8 0 2-1.8 9.8 0 21.6.9 3.9 9.7 32.3 9.7 35.2 0 4-2.7 6-4.5 9.9 7 5.9 0 15.7-3.5 21.6 26.2 5.9 14 17.6 34.9 11.7-5.2 13.8-3.4 12.7 1.8 26.4-10.4 7.8-.2 10.2-7.1 20-.5.7 4.1 8.6 10.5 8.6-1.7 15.6-7 9.8-5.2 33.3-13.7-.3-8.2 17.6-17.4 15.7.5 11.2 5.2 12.2 3.4 23.5-7 2-7 2-10.4 7.9l-5.2-2c-1.8 9.8-5.3 11.8 0 21.6 0 0-6.8.2-8.8 0-.1 3.4 3 4.3 3.5 7.8-.2 1.4-9.9 7.6-17.4 7.9-2 4.8 5.2 10 4.8 12.4-8.2 1.8-11.8 13-11.8 13s4.2 2 3.5 4c-2.2-1.8-3.5-2-7-2-1.7.5-6 0-10 7.7-4.5 1.6-6.6 1-10 6-1.5-4.7-3.7.1-6.3 2-2.7 1.8-6.2 6.5-6.7 6.3.1-1.4 1.6-6.3 1.6-6.3L399 437c-.7.1-.5-5.7-2.2-5.5s-6.4 7.3-8 7.5-2.1-2.2-3.5-2-4 7.5-5 7.7c-1 .1-5-4.5-8.3-3.8-17.1 6.8-19.9-13.4-22.5-2-3.6-2.2-3-1-6.7.1-2.3.7-2.5-3.4-4.6-3.4-4.1.2-4 4.6-6.2 3.3-1.8-9.2-13-7.6-14-11.5s4.8-4 6.6-6.8c1.4-4-1.5-5.6 4.3-9.4 7.5-5.7 6.8-19.8 4.9-25.3 0 0-5.9-17.7-7-17.7-3.5-1-3.5 6.5-8.6 8.6-10.5 4-29-9.9-32.2-9.9-2.9 0-16.5 3.6-16-4-2 7.4-9.5 1.7-10 1.7-7 0-4.3 6.1-9 5.9-2.1-.8-23.6-2.3-23.6-2.3v4l-26.1-11.8c-10.5-4-5.3-13.7-22.7-7.8v-11.8h-8.7c3.5-23.6 0-11.8-1.8-33.4l-7 2c-7-10.6 9.8-8.6-5.2-15.7 0 0 .3-11.7-3.5-7.8-.7.5 1.8 5.8 1.8 5.8-14-2-17.4-5.8-17.4-21.5 0 0 11.4 1.8 10.4 0-1.6-3-3.7-22-3.4-23.4-.1-2.6 10.7-9 8.6-15.2 1.4-.6 5.3-.7 5.3-.7"/>
<path fill="none" stroke="#fff" stroke-linejoin="round" stroke-width="2.5" d="M595.5 297.6q-.9 2 .1 3.6c1.1-1.7.2-2.4 0-3.6zm-476-149.4s-3-.4-2.4 2.3c1-2 2.3-2.2 2.4-2.3zm-.3-6.4c-1.7 0-3.8-.2-3 2.5 1-2.1 3-2.4 3-2.5zm12.7 36.3s2.6-.2 2 2.5c-1-2-2-2.4-2-2.5z" transform="scale(.86021 .96774)"/>
</svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

+32
View File
@@ -0,0 +1,32 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" id="flag-icons-ar" viewBox="0 0 640 480">
<path fill="#74acdf" d="M0 0h640v480H0z"/>
<path fill="#fff" d="M0 160h640v160H0z"/>
<g id="ar-c" transform="translate(-64)scale(.96)">
<path id="ar-a" fill="#f6b40e" stroke="#85340a" stroke-width="1.1" d="m396.8 251.3 28.5 62s.5 1.2 1.3.9c.8-.4.3-1.6.3-1.6l-23.7-64m-.7 24.2c-.4 9.4 5.4 14.6 4.7 23s3.8 13.2 5 16.5c1 3.3-1.2 5.2-.3 5.7 1 .5 3-2.1 2.4-6.8s-4.2-6-3.4-16.3-4.2-12.7-3-22"/>
<use xlink:href="#ar-a" width="100%" height="100%" transform="rotate(22.5 400 250)"/>
<use xlink:href="#ar-a" width="100%" height="100%" transform="rotate(45 400 250)"/>
<use xlink:href="#ar-a" width="100%" height="100%" transform="rotate(67.5 400 250)"/>
<path id="ar-b" fill="#85340a" d="M404.3 274.4c.5 9 5.6 13 4.6 21.3 2.2-6.5-3.1-11.6-2.8-21.2m-7.7-23.8 19.5 42.6-16.3-43.9"/>
<use xlink:href="#ar-b" width="100%" height="100%" transform="rotate(22.5 400 250)"/>
<use xlink:href="#ar-b" width="100%" height="100%" transform="rotate(45 400 250)"/>
<use xlink:href="#ar-b" width="100%" height="100%" transform="rotate(67.5 400 250)"/>
</g>
<use xlink:href="#ar-c" width="100%" height="100%" transform="rotate(90 320 240)"/>
<use xlink:href="#ar-c" width="100%" height="100%" transform="rotate(180 320 240)"/>
<use xlink:href="#ar-c" width="100%" height="100%" transform="rotate(-90 320 240)"/>
<circle cx="320" cy="240" r="26.7" fill="#f6b40e" stroke="#85340a" stroke-width="1.4"/>
<path id="ar-h" fill="#843511" stroke-width="1" d="M329 234.3c-1.7 0-3.5.8-4.5 2.4 2 1.9 6.6 2 9.7-.2a7 7 0 0 0-5.1-2.2zm0 .4c1.8 0 3.5.8 3.7 1.6-2 2.3-5.3 2-7.4.4q1.6-2 3.8-2z"/>
<use xlink:href="#ar-d" width="100%" height="100%" transform="matrix(-1 0 0 1 640.2 0)"/>
<use xlink:href="#ar-e" width="100%" height="100%" transform="matrix(-1 0 0 1 640.2 0)"/>
<use xlink:href="#ar-f" width="100%" height="100%" transform="translate(18.1)"/>
<use xlink:href="#ar-g" width="100%" height="100%" transform="matrix(-1 0 0 1 640.2 0)"/>
<path fill="#85340a" d="M316 243.7a1.8 1.8 0 1 0 1.8 2.9 4 4 0 0 0 2.2.6h.2q1 0 2.3-.6.5.7 1.5.7a1.8 1.8 0 0 0 .3-3.6q.8.3.8 1.2a1.2 1.2 0 0 1-2.4 0 3 3 0 0 1-2.6 1.7 3 3 0 0 1-2.5-1.7q-.1 1.1-1.3 1.2-1-.1-1.2-1.2c-.2-1.1.3-1 .8-1.2zm2 5.4c-2.1 0-3 2-4.8 3.1 1-.4 1.8-1.2 3.3-2s2.6.2 3.5.2 2-1 3.5-.2q2 1.3 3.3 2c-1.9-1.2-2.7-3-4.8-3q-.7 0-2 .6z"/>
<path fill="#85340a" d="M317.2 251.6q-1.1 0-3.4.6c3.7-.8 4.5.5 6.2.5 1.6 0 2.5-1.3 6.1-.5-4-1.2-4.9-.4-6.1-.4-.8 0-1.4-.3-2.8-.2"/>
<path fill="#85340a" d="M314 252.2h-.8c4.3.5 2.3 3 6.8 3s2.5-2.5 6.8-3c-4.5-.4-3.1 2.3-6.8 2.3-3.5 0-2.4-2.3-6-2.3"/>
<path fill="#85340a" d="M323.7 258.9a3.7 3.7 0 0 0-7.4 0 3.8 3.8 0 0 1 7.4 0"/>
<path id="ar-e" fill="#85340a" stroke-width="1" d="M303.4 234.3c4.7-4.1 10.7-4.8 14-1.7a8 8 0 0 1 1.5 3.4q.6 3.6-2.1 7.5l.8.4q2.4-4.7 1.6-9.4l-.6-2.3c-4.5-3.7-10.7-4-15.2 2z"/>
<path id="ar-d" fill="#85340a" stroke-width="1" d="M310.8 233c2.7 0 3.3.6 4.5 1.7 1.2 1 1.9.8 2 1 .3.2 0 .8-.3.6q-.7-.2-2.5-1.6c-1.8-1.4-2.5-1-3.7-1-3.7 0-5.7 3-6.1 2.8-.5-.2 2-3.5 6.1-3.5"/>
<use xlink:href="#ar-h" width="100%" height="100%" transform="translate(-18.4)"/>
<circle id="ar-f" cx="310.9" cy="236.3" r="1.8" fill="#85340a" stroke-width="1"/>
<path id="ar-g" fill="#85340a" stroke-width="1" d="M305.9 237.5c3.5 2.7 7 2.5 9 1.3 2-1.3 2-1.7 1.6-1.7s-.8.4-2.4 1.3c-1.7.8-4.1.8-8.2-.9"/>
</svg>

After

Width:  |  Height:  |  Size: 3.4 KiB

+109
View File
@@ -0,0 +1,109 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" version="1.0" id="flag-icons-arab" viewBox="0 0 640 480">
<path fill="#006233" d="M0 0v480h640V0Z" class="arab-fil0 arab-str0"/>
<g fill="#fff" fill-rule="evenodd" stroke="#fff">
<path stroke-width=".4" d="M1071.9 2779.7c-25.9 38.9-7.2 64.2 19.5 66 17.6 1.3 54.2-24.9 54.1-55.7l-10-5.6c5.6 15.8-.2 20.8-12.1 31.6-23.5 21.3-71.5 22.8-51.5-36.3z" transform="matrix(.36355 0 0 .3308 -130 -670.9)"/>
<path d="M1277.2 2881.7c145.8 4.1 192.2-137 102.2-257.8l-8.9 13.3c5.8 56.3 14.2 111.8 15 169.5-17.6 20.7-43.2 13-48.3-10 .3-31.2-9.9-57.6-22.8-82.8l-7.2 13.3c8.4 20.7 17.5 44 19.4 69.5-41.6 49.9-87.6 60-70.5-5.6-32.9 57.5 16.9 98 73.3 9.5 12.1 60.4 58.9 22.9 61.7 9.9 5.1-39.6 2.5-103.4-7.8-153.8 40.6 70.3 42 121 20.4 154.9-24 37.7-76.2 55.3-126.5 70.1z" transform="matrix(.36355 0 0 .3308 -130 -670.9)"/>
<path d="M1359.9 2722.2c-31.2 2.3-47.2-4.1-30.3-27.2 16.7-22.6 32.3-4.6 36.5 25.6 3.9 28.3-54.8 64.4-75.1 64.4-30.7 0-44.9-39.5-16.6-75-36.4 103.6 78.6 43.5 85.5 12.2zm-21.6-24c-3.8-.2-6.6 6.5-4.7 7.8 5.5 3.8 14.2 1.5 15.1-.4 1.9-4.2-5.1-7.2-10.4-7.4z" transform="matrix(.36355 0 0 .3308 -130 -670.9)"/>
<path d="M1190.5 2771.1c-30 59-.1 83.4 38.4 76.6 22.4-4.1 50.8-20 67.2-41.7.3-47.8-.4-95.2-4.6-141.5 15-17.9-1.3-17.8-7-37-2.6 11.2-8.9 23.3-2.8 32q6.6 70 6.6 142.2c-30.2 24.3-52.9 33.3-69.1 33.1-33.5-.3-40.7-28.5-28.7-63.7z" transform="matrix(.36355 0 0 .3308 -130 -670.9)"/>
<path d="M1251.8 2786.7c-.5-44.5-1.2-95-5.2-126.1 15.6-17.3-.8-17.7-5.9-37.1-3 11-9.6 23-3.8 31.9 2.6 47.6 5.1 95.2 5.6 142.8 3.6-2.3 7.7-3.2 9.3-11.5z" transform="matrix(.36355 0 0 .3308 -130 -670.9)"/>
<path stroke-width=".4" d="M1135.4 2784.6c-3.8-4.8-6.5-10.2-9.6-14.9-.5-6.7 4-12.9 4.6-16.3 5.1 7.9 8.1 13.9 12.2 17.8m5.4 3.1c7.5 3 16.7 3 25.2 3.2 32.8.6 67.3-4.8 63.6 39.6a66 66 0 0 1-65.2 61.9c-41.7-.4-77.3-46.4-13-131.1 6.2-1 14.3.7 21 1.3 11.5.9 23.3-.2 36.8-11-1.6-27.9-1.6-54.3-5-79.5-5.8-8.9.8-20.8 3.8-31.9 5.1 19.4 21.4 19.8 5.9 37.2 3.7 28 4.1 56.5 4.1 73.5-7.8 11.9-13.9 24.5-36.7 29.3-23.3-3.4-33.8-36-58.1-25.2 6.7-29.4 68.4-36.1 74.6-12.9-4.1 24.2-61.7 14.5-77 92.7-4.7 24.1 20.7 46.3 46.8 44.5 25.5-1.7 52.7-19.4 55.4-49.2 2.1-24.9-33-22-47.7-21.7-21.4.5-34.9-2.8-43-7.5m21.9-53.9c3.8-3.6 17.1-6.1 21.9-.3q-5.5 3.5-10 8.1c-5-2.6-8.3-5.2-11.9-7.8z" transform="matrix(.36355 0 0 .3308 -130 -670.9)"/>
<path d="M1194 2650.9a49 49 0 0 1 5.3 21c-2.2 10.4-11.1 20.1-20.3 20.4-5.7.2-12.1-1.4-16.6-10.3-.5-1.1-2.9-3.7-5.2-2.5-10.1 16.6-17.6 23.6-26.7 23.5-18.2-.3-12.8-16.5-29.6-21.5-7-.2-18.5 6.9-24.4 20.8-22.4 63.5-42.8-.2-34.1-29.8 1.3 28.3 8.1 45.1 15.1 44.6 5.1-.5 9.6-12.3 16.1-24.7 5-9.5 17-26.6 29.7-26.6 11.6.3 4.3 21.6 27.5 21.3 11.2-.2 21.5-8.8 31.9-26 2.3-.4 2.9 3.7 3.4 5.1 1.6 5.9 11.8 22.1 25.6 7.3-.7-3.2-.4-8.5-3.9-9.6z" transform="matrix(.36355 0 0 .3308 -130 -670.9)"/>
<path stroke-width=".4" d="M1266.9 2598.3c-12.3 6.1-21.3.5-26.4-4.9 8.9-1.8 15.8-5 17.8-12-4-9-13.5-12.9-26.9-13-17.9.5-27.1 7.7-28.2 17.6 8.3.3 15.8-2 19 6-14.7 7.2-32 9.8-50.8 9.7-30.8 1.6-35.3-12.3-43.4-24.5-.6-.8-3.3-2.1-4.7-1.9-9.5 0-16.5 33.2-27.2 33.1-10.7-1.4-8.3-21.4-11.4-32.8-2.6 17.9 3.3 84.5 36.4 12.2 1-2.4 2.4-1.7 3.3.3 8.9 20.2 27 27.2 46.5 28.2 16.3.9 37.1-6.2 59.4-18.8 5.9 6.5 10.6 13.9 23 15.3 14.5.7 30-9.8 33.5-22.8 1.8-6.7 2.1-19.9-5-20.1-9.9-.3-17.1 23.7-14.8 45.3.2-.3 1.3-5.4 1.3-5.4m-43.8-28.8c6.5-3 12.8-4.4 17.8 2.2a27 27 0 0 0-8.4 4c-2.8-2.2-6.6-3.3-9.4-6.2zm47.8 14.9c1.6-7.1 2.5-12.8 8.3-16.5 1.2 7.5 1.4 11.7-8.3 16.5zm39 11c-1.9-6.1-3.8-11.4-4.4-18-1.4-13.4 10.1-21 20.5-19.9 10.7 1.1 17.8 5.1 28 8.6 8 2.7 18.8 4.8 29.1 7.7 5.8 2.6 0 9.4-1.5 10.3-25.8 10.1-44.1 26.1-60.5 26.8q-14.6.7-26.4-19c-.5-25.4-1.4-55.2-3.9-73.9 3.8-3.8 4.6-6.6 6.4-9.7 2 24.7 2.8 50.7 3.3 76.9 2.1 4.5 4.7 8.3 9.4 10.2zm16.5 2c-13.8 3.9-12.1-7.8-13.4-15-1.5-8.4-.5-17.9 10.2-15.5 13.9 3.7 26.6 8.6 38.9 13.8z" transform="matrix(.36355 0 0 .3308 -130 -670.9)"/>
<path stroke-width=".4" d="m1314.3 2621.3 1.9 9.3h1.5l-.6-8.7" transform="matrix(.36355 0 0 .3308 -130 -670.9)"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="4" d="m1094.2 2718.5 7-7.2 8.1 6.9-7.5 6.7zm17.8-2.4 7.1-7.2 8.1 6.9-7.5 6.7zm-49.5-74.6 7.1-7.2 8.1 6.9-7.5 6.7zm3.2 21.2 7.1-7.2 8 6.9-7.5 6.7zm128.5 35.5 6.5-5.3 6 6.5-6.8 4.8zm-85.8-135.7 4.6-4.7 5.3 4.5-4.9 4.4zm11.7-1.5 4.6-4.8 5.3 4.6-4.9 4.3zm245.6 53.7-4.4 3.7-4.2-4.3 4.6-3.4z" transform="matrix(.36355 0 0 .3308 -130 -670.9)"/>
<path stroke-width=".4" d="m1158.7 2747.4-.5 7.9 12.6 1.2 10.1-7.6z" transform="matrix(.36355 0 0 .3308 -130 -670.9)"/>
<path d="m1265.2 2599.8 3.7-.8-.4 10.3-2.3.9z" transform="matrix(.36355 0 0 .3308 -130 -670.9)"/>
</g>
<path fill="#fff" d="M320 326.3c51.6 0 93.6-38.2 93.6-85.2a82 82 0 0 0-32.6-64.4 70 70 0 0 1 19.2 48c0 40.8-35.9 73.9-80.2 73.9s-80.2-33.1-80.2-74c0-18.3 7.2-35.1 19.2-48a82 82 0 0 0-32.6 64.6c0 46.9 42 85.1 93.6 85.1" class="arab-fil2"/>
<g fill="#fff" stroke="#000" stroke-width="8">
<path d="M-54 1623c-88 44-198 32-291-28-4-2-6 1-2 12 10 29 18 52-12 95-13 19 2 22 24 20 112-11 222-36 275-57zm-2 52c-35 14-95 31-162 43-27 4-26 21 22 27 49 5 112-30 150-61z" class="arab-fil2 arab-str2" transform="matrix(.23458 0 0 .21345 320 27.3)"/>
<path d="M0 1579c12 0 34-5 56-8 41-7 11 56-56 56v21c68 0 139-74 124-107-21-48-79-7-124-7s-103-41-124 7c-15 33 56 107 124 107v-21c-67 0-97-63-56-56 22 3 44 8 56 8z" class="arab-fil2 arab-str2" transform="matrix(.23458 0 0 .21345 320 27.3)"/>
<path d="M54 1623c88 44 198 32 291-28 4-2 6 1 2 12-10 29-18 52 12 95 13 19-2 22-24 20-112-11-222-36-275-57zm2 52c35 14 94 31 162 43 27 4 26 21-22 27-49 5-112-30-150-61z" class="arab-fil2 arab-str2" transform="matrix(.23458 0 0 .21345 320 27.3)"/>
<path d="M3 1665c2 17 5 54 28 38 31-21 38-37 38-67 0-19-23-47-69-47s-69 28-69 47c0 30 7 46 38 67 23 16 25-21 28-38 1-6 6-4 6 0z" class="arab-fil2 arab-str2" transform="matrix(.23458 0 0 .21345 320 27.3)"/>
</g>
<g fill="#fff" stroke="#000" stroke-width="8">
<path d="M-29 384c-13-74-122-79-139-91-20-13-17 0-10 20 20 52 88 73 119 79 25 4 33 6 30-8z" class="arab-fil2 arab-str2" transform="matrix(.23458 0 0 .21345 320 27.3)"/>
<path d="M4 386c11-76-97-112-110-129-15-18-17-7-10 14 13 45 60 98 88 112 23 12 30 17 32 3z" class="arab-fil2 arab-str2" transform="matrix(.23458 0 0 .21345 320 27.3)"/>
<path d="M93 430c10-91-78-105-101-134-15-18-16-8-11 13 10 46 54 100 81 117 21 13 30 18 31 4z" class="arab-fil2 arab-str2" transform="matrix(.23458 0 0 .21345 320 27.3)"/>
<path d="M66 410c-91-59-155-26-181-29-25-3-33 13 10 37 53 29 127 25 156 14 30-12 21-18 15-22zm137 40c-28-98-93-82-112-94s-21-9-17 13c8 39 75 82 108 95 12 4 27 10 21-14z" class="arab-fil2 arab-str2" transform="matrix(.23458 0 0 .21345 320 27.3)"/>
<path d="M190 467c-78-63-139-16-163-23-18-5-10 7-3 12 50 35 112 54 160 32 19-8 20-10 6-21zm169 64c1-62-127-88-154-126-16-23-30-11-22 26 12 48 100 101 148 111 29 6 28-4 28-11z" class="arab-fil2 arab-str2" transform="matrix(.23458 0 0 .21345 320 27.3)"/>
<path d="M355 542c-81-73-149-49-174-56-25-6-35 9 4 39 48 36 122 43 153 36s23-14 17-19zm145 107c-23-106-96-128-114-148-17-20-35-14-20 34 18 57 77 107 108 119 30 13 28 3 26-5z" class="arab-fil2 arab-str2" transform="matrix(.23458 0 0 .21345 320 27.3)"/>
<path d="M499 663c-59-95-136-92-160-105-23-14-39-2-8 39 36 50 110 78 144 80s28-7 24-14z" class="arab-fil2 arab-str2" transform="matrix(.23458 0 0 .21345 320 27.3)"/>
<path d="M575 776c34-108-44-148-52-166-9-18-18-18-23 1-22 77 49 152 60 167 11 14 13 7 15-2z" class="arab-fil2 arab-str2" transform="matrix(.23458 0 0 .21345 320 27.3)"/>
<path d="M559 806c-27-121-98-114-114-131-17-17-19-5-16 17 8 59 79 99 111 119 10 6 22 13 19-5zm68 142c49-114-9-191-27-208-18-16-29-23-23 0 8 35-20 125 23 191 14 22 16 43 27 17z" class="arab-fil2 arab-str2" transform="matrix(.23458 0 0 .21345 320 27.3)"/>
<path d="M601 971c11-70-29-134-72-159-25-15-26-11-26 10 2 65 63 119 81 149 17 28 16 7 17 0z" class="arab-fil2 arab-str2" transform="matrix(.23458 0 0 .21345 320 27.3)"/>
<path d="M590 1153c-36-132 39-208 62-223 22-16 36-22 26 3-15 37 1 140-56 205-18 22-25 45-32 15z" class="arab-fil2 arab-str2" transform="matrix(.23458 0 0 .21345 320 27.3)"/>
<path d="M598 1124c30-115-35-180-55-193-19-13-31-18-22 3 12 32-1 122 49 178 16 19 22 38 28 12z" class="arab-fil2 arab-str2" transform="matrix(.23458 0 0 .21345 320 27.3)"/>
<path d="M561 1070c-54 58-55 143-31 193 15 29 17 27 31 6 38-61 15-149 17-188 1-37-11-17-17-11z" class="arab-fil2 arab-str2" transform="matrix(.23458 0 0 .21345 320 27.3)"/>
<path d="M650 1162c0 80-49 145-101 165-30 11-30 8-26-16 14-90 83-123 108-152 24-28 19-5 19 3z" class="arab-fil2 arab-str2" transform="matrix(.23458 0 0 .21345 320 27.3)"/>
<path d="M464 1400c88-80 41-136 45-188 2-28-9-21-19-11-56 55-59 153-47 191 5 17 13 15 21 8z" class="arab-fil2 arab-str2" transform="matrix(.23458 0 0 .21345 320 27.3)"/>
<path d="M582 1348c-29 88-106 142-171 145-38 2-37-1-24-27 49-94 136-105 175-129 36-22 23 2 20 11z" class="arab-fil2 arab-str2" transform="matrix(.23458 0 0 .21345 320 27.3)"/>
<path d="M343 1513c114-57 91-152 112-176 15-17-3-15-12-9-67 39-121 101-122 167 0 25 2 28 22 18z" class="arab-fil2 arab-str2" transform="matrix(.23458 0 0 .21345 320 27.3)"/>
<path d="M187 1619c144 23 211-86 253-96 22-5 6-14-5-15-96-11-218 34-255 84-15 20-15 24 7 27z" class="arab-fil2 arab-str2" transform="matrix(.23458 0 0 .21345 320 27.3)"/>
<path d="M333 1448c-29 95-137 173-218 179-38 3-38-1-24-26 65-118 178-138 218-168 34-26 27 6 24 15zM29 384c13-74 122-79 139-91 20-13 17 0 10 20-20 52-88 73-119 79-25 4-33 6-30-8z" class="arab-fil2 arab-str2" transform="matrix(.23458 0 0 .21345 320 27.3)"/>
<path d="M-4 386c-11-76 97-112 110-129 15-18 17-7 10 14-13 45-60 98-88 112-23 12-30 17-32 3z" class="arab-fil2 arab-str2" transform="matrix(.23458 0 0 .21345 320 27.3)"/>
<path d="M-93 430c-10-91 78-105 101-134 15-18 16-8 11 13-10 46-54 100-81 117-21 13-30 18-31 4z" class="arab-fil2 arab-str2" transform="matrix(.23458 0 0 .21345 320 27.3)"/>
<path d="M-66 410c91-59 155-26 181-29 25-3 33 13-10 37-53 29-127 25-156 14-30-12-21-18-15-22zm-137 40c28-98 93-82 112-94s21-9 17 13c-8 39-75 82-108 95-12 4-27 10-21-14z" class="arab-fil2 arab-str2" transform="matrix(.23458 0 0 .21345 320 27.3)"/>
<path d="M-190 467c78-63 139-16 163-23 18-5 10 7 3 12-50 35-112 54-160 32-19-8-20-10-6-21zm-169 64c-1-62 127-88 154-126 16-23 30-11 22 26-12 48-100 101-148 111-29 6-28-4-28-11z" class="arab-fil2 arab-str2" transform="matrix(.23458 0 0 .21345 320 27.3)"/>
<path d="M-355 542c81-73 149-49 174-56 25-6 35 9-4 39-48 36-122 43-153 36s-23-14-17-19zm-145 107c23-106 96-128 114-148 17-20 35-14 20 34-18 57-77 107-108 119-30 13-28 3-26-5z" class="arab-fil2 arab-str2" transform="matrix(.23458 0 0 .21345 320 27.3)"/>
<path d="M-499 663c59-95 136-92 160-105 23-14 39-2 8 39-36 50-110 78-144 80s-28-7-24-14z" class="arab-fil2 arab-str2" transform="matrix(.23458 0 0 .21345 320 27.3)"/>
<path d="M-575 776c-34-108 44-148 52-166 9-18 18-18 23 1 22 77-49 152-60 167-11 14-13 7-15-2z" class="arab-fil2 arab-str2" transform="matrix(.23458 0 0 .21345 320 27.3)"/>
<path d="M-559 806c27-121 98-114 114-131 17-17 19-5 16 17-8 59-79 99-111 119-10 6-22 13-19-5zm-68 142c-49-114 9-191 27-208 18-16 29-23 23 0-8 35 20 125-23 191-14 22-16 43-27 17z" class="arab-fil2 arab-str2" transform="matrix(.23458 0 0 .21345 320 27.3)"/>
<path d="M-601 971c-11-70 29-134 72-159 25-15 26-11 26 10-2 65-63 119-81 149-17 28-16 7-17 0z" class="arab-fil2 arab-str2" transform="matrix(.23458 0 0 .21345 320 27.3)"/>
<path d="M-590 1153c36-132-39-208-62-223-22-16-36-22-26 3 15 37-1 140 56 205 18 22 24 45 32 15z" class="arab-fil2 arab-str2" transform="matrix(.23458 0 0 .21345 320 27.3)"/>
<path d="M-598 1124c-30-115 35-180 55-193 19-13 31-18 22 3-12 32 1 122-49 178-16 19-22 38-28 12z" class="arab-fil2 arab-str2" transform="matrix(.23458 0 0 .21345 320 27.3)"/>
<path d="M-561 1070c54 58 55 143 31 193-15 29-17 27-31 6-38-61-15-149-17-188-1-37 11-17 17-11z" class="arab-fil2 arab-str2" transform="matrix(.23458 0 0 .21345 320 27.3)"/>
<path d="M-650 1162c0 80 49 145 101 165 30 11 30 8 26-16-14-90-83-123-108-152-24-28-19-5-19 3z" class="arab-fil2 arab-str2" transform="matrix(.23458 0 0 .21345 320 27.3)"/>
<path d="M-464 1400c-88-80-41-136-45-188-2-28 9-21 19-11 56 55 59 153 47 191-5 17-13 15-21 8z" class="arab-fil2 arab-str2" transform="matrix(.23458 0 0 .21345 320 27.3)"/>
<path d="M-582 1348c29 88 106 142 171 145 38 2 37-1 24-27-49-94-136-105-175-129-36-22-23 2-20 11z" class="arab-fil2 arab-str2" transform="matrix(.23458 0 0 .21345 320 27.3)"/>
<path d="M-343 1513c-114-57-91-152-112-176-15-17 3-15 12-9 67 39 121 101 122 167 0 25-2 28-22 18z" class="arab-fil2 arab-str2" transform="matrix(.23458 0 0 .21345 320 27.3)"/>
<path d="M-187 1619c-144 23-211-86-253-96-22-5-6-14 5-15 96-11 218 34 255 84 15 20 15 24-7 27z" class="arab-fil2 arab-str2" transform="matrix(.23458 0 0 .21345 320 27.3)"/>
<path d="M-333 1448c29 95 137 173 218 179 38 3 38-1 24-26-65-118-178-138-218-168-34-26-27 6-24 15z" class="arab-fil2 arab-str2" transform="matrix(.23458 0 0 .21345 320 27.3)"/>
</g>
<path fill="#006233" d="M359.6 128.9c-4.4-3-20.8-1.3-23.9-3.3 5.9 4.5 19 1.3 24 3.3zm39.7 7.6c-3.5-5.7-24.4-9.6-27.5-14.7 5.5 9.8 21.6 8.5 27.5 14.7m-3 6.6c-7.8-6.8-25.8-4-31.3-8 12.7 10.4 19.7 2.3 31.2 8zM351 112.8c4.9 2.4 11 4.7 14 10.3-3.5-4.3-9.8-6-15-9.6q.5-.1 1-.7m77 44c-3.1-6.4-14-13.4-14.9-15.8 3 8.3 12 10.3 14.8 15.8zm2.7 11.3c-9.4-13.4-24.1-12-30-17 4.5 4.9 21.4 8 30 17m21.8 20.7c.7-14.3-11-19.6-11.4-27.7-.3 9.6 12 22.6 11.4 27.7m-5.8 7.7c-2.4-12.4-18.3-13.2-21.1-20.5 0 6.8 18.7 13.9 21 20.5zm13.1-7c8.5 9.4 2.6 23.7 6.1 34.1-4.2-7.7-2.1-26.9-6-34.1zm-13.8 40c12.6 12.5 7.5 26.3 12.6 32.3-6.3-8.3-5.4-24.5-12.6-32.2zm26.3 1.8c-10.9 10.9-4.3 27.3-10 35 6.4-6.6 5.5-27 10-35m-13.7 0c-1.4-12.6-14.3-19.2-15.4-26-1.5 6.8 12.4 17.5 15.4 26m-6.5 30c2 8.8-5.7 27.6-3.3 33.4-5.2-10 4.4-29 3.3-33.3zm16.6 20.1c-5.1 15.6-15.5 14.6-18.7 24 2.3-9 16-17.1 18.7-24m-33.5 7.3c-6.8 10.5-1.2 22.4-6.8 29.9 8-7.5 3.7-21.4 6.8-29.9m16.4 28.6c-8.2 13.9-25.1 12.6-31.9 22.6 6.8-12.6 27.7-14.7 32-22.6zm-29.8-1.7c-14.5 9.2-10 18.8-21.1 29 13.8-10.2 12.7-21.5 21.1-29m-6.8 37.2c-14-.5-34.2 16.2-46.4 14.9 12.2 2.4 34.7-12.6 46.4-15zm-22.7-15c-1 13-37.6 21.4-41.5 30.1 4.4-11.5 36.6-20 41.5-30zm-82.8-240c-4.7-3.7-10.4-6.7-12-10.3 1.2 4.7 5.8 8 10.5 11.3.5-.2 1-.9 1.5-1.1zm-8 3.7c-7.3-3.2-15.7-3-19.5-7.4 2.4 4.4 10.3 6.1 17.1 8.5q1.2-.7 2.4-1zm-21.1 27.3c4.4-3 20.8-1.2 23.9-3.2-5.9 4.5-19 1.3-24 3.2zm-39.7 7.7c3.5-5.7 24.4-9.6 27.5-14.7-5.4 9.8-21.6 8.5-27.5 14.7m3 6.6c7.8-6.8 25.9-4 31.3-8-12.7 10.4-19.7 2.3-31.2 8zm31.3-20c4.4-8.6 17-9.6 20.4-14.8-5 7.7-15.7 9-20.4 14.8m36-7.5c13-5.5 25.7-.8 31.8-3.4-7.5 3.6-25.4 1.9-31.7 3.4zm-98.9 41.2c3-6.4 13.8-13.5 14.8-15.8-3 8.3-12 10.3-14.8 15.8m-2.8 11.3c9.4-13.4 24.1-12 30-17-4.4 4.9-21.3 8-30 17m-21.8 20.7c-.7-14.3 11-19.6 11.5-27.7.2 9.6-12 22.6-11.5 27.7m5.8 7.7c2.4-12.4 18.3-13.2 21.1-20.5 0 6.8-18.7 13.9-21 20.5zm-13.1-7c-8.4 9.4-2.6 23.6-6 34.1 4.1-7.7 2-26.9 6-34.1m13.8 40c-12.6 12.5-7.5 26.3-12.6 32.3 6.3-8.3 5.4-24.5 12.6-32.2zm-26.2 1.8c10.8 10.9 4.2 27.3 9.8 35-6.3-6.6-5.4-27-9.8-35m13.6 0c1.4-12.6 14.3-19.2 15.4-26 1.5 6.8-12.4 17.5-15.4 26m6.5 30c-2 8.8 5.7 27.6 3.3 33.4 5.2-10-4.4-29-3.3-33.3zm-16.6 20.1c5.2 15.6 15.5 14.6 18.8 24-2.4-9-16-17.1-18.8-24m33.5 7.3c6.8 10.5 1.2 22.4 6.8 29.9-8-7.5-3.7-21.4-6.8-29.9m-16.4 28.6c8.2 13.9 25.1 12.6 32 22.6-6.9-12.6-27.8-14.7-32-22.6m29.8-1.7c14.5 9.2 10.1 18.8 21.1 29-13.8-10.2-12.6-21.5-21.1-29m6.8 37.1c14-.4 34.3 16.3 46.4 15-12.1 2.3-34.7-12.6-46.4-15m22.8-15c.9 13.1 37.5 21.4 41.5 30.2-4.5-11.5-36.6-20-41.6-30.1zM301 116c2.8-11.5 17-13.6 18.8-20.5-.7 7.3-17.4 15.4-18.8 20.5m41.5-28.6c-2 8.8-17.3 13.7-19.4 20.3.7-9 16.4-14 19.4-20.3m-12 20.8c7.3-10.7 22.3-8 27.5-14.1-3.8 7.2-22.3 7.4-27.5 14z" class="arab-fil0"/>
<path fill="none" stroke="#f7c608" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" d="M429.8 240c0 55.5-49.3 100.4-110.3 100.4-60.9 0-110.3-44.9-110.3-100.3 0-55.5 49.4-100.4 110.3-100.4 61 0 110.3 45 110.3 100.4z"/>
<path fill="#f7c608" d="m298 340.5-.5 1.2q-.5 1.3-2.1 1.2l-8-1.9 2.6-7.7 8 1.7q1.5.4 1 1.8l-.2 1m-19-4.8.4-1.2q.5-1.4 2-1l7.8 2.5-2.5 7.7q-4.2-1-7.9-2.3c-.8-.4-1-2-.7-2.9"/>
<path fill="#006233" d="m296.4 339.8-.3.9q-.5 1-1.7.8l-6.6-1.6 1.8-5.6c2.4.7 4.9 1.2 6.6 1.5q1.3.4 1 1.4l-.2.7m-15.8-4 .3-1q.4-.8 1.6-.6 2.8 1 6.5 2l-1.8 5.6-6.5-1.9c-.7-.4-1-1.5-.7-2.1"/>
<path fill="#f7c608" d="m267.7 330.8-.7 1q-.9 1.2-2.4.7c-2-1.2-4.7-2.5-7-3.9l4.8-6.8 7.1 3.7q1.2.8.5 2l-.6 1m-16.7-9.6.7-1q1-1.2 2.3-.5 2.8 2 6.7 4.4l-4.9 6.8-6.7-4.2c-.7-.7-.4-2.3 0-3"/>
<path fill="#006233" d="m266.5 329.7-.6.8q-.6.8-1.9.3c-1.6-1-3.8-2-5.8-3.2l3.5-4.9c2 1.3 4.3 2.4 5.9 3.1q1 .6.5 1.6l-.5.6m-13.8-7.9.5-.8q.7-.8 1.8-.2l5.6 3.6-3.5 4.9q-3.1-1.7-5.6-3.5c-.6-.5-.5-1.7-.1-2.2"/>
<path fill="#f7c608" d="m241.8 313.7-1 .8q-1.3.8-2.6 0c-1.5-1.6-3.7-3.5-5.5-5.5l6.7-5.3c2 2.1 4.2 4 5.7 5.4q1 1-.1 2l-.9.8m-13-13.4 1-.9q1.2-.8 2.3.2a73 73 0 0 0 5 6l-6.7 5.2q-2.9-3-5.2-5.8c-.5-.8.3-2.2 1-2.8"/>
<path fill="#006233" d="m240.9 312.4-.8.6q-.8.6-1.9-.2l-4.6-4.6 4.9-3.8 4.7 4.5q.7 1 0 1.7l-.7.5m-10.8-11.2.7-.6q1-.6 1.8.2 1.8 2.3 4.3 5l-4.9 3.7-4.3-4.8c-.4-.6.1-1.7.6-2.1"/>
<path fill="#f7c608" d="m222.2 290.7-1.3.5q-1.3.4-2.4-.6l-3.6-6.8 8.1-3.3c1.3 2.5 2.7 5 3.8 6.6q.5 1.3-.8 2l-1 .4m-8.4-16.2 1.2-.6q1.5-.4 2.2.8a71 71 0 0 0 3 7l-8 3.3a60 60 0 0 1-3.3-6.8c-.2-1 1-2.1 1.9-2.5"/>
<path fill="#006233" d="m221.7 289.2-.9.3q-1 .4-1.8-.6l-3-5.6 5.8-2.4 3.2 5.5q.4 1.2-.5 1.6l-.8.3m-7-13.5 1-.3q1-.4 1.6.6l2.5 5.8-5.7 2.4-2.7-5.7c-.2-.7.6-1.6 1.2-1.9"/>
<path fill="#f7c608" d="m210.5 263.5-1.4.2a2 2 0 0 1-2-1.2l-1.5-7.4 8.8-1.1a64 64 0 0 0 1.7 7.3q0 1.4-1.4 1.7l-1.2.2m-3-17.7 1.4-.2q1.5-.1 1.8 1.2.1 3.3.7 7.5l-8.8 1.1-1-7.4c.2-.9 1.7-1.7 2.6-1.8"/>
<path fill="#006233" d="m210.5 262-1 .1q-1 .1-1.5-1l-1.1-6.2 6.3-.8 1.3 6.1q.1 1.1-1 1.4l-.8.1m-2.5-14.7 1-.2q1.2 0 1.3 1.1.2 2.8.7 6.2l-6.3.8q-.6-3.1-.8-6.1c0-.7 1.1-1.4 1.8-1.5"/>
<path fill="#f7c608" d="m207.7 234.5-1.4-.2q-1.5-.2-1.6-1.7c.3-2 .5-4.8 1-7.4l8.7 1.2a65 65 0 0 0-.7 7.4q-.3 1.4-1.8 1.3l-1.2-.2m2.6-17.7 1.4.1q1.4.4 1.4 1.7a69 69 0 0 0-1.7 7.4l-8.8-1.2q.6-3.8 1.4-7.4c.4-.8 2.1-1.2 3-1"/>
<path fill="#006233" d="M208.2 233h-1q-1-.4-1.1-1.5l.8-6.1 6.3.8-.6 6.2q-.2 1.1-1.4 1h-.8m2.1-14.9 1 .2q1.1.2 1 1.4-.7 2.6-1.3 6l-6.3-.7 1.1-6.2c.3-.7 1.5-1 2.2-1"/>
<path fill="#f7c608" d="m214 206-1.3-.6q-1.3-.5-1-2c1-2 2-4.6 3.2-6.9l8 3.4a70 70 0 0 0-3 7q-.6 1.1-2 .7l-1.2-.5m8-16.4 1.3.6q1.3.6.8 2a73 73 0 0 0-3.8 6.6l-8.1-3.4q1.7-3.6 3.6-6.7c.6-.7 2.4-.7 3.2-.3"/>
<path fill="#006233" d="m215 204.7-1-.4c-.6-.2-.8-1-.6-1.6l2.6-5.7 5.8 2.4-2.5 5.8q-.6 1-1.6.6l-.8-.3m6.7-13.6.9.4q1 .4.5 1.6l-3.2 5.5-5.7-2.4q1.4-3.1 3-5.6c.4-.6 1.7-.7 2.3-.4"/>
<path fill="#f7c608" d="m228.9 180.2-1.1-.9q-1-.9-.4-2.2c1.6-1.6 3.4-3.9 5.2-5.8l6.8 5.3a72 72 0 0 0-5 6 2 2 0 0 1-2.4 0l-.9-.6m12.8-13.7 1 .8q1.2 1 .2 2.2l-5.7 5.3-6.8-5.3q2.8-3 5.6-5.5c.8-.5 2.5 0 3.2.5"/>
<path fill="#006233" d="m230.2 179.2-.8-.6q-.7-.7-.1-1.7l4.3-4.9 4.8 3.8-4.2 5q-.8.8-1.8.2l-.6-.5m10.6-11.4.8.6q.7.7 0 1.6l-4.8 4.6-4.8-3.8q2.3-2.6 4.6-4.6c.7-.5 2-.2 2.4.2"/>
<path fill="#f7c608" d="m251 159.2-.7-1q-.7-1.2.4-2.3c2-1.1 4.4-2.8 6.8-4.2l4.8 6.8a78 78 0 0 0-6.7 4.4 2 2 0 0 1-2.2-.4l-.7-1m16.5-9.8.7 1q.8 1.4-.4 2.1-3.3 1.5-7.2 3.7l-4.8-6.8q3.5-2.2 7-3.9c1-.2 2.4.7 2.9 1.4"/>
<path fill="#006233" d="m252.7 158.6-.6-.7q-.4-1 .4-1.7 2.6-1.7 5.7-3.5l3.4 4.8-5.5 3.7q-1.1.6-1.8-.3l-.5-.6m13.7-8.2.6.8q.4.9-.5 1.5l-6 3.1-3.4-4.8 5.8-3.3c.8-.2 1.9.4 2.3.9"/>
<path fill="#f7c608" d="m279 144.9-.5-1.3q-.2-1.3 1-2l7.9-2.3 2.5 7.7a83 83 0 0 0-7.8 2.6q-1.4.3-2-1l-.3-1m18.8-5.4.4 1.3q.3 1.3-1 1.8l-8.1 1.7-2.5-7.7a85 85 0 0 1 8-2c.9 0 2 1.3 2.3 2"/>
<path fill="#006233" d="m280.6 144.7-.3-1q-.1-.8 1-1.4l6.5-2 1.8 5.6-6.5 2q-1.2.4-1.6-.6l-.3-.7m15.7-4.4.3.9q.3 1-1 1.4-3 .5-6.6 1.4l-1.8-5.5 6.6-1.6c.8-.1 1.6.8 1.8 1.4"/>
<path fill="#f7c608" d="M310 138.2v-1.3q.2-1.3 1.7-1.7l8.2-.2v8.1a84 84 0 0 0-8.2.4q-1.6 0-1.6-1.5v-1m19.7-.2v1.2q-.1 1.4-1.7 1.5l-8.2-.4V135q4.3 0 8.2.2c1 .2 1.7 1.7 1.7 2.6"/>
<path fill="#006233" d="M311.8 138.5v-1q0-.9 1.3-1.2l6.9-.1v5.8q-4 0-6.9.3-1.2-.1-1.3-1v-.9m16.3-.1v.9q0 1-1.3 1l-6.8-.2v-5.8l6.8.1c.8.2 1.3 1.2 1.3 1.9"/>
<path fill="#f7c608" d="m340 139.6.3-1.2q.5-1.2 2.1-1.2l8 1.8-2.5 7.8-8-1.6q-1.4-.5-1.1-1.9l.3-1m19 4.7-.4 1.2q-.5 1.4-2 1l-7.8-2.4 2.5-7.8q4.1 1 7.8 2.3c.8.4 1 2 .8 2.8"/>
<path fill="#006233" d="m341.5 140.3.2-.9q.5-1 1.7-.8l6.6 1.5-1.7 5.6-6.7-1.4q-1.1-.4-1-1.4l.3-.7m15.8 4-.3.8q-.4 1-1.6.7l-6.5-2 1.7-5.6q3.5.9 6.6 1.9c.7.3 1 1.5.7 2"/>
<path fill="#f7c608" d="m370.2 149.1.7-1q.9-1.2 2.4-.7c2 1.1 4.7 2.4 7.1 3.8l-4.7 6.9a81 81 0 0 0-7.3-3.6q-1.2-.9-.5-2.1l.7-1m16.8 9.5-.8 1a2 2 0 0 1-2.2.5l-6.7-4.3 4.7-6.9q3.7 2 6.8 4.2c.7.6.4 2.2-.1 3"/>
<path fill="#006233" d="m371.5 150.2.5-.8q.6-.7 1.9-.4 2.6 1.4 5.8 3.2l-3.4 5-6-3.1q-1-.6-.4-1.6l.4-.7m14 7.9-.6.8q-.6.8-1.8.2l-5.6-3.6 3.4-4.9 5.7 3.4c.6.6.5 1.7.1 2.3"/>
<path fill="#f7c608" d="m396.3 166 1-.9q1.2-.8 2.5 0l5.6 5.5-6.6 5.3a75 75 0 0 0-5.8-5.3q-1-1 .1-2l.9-.8m13.2 13.3-1 .9a2 2 0 0 1-2.4-.2 72 72 0 0 0-5-5.9l6.7-5.3q2.8 3 5.2 5.7c.4.8-.3 2.3-1 2.8"/>
<path fill="#006233" d="m397.2 167.3.7-.6q.8-.5 2 .1 2 2.1 4.6 4.6l-4.8 3.8-4.8-4.5q-.8-.9 0-1.6l.7-.5m11 11-.8.7q-.9.6-1.8-.2l-4.3-4.9 4.8-3.8 4.4 4.7c.4.7-.1 1.8-.6 2.2"/>
<path fill="#f7c608" d="m416.1 188.9 1.3-.6q1.4-.4 2.4.7l3.7 6.6-8.1 3.5q-2-4-4-6.6-.5-1.3.9-2l1-.5m8.6 16.2-1.3.5c-.8.4-1.8 0-2.1-.7a71 71 0 0 0-3.1-7l8-3.4 3.3 6.9c.2.9-1 2-1.8 2.4"/>
<path fill="#006233" d="m416.6 190.4.9-.4q1-.4 1.8.6l3 5.5-5.8 2.5-3.2-5.5q-.4-1 .5-1.6l.8-.3m7 13.5-.8.3q-1 .4-1.7-.6-1-2.6-2.6-5.8l5.8-2.5 2.8 5.7c.1.8-.7 1.7-1.3 2"/>
<path fill="#f7c608" d="m428 215.9 1.4-.2a2 2 0 0 1 2.1 1.2l1.5 7.3-8.8 1.3a65 65 0 0 0-1.7-7.3q-.1-1.4 1.4-1.7l1.1-.2m3.2 17.7-1.4.2q-1.5.1-1.8-1.3l-.8-7.4 8.8-1.3q.6 3.9 1 7.5c0 .9-1.6 1.7-2.5 1.8"/>
<path fill="#006233" d="m428 217.4 1-.1q1.1 0 1.5 1 .6 2.8 1.2 6.1l-6.3 1-1.3-6.2q-.2-1.2 1-1.3l.8-.2m2.6 14.7-1 .2q-1.1 0-1.4-1l-.7-6.3 6.3-.9q.7 3.3.9 6.2c0 .7-1.1 1.4-1.8 1.5"/>
<path fill="#f7c608" d="m431.1 244.9 1.4.1q1.5.3 1.7 1.8l-.9 7.4-8.8-1.1c.4-2.7.6-5.5.6-7.5.1-.8 1-1.4 1.9-1.2l1.1.1m-2.4 17.8-1.4-.2q-1.5-.2-1.4-1.7 1-3.1 1.6-7.3l8.8 1q-.6 4-1.3 7.4c-.4.9-2.1 1.3-3 1.2"/>
<path fill="#006233" d="M430.6 246.4h1q1 .4 1.2 1.5l-.8 6.2-6.3-.8.6-6.2q.1-1.1 1.3-1.1h.9m-2 14.9-1-.1q-1.1-.2-1-1.4.7-2.7 1.2-6.1l6.3.8-1 6c-.3.8-1.6 1.2-2.2 1"/>
<path fill="#f7c608" d="m425.1 273.5 1.3.5q1.3.7 1 2l-3 7-8.2-3.3a66 66 0 0 0 3-7q.6-1.2 2-.8l1.2.4m-7.9 16.5-1.2-.5q-1.4-.7-.9-2 1.9-2.8 3.8-6.6l8.1 3.3-3.5 6.7c-.6.7-2.4.7-3.3.3"/>
<path fill="#006233" d="m424.2 274.8 1 .3q.7.5.6 1.7l-2.6 5.7-5.9-2.3 2.5-5.8q.6-1 1.6-.8l.8.4m-6.5 13.6-1-.3q-1-.6-.4-1.6l3-5.5 5.9 2.3-3 5.6c-.5.6-1.8.7-2.4.4"/>
<path fill="#f7c608" d="m410.5 299.4 1.1.8q1 1 .4 2.3c-1.6 1.6-3.4 3.8-5.2 5.8L400 303c2-2 3.8-4.3 5-6q1-.9 2.3-.1l.9.7m-12.6 13.8-1-.8q-1.2-1-.3-2.1 2.6-2.2 5.7-5.5l6.8 5.3-5.5 5.6c-.8.5-2.5 0-3.2-.6"/>
<path fill="#006233" d="m409.2 300.4.8.6q.7.6.1 1.7l-4.3 4.8-4.9-3.7q2.6-2.8 4.2-5 .9-.8 1.8-.2l.6.5m-10.4 11.5-.8-.6q-.8-.7 0-1.7l4.6-4.5 5 3.7q-2.4 2.6-4.7 4.7c-.6.4-1.8.1-2.4-.3"/>
<path fill="#f7c608" d="m388.5 320.5.7 1q.7 1.3-.3 2.3l-6.7 4.3-5-6.8a78 78 0 0 0 6.7-4.4 2 2 0 0 1 2.2.4l.7.9m-16.4 10-.7-1q-.8-1.3.4-2.2l7.2-3.7 4.8 6.8-7 4c-.9.2-2.3-.7-2.9-1.4"/>
<path fill="#006233" d="m386.9 321.1.5.8q.5.8-.4 1.7l-5.6 3.5-3.5-4.8 5.6-3.7q1-.6 1.7.2l.5.7m-13.6 8.3-.6-.8q-.4-.9.5-1.6l6-3.1 3.4 4.8q-3 1.9-5.8 3.3c-.7.3-1.9-.3-2.2-.8"/>
<path fill="#f7c608" d="m360.8 335.1.4 1.2q.3 1.4-1 2l-7.8 2.5-2.6-7.8a75 75 0 0 0 7.7-2.6q1.5-.2 2 1l.4 1m-18.8 5.5-.4-1.3q-.3-1.4 1-1.8 3.6-.6 8-1.8l2.7 7.8q-4 1.1-8 2c-1 0-2-1.3-2.3-2"/>
<path fill="#006233" d="m359 335.3.4.9q.2 1-1 1.5l-6.4 2-1.9-5.6 6.4-2q1.3-.4 1.7.6l.2.7m-15.6 4.5-.3-.9q-.2-1 1-1.4l6.6-1.5 1.9 5.6-6.6 1.6c-.8 0-1.7-.8-2-1.4"/>
<path fill="#f7c608" d="M329.7 342v1.3q-.1 1.4-1.6 1.7c-2.4 0-5.4.3-8.2.3l-.1-8.1a82 82 0 0 0 8.2-.5q1.6 0 1.6 1.5v1m-19.6.4v-1.2q0-1.4 1.6-1.5 3.5.2 8.2.3v8.1l-8.2-.1c-.9-.2-1.6-1.7-1.6-2.6"/>
<path fill="#006233" d="M328 341.8v.9q-.1.9-1.4 1.2l-6.8.2v-5.7q4-.1 6.8-.4 1.2 0 1.4 1v.8m-16.4.3v-1q0-.9 1.3-1 3 .3 6.9.2v5.8H313c-.8-.2-1.4-1.3-1.4-1.9"/>
</svg>

After

Width:  |  Height:  |  Size: 24 KiB

+72
View File
@@ -0,0 +1,72 @@
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-as" viewBox="0 0 640 480">
<path fill="#006" d="M0 0h640v480H0Z"/>
<path fill="#bd1021" d="m-.6 240 640-240v480Z"/>
<path fill="#fff" d="m59.7 240 580-214.3v428.6"/>
<path d="M474 270.4c5.1.3 5 5.4 5 5.4l18 .4c2.3-6.3 4.8-5.6 9.2-2.4a33 33 0 0 0 8.7 4.2c1.7-9 14.5-7.2 14.5-7.2 5.6-13 6-12.9 2.7-14.5a11 11 0 0 1-4.6-4.5c-3-3.7-4.6-9.1-5-12.4s-4.2 1.6-5 .6c-.6-1-6.3-.4-6.3-.4 1.4 1.5-3.4.6-3.4.6.5.4 0 1.7 0 1.7-.4-.6-4.1-1.2-4.1-1.2l-1.1 1.6c-2-.8-6-.7-6-.7a20 20 0 0 0-10.9 2.8c-1.6.9-7.4 3.8-12.3 8.5-4.7 4.6-7.4 4-7.4 4-1.4 5.2-12.8 11.5-12.8 11.5-1.8 1.6-7.6 2.4-10.5 0s0-6.9 0-6.9c1.2-2 2.2-1.9 2.3-9 .1-4.7 5-8.5 10-14 6.3-6.8 15-18 15-18 0 3.4 1.8 4 1.8 4 1.7-3.5 4.2-6.3 4.2-6.3q.3.4.5.4 3-3.7 3-3.6c-.5-.3-6 0-11 4.4s-8.4 3-8.4 3c-3.5-1.2-3.8-4-3.8-4-2.5-10.9 7.4-18.7 7.4-18.7-13.4-3.2-3.7-20.3 13-27.5s16.4-10.5 16.4-10.5a13 13 0 0 1 1.8 3c.1 0 1.4-1.9 11-6.1 9.6-4.3 14.2-8 14.2-8 1.2 2.4 1 4 1 4 26.3-9.1 52-30.2 52-30.2.8 1.7.5 4.4.5 4.4 4.2-4 19.7-13.2 19.7-13.2a9 9 0 0 1-4.6 8.2l.8 2.3a360 360 0 0 0 14.4-9.5c4.3 3.7.4 9.8.4 9.8 1.6-.3 2.6-1.6 2.6-1.6 1.2 6.4-5.9 12-5.9 12 1.3 0 3.3-1.3 3.3-1.3-1.3 7-14.4 14.6-14.4 14.6 1.9 1.8 0 4-1.6 5-1.5 1-4.3 3.3-3.4 4.2s6.7-3.2 6.7-3.2c1 2.9-6.5 8.6-6.5 8.6 5.2.7 19.6-5.9 19.6-5.9-1.1 5.6-6.6 10-13.3 12.5s-6.4 3-6.4 3c1.2.8 10.5-1.8 10.5-1.8-2.8 6.2-12.5 10.5-12.5 10.5 2.7 2.3 6.3-.4 10-2.9a58 58 0 0 1 14-6.4c5.3-1.9 9.2-.5 9.2-.5a12 12 0 0 1 8.4.6c8.7.7 9.6 3.9 9.6 3.9 1 .2 1.7.6 4 2.3 2.1 1.6 2 6.6 2 9.2-.2 2.4-.9 2.4-1.3 3q-.5 1.2-.5 2.5c0 1-2.2 6.9-15.7 6.9h-20.3c-1.2 0-2.5.7-2.5.7-5.7 2.8-2.7-2-9.4 3.6s-10.2 4.6-10.2 4.6A90 90 0 0 1 568 221c-4 2.6-3.3 2.3.3 3.8s8.8 0 8.8 0c-3.4 2.3-1 3.4-1 3.4 4.4-2.7 7.2-1.7 7.2-1.7 1.4 3.9-3.8 10-3.8 10 2 .3 5.8 0 5.8 0-1 2.7-4.6 5.6-7.4 6.4-2.7 1-2.3 1.3-1.4 3 .7 1.6.1 3.3.1 3.3-4.8-3.3-5-.4-5-.4-.5 4-.4 9.6-.4 9.6-3.4-1.7-3.5.5-3.5.5-1 3.6-5.1 7.7-5.1 7.7-.2-2.2-2.2-2.8-2.2-2.8-2.2 4.2-6.1 6.7-6.1 6.7-.5 3.5.5 8.6.5 8.6-2.6-.6-3.5-.6-4 0-.3.7.6 1 .6 1l33.4.8c.5 0 2.5.3 2.5 3.8 0 3.7-3 3.9-3 3.9l-36.4-.9s.1 1-1.8 2-1.2-1.1-1.7 3.4-7.8-.4-7.8-.4c-1.2 1.8-4 4-4 4-1.7-5-3.4-6.4-6-2.2s4.8 3.6 4.8 3.6 42.8-6.3 45.1-6.5 4.9-.1 6 3.1-5.3 3.8-5.3 3.8l-44 4.8c-.9 2.6-4.5 2.4-4.5 2.4.3 2.5-2.3 4-3.6 5-1.4.8-5.6.5-5.6.5-5 3.4-7.6.7-7.6.7-3.3 1.4-5.4.8-8.1-.4-2.8-1.2-2.5-4.5-2.5-4.5l-27.8 3a7 7 0 0 0-2.2 1.2c1 1.3-2 4.3-2 4.3.9.5 2.5 2.1 2.7 5.5.2 3.7-4.5 4.3-2.2 7 2.3 2.5 6.7.3 11.5-2s9.5-2 11.5-2 7.8 1.6 11.4 2.8 4.8.4 5-1.4 1.9-2.3 1.9-2.3c-.5 1.8.5 2.6.5 2.6a11 11 0 0 0 3.7-1.3c-.2 1.4-2 2.2-2 2.2-3.4 2.3 1.4 1.5 1.4 1.5a44 44 0 0 1 15.4-1.5 123 123 0 0 1 14.3 5.2c.4-1.2.1-4 .1-4 3 .8 4.2 2.5 4.2 2.5 1.2-1.2.4-3.4.4-3.4 9.7 5.5-2 8-5.1 9s-3 2.3-3 2.3a28 28 0 0 1 6.4-1.3c2.2-.2 1.4 0 6.5-1 5.2-1 7.8 1.2 7.8 1.2-4.3.2-5.5 1.5-5.5 1.5 2.6 1.7 0 3.4 0 3.4-3.8-5-7.2.1-7.2.1a15 15 0 0 1 6.4 1.4l5.4 2.7c3.6 1.6 2.9.6 5.6 1.6 2.8 1 1.7 3.7 1.7 3.7a7 7 0 0 0-3.7-3c-.2 3-3.1 3.5-3.1 3.5 3.6-4-4.1-5.8-7.8-5.7-3.6 0-6.3 2.4-6.3 2.4 7.3 6.9 12.3 4.6 12.3 4.6-.9 2.5-6.9 1.5-6.9 1.5 2.8 2.2 2.5 3.6 2.5 3.6-1.5-1.4-4-.7-9.2-4-5.2-3.5-9.9-2.3-9.9-2.3 5.2 5.3-1.8 8.6-1.8 8.6-2.6 1.6 1 3.5 1 3.5-3.2.6-3.6-2.6-3.6-2.6-1.7-.4-4.2 1.6-4.2 1.6.2-3.2 4.6-1.6 4.6-5 .2-3.5-4-6.2-16.3-4.5s-16-2.2-16-2.2c-1 0-1.2 1-1.2 1 2 2 2.9 2.8 2.6 4.2-.4 1.3.6 1.8.6 1.8-2.3-.2-2.4-2.8-2.4-2.8 0 1.1-.5 1.2-1.3 2.3s0 2.7 0 2.7c-1-.8-2.7-1.8-1-4.3 1.2-1.8-2.7-4.2-2.7-4.2-1.5-1.5-5.6 0-5.6 0a15 15 0 0 1-13.3-3.7c-1 0-2.9-.6-2.9-.6-8.9 4-16.7-4.6-16.7-4.6-6.7 1.3-9.8-2-11.8-5.2a12 12 0 0 0-5.2-5c-2.6-1.6-5.2-6.2-2.6-8.7 2-2.1 1.5-2.6 1.5-2.6-3.5-5.9 6.1-7.7 6.3-9.2.3-2 2.3-3.3 4.5-3.4s2.2 0 3.7-1.4c1.3-1.5 4 .3 4 .3.7-.4 5.5-4.1 9.7-2.2 4.3 1.9 7.9.6 7.9.6 3-.7 28-4 28-4 1.5-2.5 2.7-5.4 9.6-7s12-6 12-6c-1.2-1.2-3.2-1.2-4.2-1.3-1.1 0-3.2-2-3.2-2-1.3.6-2 .3-11 5.8-8.1 5-8.3-4.8-8.3-4.8H479c-.3 3.7-3 5.2-3 5.2l-6.5.3c-3.6-1.8-3.6-8.2-3.6-8.2-19.4.3-30.1 7.2-30.1 7.2-22-11.2-39.2-13.8-39.2-13.8a122 122 0 0 0 40.8-10.2 63 63 0 0 0 28.5 9c.5-5.4 4.1-6.7 4.1-6.7z"/>
<path fill="#ffc221" d="M442.3 314.6c-5.5 3.2-4.5 5-4 6s.5 2-1 3.6c-1.5 1.5-1.4 2-1.4 2 .3 5.4 4 6.6 5.7 8 1.4 1 3.6 4.5 3.6 4.5 2.9 4.1 5.9 4.2 8.1 4.2 2.3 0 2-.3 1-1.3l-3.4-2.7a18 18 0 0 1 5.9 4.1c5.6 6.2 10.8 5.4 13.1 5.2s2-1.7 2-1.7l-2.4-.4c-8.5-.8-11-6.4-11-6.4a24 24 0 0 0 15.6 6c2.4-.1 2.3.6 1.7.8l-2.4-.2c-1.1 0-1.1.3-.9.8q.6.5 2.7.4c1.4 0 .3.1 3.8 2.8 3.6 2.8 12.3.5 12.3.5-5.7-1.3-6.4-4-6.4-4-7.7 1-10.8-3.6-10.8-3.6a33 33 0 0 0-5.6-3.5 9 9 0 0 1-5-5.8c1.3 1.8 3.7 3.8 6.7 4.6s3.8 1.2 3.8 1.2a4 4 0 0 1-2.3-.2c-3-1-1.3.3-1.3.3 3.4 2.7 4.3 2.5 4.3 2.5 8.6.9 4.3-2.6 4.3-2.6 6.2 1.5 7.2-.8 7.2-.8 1.3 2.7 6 1.7 6 1.7-6.2 3-1.5 2.1-1.5 2.1 6.3-1.1 7.6.5 7.6.5 1.6 1.5 3.4 1.4 3.4 1.4s1.2 0 3.5.4c2.4.5 6.2 2.5 9.6 2.2 3.5-.5 4 .6 4 .6-.6-.3-2.2-.5-4.8.7-2.7 1.3-7.4 1.6-14.2 0s-7.4-1.3-7.4-1.3a9 9 0 0 1 3.4 4c.3 1.2 1.5 1.2 1.5 1.2.5-1.5 2.5-2.1 2.5-2.1a27 27 0 0 0 5 2.8c.4-.7 0-1.3 0-1.3 2.6 2.5 5.6 1.7 5.6 1.7.8-.5.6-2 .6-2 1 0 1.2.6 2 1.2.7.4 3 .1 3 .1-.8-.4-1.5-1.7-1.5-1.7 3.5-2.3 11-1.3 11-1.3 5.3 1 4.7 4.5 4.7 4.5a10 10 0 0 1 2.5 2.1c.5-1.2 0-2.5 0-2.5 2.6 1.2 3 4 3 4 3-3.2-2.7-6.8-2.7-6.8 2.7-.4 5.7-.2 7.5 0a14 14 0 0 1 6.6 3.1c2.1 1.7 5.9 2.5 5.9 2.5-.1-.7-2.2-2-2.7-2.2s-.6-.9-.6-.9c1.9.4 3.1.2 3.1.2-6.4-4-8.1-5.9-8.1-5.9 2.4.3 3.8-1.2 3.8-1.2-5.1 0-5.4-1.2-5.4-1.2.7.1 3.1.7 6.2.1s7.2 0 7.2 0c-2.2-3.6-10.7-3-13.5-2.8s-3.8-.2-3.8-.2c.4-.2.9-.6 3-.7 2.2 0 4.3.2 6.8-1.6 2.3-1.6 5.7-1 5.7-1-.8-1.6-4.7-2.2-8 0-3.5 2.1-6.5 1.5-6.5 1.5 5.3-.8 6.9-2.7 6.9-2.7-1.6-.4-2.5.1-5.8.8-3.2.6-4-.5-4-.5 3.5-2.1 6-3 6-3-3-.6-5.8-2-5.8-2-3.2 3-5.6 4.6-11.7 1.6-6-3.2-9.2-2.8-9.2-2.8a14 14 0 0 1 14.8.6c4 2.3 5 .4 5 .4-1.2-.7-1-1.5-1-1.5 9.6 4.9 13.8 2 15.9.5s-1-3.4-1-3.4c-.2 3-4 4.6-7.2 3.5-3-1-6-2.4-10.4-4.3s-10-.8-15.1.2c-5.2 1.1-5.9.6-6.4.2s-.7-1.7-3.4-.6c-2.6 1.1-8.8-1.8-12.6-2.7s-10.1-.5-15.5 2.5c-5.4 3.1-8.2 2.3-9.8 1.6-1.6-.8-2.7-2.8-.9-4.6s2-2.3 1.8-5c-.2-2.6-2.8-4.2-2.8-4.2 2.4-2.5 3-3 2.2-4-.8-1.2.4-1.2 1.8-1.8s.8-.7.5-1.5-1.2-.6-1.2-.6c-3.1.1-4.9-.8-4.9-.8-5.2-2.4-10.1 2.3-10.1 2.3-3-2.3-3.7-.7-4.2-.2q-.8.9-3 1c-1.2.2-3.1.7-3.8 1.9 0 0-.6 1 .1 2 0 0 .8 1.2-.6 2.7-1.5 1.5-2 1.8-1.5 3.3q.6 2-.3 3.3s-.7-.7-.5-1.7q.3-1.5 0-2s-1.5 1.4-1.8 2.4c0 0-.6-1.6 1.6-3.7q3.1-2.9 2.4-4c-.4-.6-2 .4-2.3.6z"/>
<path d="M448.4 338s-2.7-2-2.4-4.9c.3-2.7.3-3 0-3.7 0 0-.5.3-.4 1.4s-.2 2.1-.3 2.3c0 0-1.3-2.3-2-2.8 0 0 .6-2.4-.2-3.4q-1-1.5-2.4-.8c-1.2.4-2.1 1.5 2 4.8 0 0 1.5 1.3 2.5 3.9s2.8 3 3.1 3.2zm13-7.8s-.1-1.5 1.3-4.3a6 6 0 0 0 .3-5.6c-.3-.8-.6-.5.9-1.7 1.7-1.5-.7-3.4 2.3-6 0 0 1.8-1.6 2.3-2.3 0 0-3 1.6-5.2 2.5-2 .8-9.6 4.6-8 7.1 1.8 2.5 1.6 2.7 1.3 3.8 0 0-4.6-2.5-3-6.4 0 0 .8-1.5 2.7-3.4 1.8-1.6.8.4 4.3-1.7 0 0 2.7-1.6 4.3-3.9 0 0-2 1.2-2.6 1.4 0 0-4 .8-5.8 2.5-1.6 1.7-5.1 4.7-4 8 0 0-4-.4-5-4.7 0 0-7.6 9.4 8.4 13.8 0 0 3 .8 5.6 1z"/>
<path fill="#ffc221" d="M531.6 299c6-1 40.4-6.2 43.6-6.5 3.4-.3 4.7-.8 5.9 2 1.3 3-4.8 3.1-4.8 3.1l-41.1 4.7c-2 .2-2.5-.6-2.5-.6l-1.5-2s-.5-.6.4-.8z"/>
<path fill="#5a3719" d="M447.3 317.7s-4.4 9.3 13 11.6q-.1-.1.8-2.5c.8-1.5 2.3-4.5.8-6.4s1.2-.9 1.5-3.4c.5-2.5-.2-2.2 1-3.8 0 0-5.4 2-7.6 4.5-2 2.4 2.9 4.2 0 6.9 0 0-2.5-1-4-3.6 0 0-3.3 0-5.5-3.3"/>
<path d="M464.5 329.2s4.3 3.7 9.4 3.6c5.1-.3 7.4-1.6 8.7-3.6 0 0 1 1.5 1 2.6 0 0 4.4-3.7 12-.5s5.4 2.3 7.1 2.5c0 0-3.3-.5-10.7 2.9-7.7 3.5-27.7 2.3-27.6-7.5z"/>
<path fill="#5a3719" d="M457.3 312.6s1.9.3 3.8-1.9c0 0-2.6.5-3.8 2zM442.6 330s-3.6-2.8-1.3-3.4c0 0 1.7-.3 1.3 3.4"/>
<path d="M521.2 347.8s2-3.5 7.5-3.5 6.1 2.6 13.1 3c0 0-8.4 2.4-14.2.3-3-1.1-5.8-.2-6.4.2"/>
<path fill="#5a3719" d="M466.3 331.7s8.4 5 15.7-.5q.2 0 1.2 2s5.6-5.4 15.5.4c0 0-1.2-.1-5.9 1.8-6.1 2.7-21.4 4.5-26.5-3.8z"/>
<path d="M498.3 336.7s8 1 14.7.6c4.1-.2 8.6-1 6.4.4-2.3 1.3-1.1 1.5 8.4.7 9.4-1-.1 1.7 6.4 2.6 0 0-15.9 8-35.9-4.3"/>
<path fill="#5a3719" d="M519.2 331.7s4.6-1.7 9 .3c4.3 2 3.6 2.2 6.5 2.5 0 0-2 2.9-6.7.6s-6-2.8-8.8-3.4m5.2 14.3s4.6-2.3 9.6 0c.6.4 2 1 3.3 1.2 0 0-3.8 1.3-7.8 0-1.7-.5-3-.9-5.1-1.2m-22.7-8.2s10.3 1 15.8-.1c0 0-6.4 3 9.7 1.7 0 0 3.5-.4 3 .1-.3.5-.6 1 1.2 1.5 0 0-12 5.4-29.7-3.2"/>
<path d="M450.7 329.2s.2.7 2.4 1.7a9 9 0 0 1 4 3.9 6 6 0 0 0 3.5 2.9s-8 1.7-11.6-2.6c0 0-2.7-3 1.6-6"/>
<path fill="#5a3719" d="M513.7 347.6s-3.1-.2-7.5-1.7c-4.3-1.5-5.4-.2-7.9-2-2.4-1.9-7.3-.7-8.2-.6-1 .1-3.6 0-.3-2.1 0 0-2.6 0-3.6-1.4 0 0-1.2 1.2-5.6.8 0 0 2 3-6 2.1a10 10 0 0 0 11.1 3c0 .2-.5 2.5 3 3.5 3.8.9 4.5 1.6 6.4 2.3 0 0 .3-1.5-4.6-5 0 0 2.6-.2 6.4.7s12.2 3.1 16.8.4m2 3.7s.8 1.8 3.2 1.4a17 17 0 0 1 10.2.8s.7-3.2-7-3.4c0 0-4.8.2-6.4 1.2m-65.2-21s-3 2.5-.3 5c2.4 2.3 6.2 2.1 8 2 0 0-1-.6-2-2-1-1.5-1-2.5-3-3.4-2.1-.9-2.3-1.1-2.7-1.6m-3-12.6s-4.6 9.3 13 11.6q-.2-.1.7-2.5c.6-1.5 2.1-4.5.8-6.4-1.6-1.9 1.1-.9 1.5-3.4s-.3-2.2 1-3.8c0 0-5.5 2-7.7 4.5-2 2.4 2.9 4.2 0 6.9 0 0-2.5-1-4-3.6 0 0-3.3 0-5.5-3.3z"/>
<path d="M493.3 339.3s3.7-.6 13 2.9c9.4 3.4 13.3 2.6 14.6 2.5 0 0-5.2 2.8-13.4-.8-7.2-3.2-7.6-2-14.2-4.6"/>
<path fill="#ffc221" d="M551.8 337.2s2 0 3.4.5c0 0 .7-.7 2.7-1 0 0-1.3-1.2-6.1.5m-6.4-5.2s2.1 0 2.8-1.2c0 0-1.1-1.3-2.8-2 0 0 .4 1.6 0 3.2m-71.7-23.8s-.5-1 1.8-1.4l31.3-4.5s1.5 0 1.7 1c.3 1.1-.1 1.9-7.2 2.7l-25.6 3.2s-1.9.3-2-1"/>
<path fill="#ffc221" d="M502 306.9s0 4.1 4.2 4.7c4 .6 5.5-.2 6.5-2.3.3-.7 1.6-5-.2-5.3q-1.4-.2-2.9.3c-1.4.7-2.7 1.4-2.3 2 1 1.6 1.2 2 1 2-1.2.3-1.8-.6-2-1.2-.3-.8.5-1.2-2.2-.8q-1.9 0-2 .6zm17.5-3.2c2 .3 1.9 4.8-.6 6.9-2.8 2.2-5.4 1.3-5.4 1.3-1.4-.5-1.2-.4-.1-2 1-1.5 1.5-3.6.9-5q-.2-.8 1-1s2-.4 4.2-.2"/>
<path fill="#ffc221" d="M521.3 304.1s1.6 2-.4 5.5c0 0-.8 1 1.1.9 1.8-.2 6.1-2.2 5.7-4.8 0 0-.2-.6-1.3-.6s-.2-.5.3-.8c.4 0 1.9-.6-1.9-3 0 0-.6-.6-1.3-.3-.6.2-2.6 1-2.6 2.2q.2.9.4 1z"/>
<path fill="#ffc221" d="M525.4 300.9s3 2.1 3 2.8-.3 1.5.5 1.3c.8 0 4-.7 3-2.8q-1-3-3.2-3.4c-1.5-.6-1.9.1-3.2 1.1 0 0-.9.6-.1 1m-16.1 3s.5-1.5-2.2-2.2c0 0 1.1-1 3.4-.4 2.2.4 2 2 2 2.1 0 0-1.8 0-3.2.5m5.8-.4s3-.5 4.5-.4c0 0-1.6-3.3-5.7-2.3 0 0 1.5 1.8 1.2 2.7m5.3-.8s0-1.1 2.6-2.1c0 0-1.2-1.2-3-1-2 0-2.5.7-2.5.7s2.3.8 2.9 2.4m1-3.6s1.7.4 2.7 1.3c0 0 1.5-1.7 2.8-2 0 0-2.5-1.4-5.5.7"/>
<path fill="#5a3719" d="M435.8 290.9s7.2-6.2 11.2-5.4 2 .2 6.4-.5 9-1.1 10.8-.9c0 0-5.4-3.8-14.9-3.7 0 0-6.6 2.3-11.3 5.3 0 0-8.9-4.9-18-2 0 0 9.9 3.7 15.8 7.2"/>
<path fill="#ffc221" d="m512.2 301.4 1.2-.2s2 2.5.6 2.5c-1.2 0-.8-.3-1-1a2 2 0 0 0-.8-1.3m-9 .2s-.8 1 .6.8c1.7-.3 1.4 0 3.1-1.3 0 0 1.2-1.1 3.2-.4 0 0 1.8.6 3.2-.1 1.4-.8 1.7-.7 2.5-.6s.8.2 1.7-.6c1-.7 2.8-.1 3.9-1s2.5-.2 0-2c0 0-.5-.5-.5-1 0 0 1 .4 1.8 1 .8.8 2 .5 2.2.4 0 0 .2-2.3 2.3-4.3 2.3-2 2.3-2.2 1-2.2s-3.5-.6-4.3 0-7.2 4.8-11 5.5-7.3 1.8-9.7 5.8m-101.3-23.4s11.7 3 14.3 4.2c0 0 .6-1.9-4.7-3.4 0 0 12.9-.4 26.4 5.8 0 0 6.6-5.6 27.7-3.9 0 0 0-1.8.2-3.3 0 0-14.8-.4-28.4-8.7 0 0-12.3 6-35.5 9.3m64.7 5.6c-.7-11.8 3.8-13 3.8-13s2.1 0 4.4.5c0 0-3.6 4.3-2.6 12.8 0 0 .4 1.3-2.7 1.3s-2.9-1.5-2.9-1.5z"/>
<path fill="#5a3719" d="M469.8 291.7s-2.3-2.3-2.5-4.9c0 0 0-.6 2.2-.6s2.5-.2 3 1.1 2 4 2.3 4.3z"/>
<path fill="#ffc221" d="m474.5 285.7-.2-4.5c.1-6.6 1.2-6 1.7-5.2h2.3s-1.7-7.4-3.7-3a19 19 0 0 0-1.5 10.4q.1 3 .6 4z"/>
<path fill="#5a3719" d="M500.2 285.7s4.3.8-2.3 2.3c0 0 .3 8.2 8.2 2.5 0 0 4.7-3 8-4.2 0 0 1.6-.6 1.4-1.8 0 0 .2-1.5-1.5-1.1 0 0-1.4 0-2.3-.3 0 0-1-1.2-1.6-.8-.6.5-2.1.2-.9 1.7 1.2 1.4 1.5 1 2 .6s3.1-1.4.9.7-4.2-1.2-5-1.8zm-22 1h-2s-1 1.6-1.7-1l-.7 1.6s2.3 8.8 4.4-.6"/>
<path fill="#ffc221" d="M475.4 276.6s-1 5.8.3 9.2l21.1.5s-.2-4 0-9.7H494s-.5 4.6 0 7.5h-.5s-.4-4 0-7.5H491s-.4 4.3 0 7.5h-.5s-.4-3.7 0-7.5H488s-.5 3.9 0 7.5h-.6s-.5-3.9 0-7.5h-2.7s-.6 3.6 0 7.5h-.5s-.6-3.6 0-7.5h-2.7s-.6 4.2 0 7.5h-.6s-.4-4 .1-7.5h-2.5s-.7 3.5 0 7.5h-.7s-.4-3 .2-7.5zm22.3 10.4s-.5-10.2 1.4-13c2-2.6 2.5-2 5.8 0 3.4 2.2 7.7 4.5 8.5 4.8.6.3 1.6.5 1.6 2.4s.3 2.4-2.6 0a9 9 0 0 0-2.7-1.8c-2.6-.9.6.5 1.5 1.9.8 1 1.5 1-.6 1.5a219 219 0 0 0-12.9 4.2"/>
<path d="M505 279.6s-1.5-1.8.5-2.3 2.1 3 2.5 5.1c.3 2.2-2.5-2.1-2.8-2.7zm-2.7 9s-2.3.9-.7 1.6c1.4.7 5.5-2.7 4.2-2.5-1.6.3-3.5 1-3.5 1zm3-3s2-.3 1.6.5c-.3 1-1 .4-1.4.2s-1.6-.7-.1-.8z"/>
<path fill="#ffc221" d="M516 282.8s.6 4 4 5c0 0 2 .4 1.5-1.3 0 0-.3-1.5-.6-2-.3-.7-1.6-1-1.8-1.1-.2 0-.3-.5.6-.2 1 .3 1 .4 1-.3s-.6-.4-1.4-.8c-.4-.2 0-.4.3-.3.4 0 1.3.3 1.3-1 0 0 .1-.8-.9-.8-1.1 0-1-.6-.7-.7s1.5.8 1.9-.6-1.6-.5-1.4-1.2c.3-.8 1.7.3 1.7-.5.2-.8 1.3-1.1-.6-1.4-.9-.1 0-.6 1-.4s1.6-1.2 2.3-1.6 4.2-2.6-.6-1.9-6.1 3-6.3 3.5a13 13 0 0 0-1.3 7.6"/>
<path fill="#ffc221" d="M527 285.8q1-.1 1.4.5c.8 1.6-1 1-2 2.2s-1 1-2.4.5-2-2.5-2-2.5q0-1 1.2-.6s2.3.2 3.9 0zm-5-.8s0 .4.9.5c.7 0 3 .3 4.5-.1 0 0 .4-.1.2-1 0 0 0-.7-1.2-.4-1.3.2-3 0-3.7-.1q-1-.4-.8 1zm-.2-2.9s-.1 1.3 1.1 1.4c1.3.2 2.9.2 3.5 0 .5 0 1.4-.2 1.5-1 0-.7.2-1.2-1.3-.8s-3.4 0-3.6 0c-.1 0-1.2-.3-1.2.4m.5-2.5s-.3.6-.2 1.2q0 .7 2.5.7c2.5 0 3-.2 3.2-.7.1-.7.5-1.3-.7-1-1.3.1-3 .2-3.6 0q-1-.5-1.2-.2"/>
<path fill="#5a3719" d="M582.1 286s0 1 .9 2.2l-45.2-1.3s.6-.4.8-2.2z"/>
<path fill="#ffc221" d="M522.7 277.8s-.4 1.1.4 1.4q1.2.4 4 .1s1 0 1.3-1 .3-.4-2.3-.8c0 0-.8-.3 1.5-.3 0 0 1.4 0 1.5-.2.3-.2 2-1.7-.3-1.5-2.3 0-1.1-.5 0-.5s1.6.3 2 0 0-.2-.7-.8-.1-.5.3-.1q.6.6 1.3 0c.7-.6-.4-1.2 0-1 .3 0 .6.8 2 0 1.6-.7 3.5-.3 4 0 .6.5 2.2 1 3.1 0 1-.8-1.1-1.7-.3-1.8 1-.1 1.6.2 1.9-.5.4-.8-1.4-1.4.3-1.8s.2-5-.3-5.5c0 0-1.9 1.1-3.8 4.3-2.1 3.3-3.3 5.2-6 4.2-3.9-1.5-6 .6-6.5 1-1 .6 2 .8.2.9-1.7 0-1.7.2-1.8.4q-.2.4.3.6c.3 0 .9.6-.1.6s-1.8-.3-1.5.9q-.1 0 .6.3c.6 0 .8.8-.3.8q-1 0-.8.3m4.1 11.3s-.7.5.3.6c1.2 0 1.7.3 2.1-.3s1.8-.4.8-1.2-1.6-.3-3.2 1z"/>
<path fill="#ffc221" d="M531.5 275.5s3.8-3.5 6.9-1.2c3.2 2.5 3.4 2.8 3.5 2.9 0 0 .4.3-.4 1-.9.8 0 .8.9.3s1 0 1.4.5c.5.5 1.1.8-.3.8h-4.6s-2.1.2-1-.7c1-.9.8-1.9.3-2-.6 0 0 .6-.3 1q-.6.6-1.9.7-2 .3-.2 1c1.1.4-.2.7-.8.7s-3.5.2-.5.6-.3.3 2 1.5c2.4 1.4.6 4.3-.3 4.6 0 0-1 .5.2.4 1.3-.2 2-.3 1 .4-.8.6-2.6 2.9-5 1.2 0 0-1.2-.6.8-.7s-1.6-.5-2.3-1c-.5-.3-3-2.7-1.5-2.5 1.6.4 1-.5.1-.8s-1-1.6 0-1.4 2 .9 3 .8q1.2 0-1.2-.8c-1.7-.6-2.4-.7-2-2 .4-1.5 2.3.5 1.8-.6-.4-1-2-.5-1.2-1.9s1-.8 1.5-.6c.3.1 1 0-.1-.8-.8-.5 0-1.3.2-1.4"/>
<path d="M534.2 276.5s0-.5.8-.4c.6 0 .4-.2.6-.4.2 0 1.9.5.3 1-.6.3-1.6.2-1.6-.2z"/>
<path fill="#ffc221" d="M537.9 280.5s-1.3.6-.2 2c1 1 1 1.5 1 2.2-.1.8 43.4 1.3 43.4 1.3s0-2.9 1.8-4.5z"/>
<path fill="#5a3719" d="M582.8 285.2s.2-2.4 1.6-3.1c.7-.5 1.6-.3 2 1.6.6 2.7-1.7 5.1-2.7 4s-.8-2.5-.8-2.5z"/>
<path fill="#7b3c20" d="M532.9 295.4s2.9-2.5 3.4-3.6c0 0 7.8 5.6 7.3.4l.2-2.6s2.9.3 3.3-2l-7.3-.3s-.8-.1-2 1.1c-1 1.2-3.4 2.5-5.5 1.4 0 0-1-.8-1.9 0-1 .5-1 .7-.2 1.5s2.4 2.9 2.7 4zm16.8-15.4-4.3-.2s-1.5-2.2-4.6-4.6c0 0-.9-.4.8-1.8q2.5-2.3 2.3-3.5c0-.6 0-1.7.6-1 .6.8 5 4.9 5.8 3.7q.8-1.6.7-2.1c.2-.4.3-1.5 1-.3s1 .8 1.1 3.8c0 0 0 3 .5 4 0 0-5.6-1.7-3.8 2zm-18.6-9.2s3.3 2 5-.6c1.5-2.5 2.6-2.8 1.4-5.3-1.2-2.3 0-3.4.9-4.4s1.8-.8 1.8-4.6c.2-3.9 2.8-5 4-6.3s4.2-3-.4-3.7c-4.4-.8-13.4-3-15.7-6.5s-3.3-1.5-3.3-1.3-.8 2.7 1.5 7.3 4.2 7.6 6.5 9 4.2 2.3 3 5.4-3 8.6-4.7 11"/>
<path fill="#5a3719" d="M543.2 261s.6 8 6.3 10.8c0 0 1.3-3 .8-6.1 0 0 1.9.1 2.4 1 0 0 0-2.3-2.6-3.2-2.7-.7-1.4-6-.4-6.5s.6-1.7 0-2.6-.8-2.3 1.4-1.7 2-.6.5-1.7c-1.3-1.1-1.3-2.5.7-2.5s5-1.9 3.2-2.4c-2-.6-2.5-1.3 0-2 2.7-.8 4-1.7 2-2-2-.2-3.3-.9-1.4-1.2s-.3-2.3-2.5-2.3c-2.3-.2-7 .7-3.3-2.3s-5.4-.8-1.6-2.8-1.3-1.1-2-1.1-.7 0-.4-1-.5-1.6-1.7-.9c-1 .6-1 .6-1-.7 0-1.5-1.3-.4-2.1 0-.9.3-3 1.9-3.9 1-.7-.9-1.2-1.7-3.8-.2s-2 .2-2-.5 1-3.4-2.4-.5-.7-3-3.5-1-3 2.4-3.5 1.5c-.5-1-1-1.7-4 .3-3.1 1.9-.8-1.3-.5-2 .5-.6 1.8-5-.9-1.6 0 0-1.3 2.4-4.2-1.9 0 0-3 4.3-3.9 2.4-.8-2-1.5-2-2.6-.8-1 1.2-.2-.1-.7-1.2s-.7-2.9-5.8.8 1.8 1-2.1 2.7-13.5 7-4.8 5.9c8.7-1.3-4.2 3.3-1.2 4.2 3 .8 2 3.5 13.4.3 11.2-3 9.4-.4 15.2-3 5.8-2.4-1.4.9 6.4.8 7.7-.2 1.3 0 2.8 1.6s8 5.3 14.1 6c6.1.6 7.7-1.7 5.9 1-1.8 2.6-2.4 3.6-3.4 4.6-1 .8-4 3-4 6.6 0 3.7-4.8 4.3-3 8.3l4.1-4z"/>
<path fill="#5a3719" d="M553.3 269.9s-1.4-1-1.4-2.8c0 0 1 .2 1.4.8 0 0 3.5-4-.8-5.4-4.1-1.4-2-5.3-.6-5.3s1.7-.3.4-2c-1.2-1.5-1-1.6 1.3-2s2.1-1 1-1.5a8 8 0 0 1-1.9-1.6s6.8-2.9 4.6-4.3 0-1 2-2.3c2-1.4 2.2-1.7 2.5-2.3 0 0-2 .3-3.4 0 0 0 1.7-.9 0-2.3s-2.3-2.6-5-2-1.8-.2-.8-1.3.6-1.7-1.3-2c0 0 .2-1.2 1.7-2.5 0 0-3.7.2-5-.4 0 0 1.6-1 1.6-2.3 0 0-2 .7-4.5.5 0 0 1.5-1.3 1.5-2.4 0 0-4.4 1-6.4 2.5q-.2.2-.8-.6c-.4-.4-.6-1-5.5.6 0 0 .5-2.2 1.7-3s1-2.6-6.5 2.1c0 0-1-.6-1.9-2.9 0 0-1.7 2.3-2.9 3.1 0 0-1 .5-1-1 .2-1.5-.7-.5-1.4 0-.8.4-1.3 1.5-1-1.5s-1-3.6-1-3.6-2.3 3.3-3.7 3.7c0 0-2.5-2.4-3.4-4s-.8-2.2-1.7.6c-.9 2.7-2 3-2 3s-1.5-1.3-1.6-2c0 0-.3.7-.8 1 0 0-1.3-1.5-1.2-3.7 0 0-8.2 4.5-9.2 7.2 0 0-7.7-.5-10.8.1 0 0 .7-2.4 2.7-3.7 0 0-2-.2-2-2.3 0 0 1.6.2 2.6 0s-1.4-3.1 1.1-3.2 4.2 1.2 3-2.2c-1-3.3-.6-3.3-.6-3.3s4.4 2.6 5.1 1.9c.8-.6-.5-2 3.4-1.4s2.8-1.5 4.4-1.7c1.5 0 2.3 1 1.3-6.1s4.8 3.5.9-7.2c0 0-1-3.3-3.3-4.7 0 0-.6 2.3-3.2.3s-7.8-2.8-5.6-4.4 3.2-3.9 2.6-5.2c0 0-2.6 2.6-7 .7-3.6-1.5-4.4 1.3-8 .5 0 0 0-1 3.1-3.4 3-2.3-1.8.8-3.6 1.3s-2.4 0 1.5-3.1c4-3 12-8.5 11-13 0 0 1.8 2.3 6.7.6s8.6-2.3 10-5a23 23 0 0 1 6.4-5.6c1.1-.5 2.4-1 .9 1.5-1.6 2.4-4 6.6-10.8 9.4s-9.5 4.8-10.7 6.3-7.4 4.8-3.3 4.3c4-.7 10.9 0 7.6-1-3.2-.9-6.9.6-3.9-2.1s3.5-3.6 7.9-5.4 9.2-6.1 8.7-1.6-8.6 9.1-10.6 10.6c-2 1.4-1.2 1.2-1.2 1.8s-.4 1.8-1.2 2.3c-.8.6-.5 1.2-.3 2.4.2 1.3-.2 1.8.4 2s1.2.2 1.4 1.1.6 1 1.8 1q1.8-.3 2 .6c0 .7 1.2 1.7 1.3-.4.1-2.2 1-2.5-1.2-1.5s-2.6.6-2.6-.4-.2-.8-1-.9c-1 0-1.3-1.3.3-2.2 1.6-.8 1.6 0 3.6-1.6s2-2 2.3-3c.3-.7-2.9 2.4-4.4 3-1.5.8-1-.5-.8-2.1.3-1.7 4-4 5.7-4s5.6 1 4 3.3c-1.7 2.3-6.4 5.2-4.5 5.4 2.1.2 2.4-.6 3.6.4s0 3.2-.4 4.4a8 8 0 0 1-2.2 2.7s-2.2-3.8-2.1-.8c0 3.1-.5 4.2 0 4.3s2.8 1.7 3.6 1.7-4.1 2.3-2 2.5c2 .1 5.3-1 6.4-3 0 0-4.2-1-5.9-2.6 0 0 4.9-1.2 3.5-5.8 0 0 4.9 1.3 2.7 3.5-2 2.1-3.3 1.8-1.5 2.4q2.8 1 2.7 1.2c-.1.2 1.3.6.5 1.6-.7 1-.7 2.6 0 2.5.5 0 2.6-1 .9-2s1.9-.8.4-1.7c-1.6-1-2-1.1-2.4-1.6-.5-.3 19.7-12.2 9.5-7.8 0 0 2.1-4.6 5.1-4.6s3.2 2.3 1.5 4.2c-1.7 1.7-2.8 4.6-6.7 5.2 0 0 5.6 2.7-1 7.2 0 0-1.5.7-1 1.2s4.5-1.7 5-3a6 6 0 0 1 3-3 38 38 0 0 0 11.2-9.6c2.3-3.9 2.8-4 7.2-7.5s3.6-2.8 4.2-3.6c.5-.9.7-2.3 2.7-3.4s9.8-5.4 12.3-7.2c2.4-1.8 7.4-5 9.6-7.8s8-6.2 9.4-5.6-.2 2.8-3.5 5.4c-3.5 2.5-12 9.3-13.3 10.4a45 45 0 0 1-11.2 6.6c-2.7.3-2.4 1.3-4 3s-5.3 5.4-6.5 6.4-4.3 3-4.4 4.5.5 1.6-1.9 3.8a50 50 0 0 1-11.9 8.1s4.5 1.6 1.8 4.6c-2.6 3-2.5 2.6-2.6 2.8 0 0 6.7-1 2 4.3 0 0-1 1.5 1.1 0 2.3-1.9 1.4-4.1 1-4.5 0 0 3.7-2.3 7.9-2.3q6 .1.2-1.4s2.7-3.2 5-1.6c2.2 1.5 1.4 2.5-.9 3.8-2.4 1.2-5.8 1.7-8.5 3.2 0 0 5 1 7.6-1.1 2.6-2 2.8-1 3-.6.5.4.8 1-.4 2.6s-1.3 1.8-1.2 2.2c0 .4-.1 1.5-2.5 2-2.3.3-3.5 1.3-2.6 2.4.7 1.2.7 4-1.2 3.7-2-.3-1.6-1.9-2.3-2.5-.8-.6-1.9-1.5-5.4.2-3.6 1.9-3.8-.3-3.7-1.5 0 0-2.3 2-4.2.2s-.2-2.6 1-3.5c1-1 5.5-3 2.8-2.5-2.7.3-6.7.4-7.6-1.6-1-2.1 2-1.9 2.4-1.7.5.2 2.3 1.7 2.5-.3 0-2 3-2.3 2-2.6s-2.5.9-2.9 1.3c0 0-2-2.9-5.4-2-3.4 1 1 .7 2 .8.8.2.3 1.8-2.7 4.6s-1.7 1.8.5 1.8 7.9 0 4.6 2.6c-3.2 2.7-4.5 4-6 3.6-1.8-.5 0-1.6.8-2.1s1.2-1.2-.4-.6-2.1.7-3.4-1.5-.8-1.6-.2-3.1 1.8-3 .4-2.5c-1.6.6-1.4.7-1.3-1s-1.7-2.1-1.7-2.1.8 1.7.1 2.8q-1 1.4.4 1.6c1 .4 2 1.3.6 2.3s-1.2.7-.4 1.3c1 .7 2.3 1.3.9 2.7s-.3 1 .4 1c.8 0 2.3.6 2.3 2 0 1.2 0 1.5 2.3.3 2.3-1.3 6.7-1.1 6.7.6s-.6 2.3 1.8.8 3.5 1.4 5.2 0c1.6-1.5 2.6-2.8 4.6-.4s1.3 3-1 4.8 1.1.4 2.9-.5 6.7-1.5 9.5-.2c2.9 1.2 3.7 1 5.8 0 2.1-.8 3.2-1 6.3 1.1 3.3 2.2 5.7 2.6 7.4 2.5 0 0-3.5 1.4-7.5 1.6q-5.8.6-6.7 1.6s2.3 1.5 2.8 3.2c0 0 2.6-.3 3.8.2 0 0-.6 1.9 1 2.9s2.7 1.4 1.5 2.7 1.9.8.1 2.7-2.1 3-2.2 4.5c0 1.6.4 1.8-1.1 2-1.6.1.2 1.9-.5 4-.7 2-5 1.7-4.8 7.2 0 0 1.2-2.7 3.8-5 2.5-2.4 2.6-2.6 2.5-4 0-1.4-.1-1.1 1.2-2.2 1.4-1-.6-2 .8-3.6 1.3-1.5.2-1.2 1.8-2.7 1.5-1.6-1.5-1.7.2-3.3 1.5-1.6-4-3.5-2.3-4.5 1.5-1 4.3-2.4-5-2.3 0 0 2.3-3.6 10-2.9 0 0-2 1.6-2.2 3l1.6.5s-.4 1.1-1.9 2.3c0 0 4.2 2.3 4.9 3.8 0 0-2.6.8-3.3 1.8 0 0 1.2 1.3 1.6 3 0 0-2.9-.4-3.2 1.7s-1.3.7-1.3 2 .1 1.7-.9 2c-1 0-.1 1.1 0 1.8.2.7.6 2.3.4 2.8 0 0-1.5 0-2.1.2 0 0 .4 3-1.3 3.5s1 1-.9 1.3-1.5.5-3.7 4.5c0 0 1.9-1 3.8-2.4 2-1.3-.2-1 3-4 3.3-3.3 2.7-3.5 2.4-5.1-.2-1.6-.3-3 .9-4.5s1.5-3.2 5.7-3c0 0-1.2-2.8-2.7-3.5 0 0 2-1.3 4-1.5 0 0-1.8-2.3-5.6-4.4 0 0 3-2.6 3.9-3.9 0 0-1.5.3-2.7 0 0 0 .6-1.3 3.4-3.1 0 0 1.5 1.4 1.4 2.9 0 0 4.8-2.7 7.5-2.4 0 0 1.4 3.4-5.3 10 0 0 4.2.3 6 0 0 0-1 3.2-6 5-5 2 1 4.2-4.1 3.8s-3.5 1.3-3.4 3.9.3 5.3.2 6c0 0-4-1.3-4 2.6.1 4-2 4.8-2.5 5.1 0 0-1.2-1-3-1.7 0 0-2.5 4.9-6.5 7.7"/>
<path fill="#7b3c20" d="M547.4 220.5s1.4-.2 3.8 1.2c2.3 1.5 4.6-1.5 2-2.3s0-1.8 2.4.2c2.4 1.9 3.3.9 4.2.3.8-.7 1.9-1.1.3-2.2s1-.6 2.3.3c1.2.7.7 1.5.6 1.7-.2.2-.3 2.9 2 .5 2.4-2.5 3.7-4.8 3.6-6 0 0 1.3.8 1.5 2.3s2-.8 2.6-1.6 1.6-3 1.5-4.4c0 0 1.6 2.5 4 0s1.4-1 4.2-1.7a18 18 0 0 0 8.5-5.2c2-2.5 2.1-.8 4.6-1.4s7.7-4.2 8.2-6.1.3-3.1-.4-2.4c-.7.6-.4 0-1.5-.6s-2.7.9-2.7.9 1.6 1.2.3 1.7c-1.2.6-2.3 2.3-4.6 1.6s-4.8 2.2-4.8 2.2 2 1.6-.7 2.7c-2.7 1-2.3 1.4-3.9.2 0 0-2.9 3.7-4.6 4.5 0 0-.7 0-1.2-.8 0 0-2 2.1-2.8 2.5 0 0-1.3-1-2.3-1.5 0 0-2.3 2.9-4.2 3.7 0 0-.6-1-1.8-1.7 0 0-.6 3.6-4.6 5.8 0 0 .2-1-1.8-2.3 0 0-5 4.3-6.9 4.7s-.2-.9 0-1.5c.4-.5 1.6-2.3-.8-3s-2 .5-2.5.7-.6-.4-2.2-.2-1.3.9-2 1.2c-.8.2-3.6-.5-3.4 1.4.1 1.8 1.5 3.1-1 4.2-2.5 1 1 .8 4.1.4"/>
<path fill="#5a3719" d="M557.5 215.3s.6-2.5-1.5-3.5c0 0 13.2-2.1 3.2-7.2 0 0 11.9-2.3 9-6.1-2.7-3.8-5.4-3-5.8-3s2.5-2.1 3.3-1.8 10.2 3.9 7.8.7c-2.4-3-2.2-2.9-2.6-3.8 0 0 3.1 0 7.9 4.6 0 0 1-1 .9-2.9 0 0 3.3 1 4.4 2 0 0 .6-1.2.3-1.8 0 0 3 1.5 4 3.2 0 0 1.3-1.2 1.5-2.6 0 0 3 1.3 3.7 2.2 0 0 1-1.3.6-3 0 0 4.9 1.3 5.5-1.6 0 0 4.9 1 1.7 2.9-4 2.5-.4-.6-4.6 2.3-3.2 2.3-5 4.9-6.6 4.4-1.1-.5-2.5 2.9-4 1.3-1.5-1.7-1.5-1-2.7.7a25 25 0 0 1-2.8 3.5s-.8-.5-1.6-1.2c0 0-.8 1.6-2 2.8 0 0-1-1.3-2.6-2 0 0-2.3 2.7-3.8 3.7 0 0-1.4-1.4-2.9-1.9 0 0-.2 3.8-3.1 5.7 0 0-.6-1.2-2.6-2 0 0-1.4 2.3-4.6 4.3z"/>
<path fill="#5a3719" d="M550.6 209.5s-1.6 1.2-.6 2.5 1.1-.2 2.4-.3c1.3-.2 17.5-3 2.8-7.3 0 0 .7-.6 3.1-.8 2.6-.3 11.8-2.7 7.5-6s-7.9 1.1-4.3-2.8c3-3.1.6-4.6.6-4.6s-8.5 5.6-10.4 6.7c-1.8 1-4.6 3-1.4 4 3.3 1 5.4-3.4 5.7-2.4s-6.4 4.8-5.4 6.4c.9 1.8.7 3.3 2.4 2.9s6 .8 2.5.7c-3.7-.1-5 1-5 1z"/>
<path d="M556.4 201.3s-1.5 1.1.5.6 5.9-1.4 5.2-2.4-3.4.2-5.7 1.8"/>
<path fill="#7b3c20" d="M582.4 184.5s7.5-.2 10.5 1.9q4.3 3.1 5.5 3.8s-.1 2.8-5 .7c0 0 .4 1.4-.2 2.8 0 0-1.7-1.2-3.8-1.7 0 0-.4 1-1 1.7 0 0-2.1-2.2-4.6-2.9 0 0-.4 1.1-.8 1.6 0 0-2.6-1.6-4.6-1.6 0 0 .4 1.7 0 2.3 0 0-5.4-4.3-10.3-3.8 0 0 2.3 3.5 3.8 5.1 0 0-9.8-.7-8-6 1.5-5.3-.2-4 6.2-4z"/>
<path fill="#5a3719" d="M536.3 199.1s-1.1 1 0 1.7c1.1.8 5-2 5.5-2.4s2-.4 0 1.1-3.8 3-5 4.6c0 0 6.4-1.8 10.6-5.4 4.3-3.7-.1-1.3 7-4.8 7.3-3.5 11.1-9.1 7.2-8.5s-7.3 5-10.4 6.7c-3 1.7-4.7 2-4.2 1s2.7-.6 6.9-4c4.1-3.3 3.2-3 3.2-4.2s-1.5-4 4.7-7.4c6.2-3.3 25.6-14.5 27.3-18.5 0 0-5.7.6-13.2 6.1a70 70 0 0 1-13.4 8.8c-2 .8-1.8.2-3.1 1.9a172 172 0 0 1-10.1 9.8c-1.3 1-1.8 1.6-1.9 3.8 0 1-8.4 7.3-11 9.7z"/>
<path fill="#5a3719" d="M562 184.3s-1.5.6-3 0c-1.3-.7-.8-3.6 2.5-5.5a50 50 0 0 1 12.6-4.8s-.5 3.8-10.1 7.1c0 0 .6 2-2 3.2"/>
<path fill="#aa5323" d="M565.4 181.8s.3 1 0 1.8c0 0 17.9 1.7 27.1-9.2 0 0-12.7 1.2-17.7 4.3 0 0 3.2-4 12.7-7.3s13.4-7.4 14.2-9.7c0 0-12 4.3-17.7 4.3 0 0-1.2 0-2.3.5-1.1.7-8.8 6.2-10.8 7.2 0 0 4.3-.4 5.9-1.8 0 0-3 8-11.4 10z"/>
<path fill="#5a3719" d="M531 192s-2.3 1.7-1.3 2.4c1 .9 2.6 1 6-1.9 3.6-3 12.1-10.2 6.8-10.5 0 0-7-.4-6.7 3.8s-4.4 6-4.8 6.2m-15.9-2.5s4.6 2.7 2.8 4.9c0 0 14-11.8 10-14.4-3.8-2.6-6.9 2.3-6 2.7 1 .6 3-.4 2.3.6a77 77 0 0 1-9 6.2zm-3.6-3.8s3 1 3.2 2.3c.1 1.2 9.2-6.4 6.8-9.7-1.1-1.5-6-2-6.4.8-.2 3 4.6-.3 3 1.8-2.2 2.7-5.9 4.4-6.6 4.8m32.6-6.4s-1.9 1.4-.1 2.3c1.8.7 2.8-.6 3.7-1.3s5.3-4 6.2-6 2.5-2.7 4.1-3.7 12.6-6.6 19.5-12.7c6.8-6.1 4-4.5 11-8.4s11.7-7.5 13.2-11.8c0 0-3.3 1-6.2 3l-10.7 6.5c-1.3.5-3 .6-4 1.6s-1 2.3-4.3 5c-3.5 2.9-21 15.3-23.2 17z"/>
<path fill="#aa5323" d="M530 183.4s2-1 5.6-.8 17.8-13.6 22-16.3a342 342 0 0 0 18.7-13.8c1.8-1.8 2-3.6 3.6-4.6 1.5-1 3-.9 6.4-2.9s20.3-12 19.3-17.8c0 0-25 15-30.7 19.8a375 375 0 0 1-24.7 17.7c-2.8 1.9-5 5-9.9 8.8-4.7 3.9-9.5 7.2-10.3 10z"/>
<path fill="#aa5323" d="M524.8 178s4.6-.4 5.2 1.9c0 0 10-6.8 12.2-9.6s-.8-1.2 4.9-4.9a594 594 0 0 0 27-19.1c2.7-2.3 7.8-5.5 11.9-8.2 4-2.8 19.9-10.6 18.1-17l-14.2 9.5c-2.7 1.8-3.9.8-6.5 2.9s-8.5 6.3-9.5 7.6a161 161 0 0 1-14.4 11c-4.4 3-14.1 8.6-18.9 12.8l-15.8 13.2z"/>
<path fill="#aa5323" d="M510.4 176.8s2.3 0 3.1.9c0 0 4.3-3.9 8.9 0 0 0 16.8-11.5 18.5-14.4 1.7-2.8 4.5-2.9 11-7.6 6.6-4.8 10.7-6.9 15-10.2 4.4-3.4 8.1-7.3 11.2-9.3s11-7.2 9.8-11.7c0 0-6.5 3.6-10.6 8.2-4.2 4.6-3.8.7-8.1 4.5a83 83 0 0 1-16.3 11.7c-5.5 2.7-2.2 2.4-6.2 5-3.9 2.5-3.6 2-5 2.5a10 10 0 0 0-5.1 3c-1.6 1.7-5.4 4-9.6 6.5a107 107 0 0 0-16.6 10.9"/>
<path fill="#aa5323" d="M515.5 168s-1-1.9.7-3.3 4.6-4.8 5-7 .1-1.9 4.8-3.8a188 188 0 0 0 38.2-21.6c1.8-1.5 6.4-4.6 8.3-6.2 0 0 .8 2.5-1.2 4.2a222 222 0 0 1-21.5 14.9 77 77 0 0 0-9.6 5.5c-1.9 1.6-1.6 2-10.2 6.3-8.5 4.1-9 4.6-8.7 4.9s4.2-1.3 6-2.4 8.8-4.3 11-6a69 69 0 0 1 7-5 296 296 0 0 0 18-11.2c3.5-2.7 4.5-3.5 5.3-3s2 .4.4 2-6.7 6-8.7 7.3-8.1 5-9.8 5.8c-1.7 1-2.4 2.5-3.4 3.2s-3.7 2.7-7 3.5-4 3.3-6.3 4.8c-2.3 1.4-18 10-18.5 10.3 0 0 .9-1 .2-3.2"/>
<path fill="#aa5323" d="M570.3 132.4s-.9.8-.4 1.2c.6.6 2.8 2.2 5.6-.6a107 107 0 0 1 12.5-10c2.3-1.5 3.6-2.8 3.5-4.7 0 0-11.4 6-21.2 14.1m15.6-1s1.7-2.9 6-5.7c4.3-2.7 10.8-6.7 11.5-7.6 0 0 1.6 1.7-1.7 3.8l-10.8 7c-.7.7-2 1.6-5 2.5"/>
<path fill="#7b3c20" d="M499 163s-4.8 2.6-3.1 4.2c1.7 1.5 4.2 1 5.4.6l3.2-1c.4 0 4.5-1.3 5.6-3.2s3.8-4.2 6-5.8 3-3.2 2.7-4.3zm-28.4 22s3.4-2 8-.7c0 0-.2-1.1-1-1.7 0 0 5.7-1.5 6.9-4s1.5-2 2.5-2.6c1.2-.8 8.7-6.8 7.8-8.1s-1-3.1-1.7-3.7c0 0-1.5 2.1-9 5.7-7.2 3.6-15.4 6.2-21.4 14.2s-5.3 12.6 2 14.7c0 0 5-3.2 17.6-2.1 12.4 1.1 16.6 5.8 17.4 6.6.8.9 3.3 4 .9 9.1 0 0 2.5 1.1 2.6-1.3.3-2.3.4-1.8 1-1.5s1.3.5 1-1.4a18 18 0 0 0-2.3-7.2c-1.1-1.5.2-.8.9-.6s3.3 2.5 1.8-1.5c-1.5-3.8-2-2-2-1.8 0 .3-.4 1.2-3.8-1.4a27 27 0 0 0-8.8-4.4c-2.3-.6-.7-.6.7-1 1.4-.6 3.1-.8 3.8-2.4 0 0-1.4.3-3.8-.6a13 13 0 0 0-11.5 2s1.2-4.5-2.5-4.3c-3.6.3-6.2.2-10 3.2 0 0-.3-4.6 3.4-7s3.2-1 5.2-1.6 2.3-2.7 1.4-3.4c0 0 4.8.9 12.8-5.8 0 0-4.3 5.7-9.5 6.8 0 0-.9 3-5.7 3.7s-4.6 3.4-4.6 4.1z"/>
<path fill="#5a3719" d="M457 212.7s2.2-14.1 15.5-15.1c11.4-1 15.1.5 17.4 1.3s8 2.4 5.8 4.2-3.5 1.5-3.5 1.5 2.5-2.9.2-3.3c-2.3-.5-2.4.9-2.7 2-.4 1.3-.5 2.6-1.6 3.6 0 0-1.2-1.4-2.9-.2s-.2 1.2.5 1 1.5-.5 1.3.5-1 2.8-3.8 4.2-2.6 1.3-5.8 1.9c-3.3.5-6.3 1.8-10.5 5.3-4.2 3.6-8.7 2.4-9.6-1.5-.8-3.4-.4-5.4-.4-5.4z"/>
<path d="M472 212.2s1.2-2.7-1-4c0 0-6.9 1.2-9-.9 0 0 7.5-.4 12.2-2.3 4.6-1.7 3.3-3 1.7-3.4s-4.6.5-4.9 2c0 0-1-1.6.2-2.6a5 5 0 0 1 4.7-.8c1.7.4 3.1 1.2 8.6-1.6 0 0 3.1.7 3.3 2.8 0 2.2-.3 3-.6 3.3q-.3.8-1.3 1c-.6-.2-1.5-.3-2.3 1.2a9 9 0 0 1-2.6 3.7s1.5-4.6-2.5-5.7c0 0-3.3 2-5.8 2.1 0 0 3.2 3-.8 5.2z"/>
<path fill="#5a3719" d="M479.3 203.8s-1.6-1.6.4-1.9c2-.1 4.6 1.4 4.2 2.7-.6 1.2-3 1.1-4.6-.8"/>
<path fill="#fff" d="M592.6 181.6s-3.7 1-.2 3.3c3.4 2.3 5 4.2 7.5 4.8 2.5.7 5 1.5 5 4s-.5 3.5-1.9 5.2.8 2.4 2.6 1.5q2.7-1.3 4.5-2.2c1.1-.7 3-.6 1.4.3-1.9 1-3.7 1.5-1.4 1.5 2.3.1 16.2.4 19.1-.6s6.8-1.2 7-5c0 0 .2-1.6 1.3-2.4 1-.7 1.8-2.3.2-1.2q-2.4 1.8-3 1.3c-.3-.3-.5-.6.7-1.1s1.8 0 2.9-1.5c1-1.6 1-1.4.4-2s-1.8-1-1.2-1.8 1.2-3-1.4-1.7-7.6 4.8-10 5.3c-2.2.5-4 1.2-7.1 1.8s-5 1.4-8.5 3.3c-3.3 1.8-3-1.1-2.5-1.5 0 0 1.3 2.3 4.7-.7 3.4-2.9 2.3-.1 10.6-2.9s6.3-3.1 9.5-4.8 6.4-1.8 4-4c-2.2-2.4-2.4-2.5-5.3 0a35 35 0 0 1-16 6.4s18.8-8 16.9-9.2a21 21 0 0 0-5.3-2.5c-1.3-.3-1.7-.6-4.7.8s-3.5 1.6-4.3 1.7q-1.6-.1-7 2.4c-3.6 1.9-5.5 2.6-8 4 0 0 1.7-3.3 9.1-5.5s11.1-4.2 10.4-4.6-2.7-.8-4-.5c-1.5.3-1-.1-5.6 1.7s-2.6 1.4-6.2 2.2c-3.7.7-5.1 1.5-6.9 2.3 0 0 .8-1 3.1-1.9 1.3-.4-1.3-.9 2.2-1h1a32 32 0 0 0 8.6-3.3c-.7-.1-5.1-.6-9.5 1.6-4.5 2-2.5 1.3-4 1.5-1.6.4-5 2.4-6 3.4-1.1.9-2.7 1.5-2.7 1.5z"/>
<path fill="#5a3719" d="M482.7 201.8s1.8.5 2.2 1.8c.5 1.2 1.6-.6 1.6-1.1-.1-.6-1.2-3-3-1.9-2 1.1-1 1.1-.8 1.2"/>
<path fill="#7b3c20" d="M477.9 226s3.7-1.8 6.9-1.5c0 0-1.3-4.4.9-3.7 2.1.8 1.5.4 2 .4 0 0 .1-2.9-.5-4 0 0 2.3.5 4.6.5 0 0-2.2-4.1.2-7a7 7 0 0 0 4.2 3.4v-2.3s1.7-.3 3 .4c1.4.8 2.5-7.6-1.5-9.3 0 0-1 1.5-4.7 2.3s-3.7 1.5-5.2 4.3-3 2.9-6.2 5c-3 2-5 6-5 6.4 0 0 1.5 2 1.3 5.1"/>
<path fill="#999" d="M603.1 177.8c1.3-.2-1.4-.9 2-1h1a32 32 0 0 0 8.7-3.3c-.7-.1-5.1-.6-9.5 1.5s-2.5 1.4-4 1.7c-1.6.3-5 2.3-6 3.3-1.1.9-2.7 1.5-2.7 1.5s-3.7 1-.2 3.4c3.4 2.3 5 4.2 7.5 4.8 2.5.7 5 1.5 5 4a7 7 0 0 1-1.9 5.2c-1.3 1.7.8 2.4 2.6 1.5q2.7-1.3 4.5-2.2c1.1-.7 3-.6 1.3.3-1.8 1-3.6 1.5-1.3 1.5 2.3.1 16.2.4 19.1-.6s6.8-1.2 7-5c0 0 .2-1.6 1.3-2.4 1-.7 1.8-2.3.2-1.2-1.5 1.1-2.8 1.7-3 1.3s-.5-.6.7-1.1 1.8 0 2.9-1.5c1-1.6 1-1.4.4-2l-1-.9s-.9-.7-1.9-.1a28 28 0 0 1-7 2.7q-2.2.2-6.5 2.4c-4.3 2.2-8.2 4.6-9 1.7l-2.8 1c-3.4 1.8-3-.7-2.5-1.4 0 0-1.8 2-1.7.2 0-1.8 1.2-1.5 3.3-2.1s5-2 3.8-3-2.7 1-4.2 1.8c-1.4.7-4.3 1.2-4.8-.9s-.4-3.6-4.3-3.8c-4-.2-3.9-2.7-2.8-3.8s2-2.8 5.7-3.5z"/>
<path d="M615.6 196.9s6.1-2.8 11.7-4.1c5.7-1.3 1.2.2.3.4-1 .3-9.7 3.2-11.8 4.2-2 1-1.7.2-.2-.4zm1.4 1.3s6.9-2.3 8.2-1.4c1.3 1 .2.6-1.3.8-1.6.1-5.7.8-6.8.8s-.1-.2-.1-.2m11-2.5s1.4-.2 1.5.4c.1.4-.6.5-1.3.4s-1.3-.5-.1-.8z"/>
<path fill="#fff" d="M446 255.9s-.3-6.2 2.8-9.2 17.8-18.5 20.1-22.8c0 0 2 1.3 2 3.8 0 0 2.5-4.3 4.5-6 0 0 1.7 1.8 1.5 5.4 0 0 3.5-1.9 9-1.9 0 0-2 2.4-2.1 3.9 0 0 7.6-1 11.7-.3 0 0-10.6 6-7.7 6.5 3.1.5 6.2 0 6.2 0s-3.4 3.3-8.8 4c0 0 6.9 0 8.2 1.5 0 0-6.6 1-12 5 0 0-.4-.2-.4-1.7 0 0-.3 1.4-1.8 2.7-1.5 1.2-5.1 4-6.5 5.3s-3.8 4-6.6 4c0 0 .6-2.1-1.4-2.8a6 6 0 0 0-6 1.5s-7 .1-9.4.5c0 0 1.6-2.6 3-2.6 1.6 0 7.6 1 8.1-3.2.6-4-3.8-3-2.2-5.4 1.7-2.4 1.3-2.2 1.4-2.6 0 0-1.4.8-2.2 3a11 11 0 0 1-4.2 6 15 15 0 0 0-4.8 5s-1.3 0-2.4.4"/>
<path fill="#fff" d="M452.8 252.2s.3-.8 2.3-1.2c2.2-.3 2.3-1.3 2-1.8s-1.5-.4.5-2.8c0 0 .8.3 1.2.8.6.6 2.9 5.5-6 5"/>
<path fill="#999" d="M447.9 247.9c0 4 5.3 2.5 5.3 2.5a21 21 0 0 0-3.8 3.6c.4-2-3-2.5-3-2.5a12 12 0 0 1 1.5-3.6m19.3-21.5 1.7-2.5s2 1.3 2 3.8c0 0 2.5-4.3 4.5-6 0 0 1.7 1.8 1.5 5.4 0 0 3.5-1.9 9-1.9 0 0-2 2.4-2.1 3.9 0 0 7.6-1 11.7-.3 0 0-10.6 6-7.7 6.5 3.1.5 6.2 0 6.2 0s-3.4 3.3-8.8 4c0 0 6.9 0 8.2 1.5 0 0-2 .3-4.6 1.2 0 0-1.9-1.8-7.6-1.5 0 0 4.4-2.6 8-3.4 0 0-1.6-2-4-.1 0 0-4.9-3.3-.8-6.2 0 0-2.8-.5-4.7.8 0 0 0-2.3 2.1-3.3 0 0-5.4-1-6.7 3 0 0-1-1.5-.5-3.4 0 0-3.3 1.9-4.8 4 0 0-.5-4-2.6-5.5M456.8 252q-1.4.3-4 .2-.2-.1 1.3-1s.4.8 2.7.8"/>
<path d="M466.6 236.7s2.5 2 3.3 3c0 0 2.3-1.4 3-2.7 0 0 1.9 1.1 2.4 2.8 0 0 1.3-.8 1.5-2 0 0 3 .6 4.2 1.6 0 0 .4-3 0-4.8 0 0 2.1.2 3.4.7 0 0-1.2-2 5-4.5 0 0-4.7 1.1-6.5 3 0 0-2 .2-2.9-.4v4.5s-1.2-.6-3.5-1.1c0 0-.6 1-1 1.2 0 0-1.5-1.2-2.1-2.7 0 0-2.3 2.1-3 3 0 0-2.3-1.6-3.8-1.6"/>
<path fill="#ffc221" d="M452.5 267.3s1 .4 3.3-1.4 8.7-5.9 9.2-9.2c.7-3.3-2-3.4-4-2.5-2.2 1-1.3 2.7-1.2 3.4 0 .6.2 2.9-3.3 6z"/>
<path fill="#ffc221" d="M451.9 268.3s-5.2-2.2-.6-4.5 6.7-2.9 7.2-4.9c.6-1.9.2-1.5-1.5-.7-1.7.7-8.2 3.8-9.2 1 0 0 2.7 1 6-.6 3.4-1.7 6.2-2.1 4-2.8a37 37 0 0 0-11 .5c-1.4.4-1 .3-1.3 1.6-.2 1.3-1.6 4-2.2 4.7-.5.8-1.8 4 .6 5.5a9 9 0 0 0 8 .2"/>
<path d="M449.9 257s-1.3.2-1 .7q.3.6 1 .4c.3 0 1-.2 1.1-.5 0-.3-.8-.7-1.1-.5z"/>
<path fill="#fff" d="M451.5 267.1s-2.4-1.1.4-2.6 5.6-3 6-3.6c0 0-1.3 1.9-6.4 6.2"/>
</svg>

After

Width:  |  Height:  |  Size: 30 KiB

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