Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5bb5808706 | ||
|
|
d70937cc47 | ||
|
|
eab658dc90 | ||
|
|
0d738d4ce4 | ||
|
|
962c58fdd1 | ||
|
|
7b2e005b37 | ||
|
|
f1e70ecee5 | ||
|
|
f012c556e9 | ||
|
|
ab8bbbc1ed | ||
|
|
609a591045 | ||
|
|
461054615b | ||
|
|
020fb619a9 | ||
|
|
3cc73f1885 | ||
|
|
48fc4c5ab5 | ||
|
|
707ca3c124 | ||
|
|
a09f9af646 | ||
|
|
928ba7746e | ||
|
|
21f210d219 |
@@ -55,3 +55,4 @@ Thumbs.db
|
||||
|
||||
# ---- Claude Code / agent ----
|
||||
.claude/
|
||||
.worktrees/
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -32,8 +32,8 @@ Usage:
|
||||
GITHUB_TOKEN Optional bearer token for private repos
|
||||
or higher rate limits.
|
||||
vocat menu Interactive lifecycle menu (root on the host):
|
||||
toggle language, change password, restart, update,
|
||||
uninstall.
|
||||
toggle language, change password, change the Web port,
|
||||
restart, update, uninstall.
|
||||
vocat help Show this help message.
|
||||
|
||||
When run without a subcommand on a non-TTY (e.g. systemd), vocat starts the
|
||||
|
||||
@@ -438,13 +438,31 @@ func restoreConfiguredCellularData(
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
dataContext, cancel := context.WithTimeout(ctx, 60*time.Second)
|
||||
_, err = manager.SetNetwork(dataContext, entry.ID, device.NetworkRequest{
|
||||
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, 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)
|
||||
@@ -472,7 +490,7 @@ func disableAllDeveloperCellularData(
|
||||
_, 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -866,7 +884,7 @@ func enforceDefaultSafeCardPolicy(
|
||||
return
|
||||
}
|
||||
iccid := strings.TrimSpace(snapshot.ICCID)
|
||||
if _, err := database.CardPolicy(ctx, iccid); err == nil && !snapshot.SIMChanged {
|
||||
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)
|
||||
@@ -953,11 +971,19 @@ func reconcileCardPolicies(
|
||||
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
|
||||
@@ -1044,15 +1070,18 @@ func enforceCardRegion(
|
||||
}
|
||||
}
|
||||
if snapshot.ICCID != "" {
|
||||
policy := store.CardPolicy{
|
||||
ICCID: snapshot.ICCID,
|
||||
NetworkEnabled: false,
|
||||
VoWiFiEnabled: false,
|
||||
AirplaneEnabled: true,
|
||||
IPVersion: "IPV4V6",
|
||||
Source: cardPolicySourceRegionBlock,
|
||||
policy, policyErr := database.CardPolicy(ctx, snapshot.ICCID)
|
||||
if errors.Is(policyErr, store.ErrNotFound) {
|
||||
policy = store.CardPolicy{ICCID: snapshot.ICCID, IPVersion: "IPV4V6"}
|
||||
policyErr = nil
|
||||
}
|
||||
if err := database.UpsertCardPolicy(ctx, policy); err != nil && ctx.Err() == nil {
|
||||
policy.NetworkEnabled = false
|
||||
policy.VoWiFiEnabled = false
|
||||
policy.AirplaneEnabled = true
|
||||
policy.Source = cardPolicySourceRegionBlock
|
||||
if policyErr != nil && ctx.Err() == nil {
|
||||
logger.Warn("region block: failed to read card policy", "device_id", id, "iccid", snapshot.ICCID, "error", policyErr)
|
||||
} else if err := database.UpsertCardPolicy(ctx, policy); err != nil && ctx.Err() == nil {
|
||||
logger.Warn(
|
||||
"region block: failed to persist card policy",
|
||||
"device_id", id, "iccid", snapshot.ICCID, "error", err,
|
||||
|
||||
@@ -7,8 +7,10 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -25,6 +27,11 @@ import (
|
||||
// rewrite it or the next restart reverts the password.
|
||||
const envFilePath = "/etc/vocat/env"
|
||||
|
||||
// legacyEnvFilePath was used by the standalone deploy/vocat.service. Keep it
|
||||
// discoverable so the menu works on installations made before the installer
|
||||
// and service template converged on /etc/vocat/env.
|
||||
const legacyEnvFilePath = "/etc/vocat/vocat.env"
|
||||
|
||||
const systemdUnitPath = "/etc/systemd/system/vocat.service"
|
||||
|
||||
// defaultDatabasePath is the install-default SQLite location written into the
|
||||
@@ -50,7 +57,7 @@ func loadMenuEnv() {
|
||||
if _, ok := os.LookupEnv("VOCAT_DATABASE_PATH"); !ok {
|
||||
_ = os.Setenv("VOCAT_DATABASE_PATH", defaultDatabasePath)
|
||||
}
|
||||
if data, err := os.ReadFile(envFilePath); err == nil {
|
||||
if data, err := os.ReadFile(menuEnvFilePath()); err == nil {
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
@@ -69,10 +76,20 @@ func loadMenuEnv() {
|
||||
}
|
||||
}
|
||||
|
||||
func menuEnvFilePath() string {
|
||||
if _, err := os.Stat(envFilePath); err == nil {
|
||||
return envFilePath
|
||||
}
|
||||
if _, err := os.Stat(legacyEnvFilePath); err == nil {
|
||||
return legacyEnvFilePath
|
||||
}
|
||||
return envFilePath
|
||||
}
|
||||
|
||||
// runMenu is the interactive lifecycle menu: toggle language, change password,
|
||||
// restart the systemd unit, self-update, or fully uninstall vocat. It must run
|
||||
// as root on the host (needs systemctl + the 0600 env file). Docker deployments
|
||||
// do not use it.
|
||||
// change the Web listener port, restart the systemd unit, self-update, or fully
|
||||
// uninstall vocat. It must run as root on the host (needs systemctl + the 0600
|
||||
// env file). Docker deployments do not use it.
|
||||
func runMenu(logger *slog.Logger) error {
|
||||
if os.Geteuid() != 0 {
|
||||
return errors.New("vocat menu must run as root (needs systemctl and /etc/vocat/env)")
|
||||
@@ -113,10 +130,14 @@ func runMenu(logger *slog.Logger) error {
|
||||
fmt.Println(menu.errorPrefix(err))
|
||||
}
|
||||
case "3":
|
||||
if err := menuRestart(menu); err != nil {
|
||||
if err := menuChangeWebPort(reader, menu); err != nil {
|
||||
fmt.Println(menu.errorPrefix(err))
|
||||
}
|
||||
case "4":
|
||||
if err := menuRestart(menu); err != nil {
|
||||
fmt.Println(menu.errorPrefix(err))
|
||||
}
|
||||
case "5":
|
||||
if err := menuUpdate(menu, logger); err != nil {
|
||||
fmt.Println(menu.errorPrefix(err))
|
||||
}
|
||||
@@ -242,9 +263,18 @@ func readPasswordMasked() (string, error) {
|
||||
// the temp file lives in the same directory so os.Rename stays on one
|
||||
// filesystem.
|
||||
func rewriteEnvPassword(newPassword string) error {
|
||||
const key = "VOCAT_ADMIN_PASSWORD="
|
||||
return rewriteEnvValue(menuEnvFilePath(), "VOCAT_ADMIN_PASSWORD", newPassword)
|
||||
}
|
||||
|
||||
// rewriteEnvValue replaces or appends one systemd EnvironmentFile value. The
|
||||
// write is atomic and rejects line breaks so one setting cannot inject another.
|
||||
func rewriteEnvValue(path, name, value string) error {
|
||||
if name == "" || strings.ContainsAny(name, "=\r\n\x00") || strings.ContainsAny(value, "\r\n\x00") {
|
||||
return errors.New("invalid environment setting")
|
||||
}
|
||||
key := name + "="
|
||||
var lines []string
|
||||
if data, err := os.ReadFile(envFilePath); err == nil {
|
||||
if data, err := os.ReadFile(path); err == nil {
|
||||
lines = strings.Split(string(data), "\n")
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
@@ -253,27 +283,34 @@ func rewriteEnvPassword(newPassword string) error {
|
||||
replaced := false
|
||||
for i, line := range lines {
|
||||
if strings.HasPrefix(line, key) {
|
||||
lines[i] = key + newPassword
|
||||
lines[i] = key + value
|
||||
replaced = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !replaced {
|
||||
lines = append(lines, key+newPassword)
|
||||
lines = append(lines, key+value)
|
||||
}
|
||||
content := strings.Join(lines, "\n")
|
||||
if !strings.HasSuffix(content, "\n") {
|
||||
content += "\n"
|
||||
}
|
||||
return writeEnvFileAtomic(path, []byte(content))
|
||||
}
|
||||
|
||||
dir := envFilePath[:strings.LastIndex(envFilePath, "/")]
|
||||
func writeEnvFileAtomic(path string, content []byte) error {
|
||||
dirIndex := strings.LastIndexAny(path, "/\\")
|
||||
if dirIndex < 0 {
|
||||
return errors.New("environment file path has no directory")
|
||||
}
|
||||
dir := path[:dirIndex]
|
||||
tmp, err := os.CreateTemp(dir, ".vocat-env-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
defer os.Remove(tmpName)
|
||||
if _, err := tmp.WriteString(content); err != nil {
|
||||
if _, err := tmp.Write(content); err != nil {
|
||||
_ = tmp.Close()
|
||||
return err
|
||||
}
|
||||
@@ -284,7 +321,125 @@ func rewriteEnvPassword(newPassword string) error {
|
||||
if err := tmp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmpName, envFilePath)
|
||||
return os.Rename(tmpName, path)
|
||||
}
|
||||
|
||||
func menuChangeWebPort(reader *bufio.Reader, m *menu) error {
|
||||
if _, err := exec.LookPath("systemctl"); err != nil {
|
||||
return errNoSystemctl
|
||||
}
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %v", errMenuConfig, err)
|
||||
}
|
||||
_, currentPortText, err := net.SplitHostPort(strings.TrimSpace(cfg.Address))
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %v", errMenuConfig, err)
|
||||
}
|
||||
fmt.Println(m.currentWebAddress(cfg.Address))
|
||||
fmt.Println(m.reverseProxyNotice())
|
||||
fmt.Print(m.newWebPort(currentPortText))
|
||||
line, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
return fmt.Errorf("read Web port: %w", err)
|
||||
}
|
||||
portText := strings.TrimSpace(line)
|
||||
if portText == "" {
|
||||
fmt.Println(m.webPortCancelled())
|
||||
return nil
|
||||
}
|
||||
newAddress, newPort, err := webAddressWithPort(cfg.Address, portText)
|
||||
if err != nil {
|
||||
return errInvalidWebPort
|
||||
}
|
||||
currentPort, _ := strconv.Atoi(currentPortText)
|
||||
if newPort == currentPort {
|
||||
fmt.Println(m.webPortUnchanged())
|
||||
return nil
|
||||
}
|
||||
|
||||
listener, err := net.Listen("tcp", newAddress)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %v", errWebPortUnavailable, err)
|
||||
}
|
||||
_ = listener.Close()
|
||||
|
||||
environmentPath := menuEnvFilePath()
|
||||
original, readErr := os.ReadFile(environmentPath)
|
||||
originalExisted := readErr == nil
|
||||
if readErr != nil && !errors.Is(readErr, os.ErrNotExist) {
|
||||
return fmt.Errorf("%w: %v", errMenuPortWrite, readErr)
|
||||
}
|
||||
if err := rewriteEnvValue(environmentPath, "VOCAT_ADDR", newAddress); err != nil {
|
||||
return fmt.Errorf("%w: %v", errMenuPortWrite, err)
|
||||
}
|
||||
if err := restartVocatService(); err != nil {
|
||||
rollbackErr := restoreMenuEnvFile(environmentPath, original, originalExisted)
|
||||
_ = restartVocatService()
|
||||
if rollbackErr != nil {
|
||||
return fmt.Errorf("%w: %v; rollback failed: %v", errRestartFailed, err, rollbackErr)
|
||||
}
|
||||
return fmt.Errorf("%w: %v", errRestartFailed, err)
|
||||
}
|
||||
if err := waitForWebListener(newAddress, 5*time.Second); err != nil {
|
||||
rollbackErr := restoreMenuEnvFile(environmentPath, original, originalExisted)
|
||||
_ = restartVocatService()
|
||||
if rollbackErr != nil {
|
||||
return fmt.Errorf("%w: %v; rollback failed: %v", errRestartFailed, err, rollbackErr)
|
||||
}
|
||||
return fmt.Errorf("%w: %v", errRestartFailed, err)
|
||||
}
|
||||
_ = os.Setenv("VOCAT_ADDR", newAddress)
|
||||
fmt.Println(m.webPortChanged(newAddress))
|
||||
return nil
|
||||
}
|
||||
|
||||
func webAddressWithPort(address, portText string) (string, int, error) {
|
||||
host, _, err := net.SplitHostPort(strings.TrimSpace(address))
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
port, err := strconv.Atoi(strings.TrimSpace(portText))
|
||||
if err != nil || port < 1 || port > 65535 {
|
||||
return "", 0, errInvalidWebPort
|
||||
}
|
||||
return net.JoinHostPort(host, strconv.Itoa(port)), port, nil
|
||||
}
|
||||
|
||||
func waitForWebListener(address string, timeout time.Duration) error {
|
||||
host, port, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch host {
|
||||
case "", "0.0.0.0":
|
||||
host = "127.0.0.1"
|
||||
case "::":
|
||||
host = "::1"
|
||||
}
|
||||
target := net.JoinHostPort(host, port)
|
||||
deadline := time.Now().Add(timeout)
|
||||
var lastErr error
|
||||
for time.Now().Before(deadline) {
|
||||
connection, dialErr := net.DialTimeout("tcp", target, 500*time.Millisecond)
|
||||
if dialErr == nil {
|
||||
_ = connection.Close()
|
||||
return nil
|
||||
}
|
||||
lastErr = dialErr
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
}
|
||||
return fmt.Errorf("Web listener %s did not become reachable: %w", target, lastErr)
|
||||
}
|
||||
|
||||
func restoreMenuEnvFile(path string, content []byte, existed bool) error {
|
||||
if existed {
|
||||
return writeEnvFileAtomic(path, content)
|
||||
}
|
||||
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// menuToggleLanguage flips the persisted language preference between "zh" and
|
||||
@@ -327,6 +482,14 @@ func menuToggleLanguage(m *menu, logger *slog.Logger) error {
|
||||
}
|
||||
|
||||
func menuRestart(m *menu) error {
|
||||
if err := restartVocatService(); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println(m.restarted())
|
||||
return nil
|
||||
}
|
||||
|
||||
func restartVocatService() error {
|
||||
if _, err := exec.LookPath("systemctl"); err != nil {
|
||||
return errNoSystemctl
|
||||
}
|
||||
@@ -334,7 +497,9 @@ func menuRestart(m *menu) error {
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("%w: %s", errRestartFailed, strings.TrimSpace(string(out)))
|
||||
}
|
||||
fmt.Println(m.restarted())
|
||||
if out, err := exec.Command("systemctl", "is-active", "--quiet", "vocat").CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("%w: service is not active: %s", errRestartFailed, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -378,6 +543,7 @@ func menuUninstall(reader *bufio.Reader, m *menu) error {
|
||||
_ = os.Remove(systemdUnitPath)
|
||||
_ = os.RemoveAll("/opt/vocat")
|
||||
_ = os.Remove(envFilePath)
|
||||
_ = os.Remove(legacyEnvFilePath)
|
||||
_ = os.Remove("/etc/vocat") // succeeds only when empty
|
||||
runIgnore("systemctl", "daemon-reload")
|
||||
runIgnore("userdel", "vocat")
|
||||
@@ -388,15 +554,18 @@ func menuUninstall(reader *bufio.Reader, m *menu) error {
|
||||
|
||||
// menu-local sentinel errors so callers can map them to localized messages.
|
||||
var (
|
||||
errCurrentWrong = errors.New("menu: current password is incorrect")
|
||||
errPasswordsDiffer = errors.New("menu: passwords do not match")
|
||||
errNoSystemctl = errors.New("menu: systemctl not found")
|
||||
errRestartFailed = errors.New("menu: restart failed")
|
||||
errUpdateFailed = errors.New("menu: update failed")
|
||||
errMenuConfig = errors.New("menu: load configuration")
|
||||
errMenuStore = errors.New("menu: open database")
|
||||
errMenuAuth = errors.New("menu: auth service")
|
||||
errMenuEnvWrite = errors.New("menu: write env file")
|
||||
errCurrentWrong = errors.New("menu: current password is incorrect")
|
||||
errPasswordsDiffer = errors.New("menu: passwords do not match")
|
||||
errNoSystemctl = errors.New("menu: systemctl not found")
|
||||
errRestartFailed = errors.New("menu: restart failed")
|
||||
errUpdateFailed = errors.New("menu: update failed")
|
||||
errMenuConfig = errors.New("menu: load configuration")
|
||||
errMenuStore = errors.New("menu: open database")
|
||||
errMenuAuth = errors.New("menu: auth service")
|
||||
errMenuEnvWrite = errors.New("menu: write env file")
|
||||
errMenuPortWrite = errors.New("menu: write Web port")
|
||||
errInvalidWebPort = errors.New("menu: invalid Web port")
|
||||
errWebPortUnavailable = errors.New("menu: Web port unavailable")
|
||||
)
|
||||
|
||||
// ---- i18n ----
|
||||
@@ -409,18 +578,28 @@ func newMenu(lang string) *menu { return &menu{lang: lang} }
|
||||
func (m *menu) msg(key string) string {
|
||||
const zh, en = 0, 1
|
||||
table := map[string][2]string{
|
||||
"title": {"vocat 管理菜单", "vocat management menu"},
|
||||
"opt_lang": {"1) 切换中英文", "1) Toggle language"},
|
||||
"opt_change": {"2) 修改账号密码", "2) Change admin password"},
|
||||
"opt_restart": {"3) 重启软件", "3) Restart software"},
|
||||
"opt_update": {"4) 更新软件", "4) Update software"},
|
||||
"opt_uninstall": {"0) 卸载软件", "0) Uninstall software"},
|
||||
"prompt": {"请选择: ", "Select: "},
|
||||
"invalid": {"无效选项,请重试。按 Ctrl+C 退出。", "Invalid choice, try again. Press Ctrl+C to exit."},
|
||||
"cur_pw": {"当前密码: ", "Current password: "},
|
||||
"new_pw": {"新密码 (至少 12 位): ", "New password (min 12 chars): "},
|
||||
"confirm_pw": {"确认新密码: ", "Confirm new password: "},
|
||||
"pw_changed": {"密码已修改。重启后仍然有效。", "Password changed. Survives restart."},
|
||||
"title": {"vocat 管理菜单", "vocat management menu"},
|
||||
"opt_lang": {"1) 切换中英文", "1) Toggle language"},
|
||||
"opt_change": {"2) 修改账号密码", "2) Change admin password"},
|
||||
"opt_port": {"3) 修改 Web 监听端口", "3) Change Web listening port"},
|
||||
"opt_restart": {"4) 重启软件", "4) Restart software"},
|
||||
"opt_update": {"5) 更新软件", "5) Update software"},
|
||||
"opt_uninstall": {"0) 卸载软件", "0) Uninstall software"},
|
||||
"prompt": {"请选择: ", "Select: "},
|
||||
"invalid": {"无效选项,请重试。按 Ctrl+C 退出。", "Invalid choice, try again. Press Ctrl+C to exit."},
|
||||
"cur_pw": {"当前密码: ", "Current password: "},
|
||||
"new_pw": {"新密码 (至少 12 位): ", "New password (min 12 chars): "},
|
||||
"confirm_pw": {"确认新密码: ", "Confirm new password: "},
|
||||
"pw_changed": {"密码已修改。重启后仍然有效。", "Password changed. Survives restart."},
|
||||
"current_web_address": {"当前 Web 监听地址: %s", "Current Web listening address: %s"},
|
||||
"new_web_port": {"新端口 (1-65535,直接回车取消,当前 %s): ", "New port (1-65535, Enter to cancel, current %s): "},
|
||||
"web_port_cancelled": {"已取消修改端口。", "Web port change cancelled."},
|
||||
"web_port_unchanged": {"端口未改变。", "Web port is unchanged."},
|
||||
"web_port_changed": {"Web 监听地址已改为 %s,软件已重启。", "Web listening address changed to %s; software restarted."},
|
||||
"reverse_proxy_notice": {
|
||||
"如使用 Nginx/Caddy 等反向代理,请同步修改其上游端口。",
|
||||
"If you use Nginx, Caddy, or another reverse proxy, update its upstream port too.",
|
||||
},
|
||||
"lang_switched": {
|
||||
"语言已切换。Web 界面下次刷新后同步。",
|
||||
"Language switched. The web UI syncs on next refresh.",
|
||||
@@ -431,9 +610,9 @@ func (m *menu) msg(key string) string {
|
||||
"警告: 将删除程序、数据与配置,且不可恢复!",
|
||||
"WARNING: removes the program, data and config. Irreversible!",
|
||||
},
|
||||
"uninstall_confirm": {"输入 yes 确认卸载: ", "Type yes to confirm uninstall: "},
|
||||
"uninstall_confirm": {"输入 yes 确认卸载: ", "Type yes to confirm uninstall: "},
|
||||
"uninstall_cancelled": {"已取消卸载。", "Uninstall cancelled."},
|
||||
"uninstalled": {"vocat 已卸载。", "vocat uninstalled."},
|
||||
"uninstalled": {"vocat 已卸载。", "vocat uninstalled."},
|
||||
}
|
||||
entry, ok := table[key]
|
||||
if !ok {
|
||||
@@ -445,25 +624,36 @@ func (m *menu) msg(key string) string {
|
||||
return entry[zh]
|
||||
}
|
||||
|
||||
func (m *menu) title() string { return m.msg("title") }
|
||||
func (m *menu) prompt() string { return m.msg("prompt") }
|
||||
func (m *menu) invalid() string { return m.msg("invalid") }
|
||||
func (m *menu) currentPassword() string { return m.msg("cur_pw") }
|
||||
func (m *menu) newPassword() string { return m.msg("new_pw") }
|
||||
func (m *menu) confirmPassword() string { return m.msg("confirm_pw") }
|
||||
func (m *menu) passwordChanged() string { return m.msg("pw_changed") }
|
||||
func (m *menu) languageSwitched() string { return m.msg("lang_switched") }
|
||||
func (m *menu) updateChecking() string { return m.msg("upd_checking") }
|
||||
func (m *menu) restarted() string { return m.msg("restarted") }
|
||||
func (m *menu) uninstallWarn() string { return m.msg("uninstall_warn") }
|
||||
func (m *menu) uninstallConfirm() string { return m.msg("uninstall_confirm") }
|
||||
func (m *menu) title() string { return m.msg("title") }
|
||||
func (m *menu) prompt() string { return m.msg("prompt") }
|
||||
func (m *menu) invalid() string { return m.msg("invalid") }
|
||||
func (m *menu) currentPassword() string { return m.msg("cur_pw") }
|
||||
func (m *menu) newPassword() string { return m.msg("new_pw") }
|
||||
func (m *menu) confirmPassword() string { return m.msg("confirm_pw") }
|
||||
func (m *menu) passwordChanged() string { return m.msg("pw_changed") }
|
||||
func (m *menu) currentWebAddress(address string) string {
|
||||
return fmt.Sprintf(m.msg("current_web_address"), address)
|
||||
}
|
||||
func (m *menu) newWebPort(port string) string { return fmt.Sprintf(m.msg("new_web_port"), port) }
|
||||
func (m *menu) webPortCancelled() string { return m.msg("web_port_cancelled") }
|
||||
func (m *menu) webPortUnchanged() string { return m.msg("web_port_unchanged") }
|
||||
func (m *menu) webPortChanged(address string) string {
|
||||
return fmt.Sprintf(m.msg("web_port_changed"), address)
|
||||
}
|
||||
func (m *menu) reverseProxyNotice() string { return m.msg("reverse_proxy_notice") }
|
||||
func (m *menu) languageSwitched() string { return m.msg("lang_switched") }
|
||||
func (m *menu) updateChecking() string { return m.msg("upd_checking") }
|
||||
func (m *menu) restarted() string { return m.msg("restarted") }
|
||||
func (m *menu) uninstallWarn() string { return m.msg("uninstall_warn") }
|
||||
func (m *menu) uninstallConfirm() string { return m.msg("uninstall_confirm") }
|
||||
func (m *menu) uninstallCancelled() string { return m.msg("uninstall_cancelled") }
|
||||
func (m *menu) uninstalled() string { return m.msg("uninstalled") }
|
||||
func (m *menu) uninstalled() string { return m.msg("uninstalled") }
|
||||
|
||||
func (m *menu) options() []string {
|
||||
return []string{
|
||||
m.msg("opt_lang"),
|
||||
m.msg("opt_change"),
|
||||
m.msg("opt_port"),
|
||||
m.msg("opt_restart"),
|
||||
m.msg("opt_update"),
|
||||
m.msg("opt_uninstall"),
|
||||
@@ -514,9 +704,24 @@ func (m *menu) errorPrefix(err error) string {
|
||||
return "认证服务错误。"
|
||||
case errors.Is(err, errMenuEnvWrite):
|
||||
if m.lang == "en" {
|
||||
return "Password changed in DB, but the env file rewrite failed — restart will revert it. Check " + envFilePath + "."
|
||||
return "Password changed in DB, but the env file rewrite failed — restart will revert it. Check " + menuEnvFilePath() + "."
|
||||
}
|
||||
return "数据库密码已修改,但环境变量文件写入失败——重启后将回滚。请检查 " + envFilePath + "。"
|
||||
return "数据库密码已修改,但环境变量文件写入失败——重启后将回滚。请检查 " + menuEnvFilePath() + "。"
|
||||
case errors.Is(err, errInvalidWebPort):
|
||||
if m.lang == "en" {
|
||||
return "Invalid port. Enter a number from 1 to 65535."
|
||||
}
|
||||
return "端口无效,请输入 1 到 65535。"
|
||||
case errors.Is(err, errWebPortUnavailable):
|
||||
if m.lang == "en" {
|
||||
return "The new Web port is unavailable or already in use."
|
||||
}
|
||||
return "新的 Web 端口不可用或已被占用。"
|
||||
case errors.Is(err, errMenuPortWrite):
|
||||
if m.lang == "en" {
|
||||
return "Failed to save the Web listening port to " + menuEnvFilePath() + "."
|
||||
}
|
||||
return "无法将 Web 监听端口保存到 " + menuEnvFilePath() + "。"
|
||||
default:
|
||||
if m.lang == "en" {
|
||||
return "Error: " + err.Error()
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWebAddressWithPort(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
address string
|
||||
port string
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "IPv4", address: "0.0.0.0:7575", port: "8080", want: "0.0.0.0:8080"},
|
||||
{name: "IPv6", address: "[::]:7575", port: "8443", want: "[::]:8443"},
|
||||
{name: "minimum", address: "127.0.0.1:7575", port: "1", want: "127.0.0.1:1"},
|
||||
{name: "maximum", address: "127.0.0.1:7575", port: "65535", want: "127.0.0.1:65535"},
|
||||
{name: "zero", address: "0.0.0.0:7575", port: "0", wantErr: true},
|
||||
{name: "too large", address: "0.0.0.0:7575", port: "65536", wantErr: true},
|
||||
{name: "not numeric", address: "0.0.0.0:7575", port: "http", wantErr: true},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
got, _, err := webAddressWithPort(test.address, test.port)
|
||||
if test.wantErr {
|
||||
if !errors.Is(err, errInvalidWebPort) {
|
||||
t.Fatalf("error = %v, want errInvalidWebPort", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil || got != test.want {
|
||||
t.Fatalf("webAddressWithPort() = %q, %v; want %q", got, err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewriteEnvValuePreservesOtherSettings(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "env")
|
||||
if err := os.WriteFile(path, []byte("VOCAT_ADMIN_PASSWORD=secret\nVOCAT_ADDR=0.0.0.0:7575\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := rewriteEnvValue(path, "VOCAT_ADDR", "0.0.0.0:8080"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := string(content)
|
||||
if !strings.Contains(got, "VOCAT_ADMIN_PASSWORD=secret\n") || !strings.Contains(got, "VOCAT_ADDR=0.0.0.0:8080\n") || strings.Contains(got, ":7575") {
|
||||
t.Fatalf("rewritten env = %q", got)
|
||||
}
|
||||
if err := rewriteEnvValue(path, "VOCAT_ADDR", "0.0.0.0:9000\nVOCAT_ADMIN_PASSWORD=changed"); err == nil {
|
||||
t.Fatal("environment line injection was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMenuIncludesWebPortOptionInBothLanguages(t *testing.T) {
|
||||
for _, lang := range []string{"zh", "en"} {
|
||||
options := strings.Join(newMenu(lang).options(), "\n")
|
||||
if !strings.Contains(options, "3)") || !strings.Contains(strings.ToLower(options), "web") {
|
||||
t.Fatalf("%s menu options do not contain Web port entry: %q", lang, options)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,6 @@ Restart=on-failure
|
||||
RestartSec=3s
|
||||
TimeoutStartSec=30s
|
||||
TimeoutStopSec=20s
|
||||
Environment=VOCAT_ADDR=0.0.0.0:7575
|
||||
Environment=VOCAT_DATABASE_PATH=/opt/vocat/data/vocat.db
|
||||
EnvironmentFile=/etc/vocat/vocat.env
|
||||
|
||||
|
||||
@@ -0,0 +1,383 @@
|
||||
# 企业微信消息推送实现计划
|
||||
|
||||
> **面向 AI 代理的工作者:** 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(`- [ ]`)语法来跟踪进度。
|
||||
|
||||
**目标:** 增加可配置 JSON 请求模板的企业微信 Webhook 通知通道,向新短信和自动任务结果发送消息。
|
||||
|
||||
**架构:** 新建专注的企业微信通知模块,统一构建事件变量、JSON 安全替换、Webhook POST 和 `errcode` 响应判定。设置 API 将 `wecom` 纳入白名单、保密 URL 与连通性测试;短信和自动任务分发器只增加该通道分支。前端在现有通知设置表单中新增企业微信页签和请求体编辑器。
|
||||
|
||||
**技术栈:** Go 1.25、标准库 `net/http` 与 `encoding/json`、SQLite 通知设置、React、TypeScript、Vite。
|
||||
|
||||
---
|
||||
|
||||
## 文件结构
|
||||
|
||||
- 创建:`internal/server/wecom_notification.go`,渲染企业微信 JSON 模板、创建安全 HTTP 请求并判定企业微信响应。
|
||||
- 创建:`internal/server/wecom_notification_test.go`,覆盖 JSON 转义、模板拒绝和企业微信响应失败。
|
||||
- 修改:`internal/server/settings_api.go`,登记 `wecom` 配置字段、启用连通性测试并调用企业微信发送器。
|
||||
- 修改:`internal/server/settings_api_test.go`,验证企业微信配置 API、敏感 URL 与测试路径。
|
||||
- 修改:`internal/store/settings.go`,将 `wecom.urls` 注册为敏感字段。
|
||||
- 修改:`internal/server/sms_notifications.go`,将新短信事件接入企业微信通道。
|
||||
- 修改:`internal/server/sms_notifications_test.go`,覆盖企业微信短信配置要求和变量数据。
|
||||
- 修改:`internal/server/automatic_task_notifications.go`,将自动任务结果接入企业微信通道。
|
||||
- 修改:`web/src/types.ts`,扩展通知设置类型。
|
||||
- 修改:`web/src/components/settings/model.ts`,增加企业微信表单、默认模板、读取和提交映射。
|
||||
- 修改:`web/src/components/settings/PushTabs.tsx`,新增企业微信配置界面。
|
||||
- 修改:`web/src/pages/SettingsPage.tsx`,增加页签、测试状态与测试请求。
|
||||
|
||||
### 任务 1:企业微信模板与响应判定
|
||||
|
||||
**文件:**
|
||||
- 创建:`internal/server/wecom_notification_test.go`
|
||||
- 创建:`internal/server/wecom_notification.go`
|
||||
|
||||
- [ ] **步骤 1:编写失败的模板与响应测试**
|
||||
|
||||
```go
|
||||
func TestRenderWecomPayloadEscapesTemplateValues(t *testing.T) {
|
||||
payload, err := renderWecomPayload(
|
||||
`{"msgtype":"text","text":{"content":{{message}},"number":{{number}}}}`,
|
||||
wecomTemplateValues{"message": "quote: \\"\\nline", "number": "+447386"},
|
||||
)
|
||||
if err != nil { t.Fatal(err) }
|
||||
if got := string(payload); got != `{"msgtype":"text","text":{"content":"quote: \\"\\nline","number":"+447386"}}` {
|
||||
t.Fatalf("payload = %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderWecomPayloadRejectsUnknownVariableAndNonObject(t *testing.T) {
|
||||
for _, template := range []string{`{"text":{{unknown}}}`, `[]`} {
|
||||
if _, err := renderWecomPayload(template, wecomTemplateValues{}); err == nil {
|
||||
t.Fatalf("template %q was accepted", template)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateWecomResponseRejectsProviderError(t *testing.T) {
|
||||
if err := validateWecomResponse(http.StatusOK, []byte(`{"errcode":40058,"errmsg":"invalid"}`)); !errors.Is(err, errProviderRejected) {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **步骤 2:运行测试验证失败**
|
||||
|
||||
运行:`go test ./internal/server -run 'TestRenderWecomPayload|TestValidateWecomResponse' -count=1`
|
||||
|
||||
预期:FAIL,提示 `renderWecomPayload`、`wecomTemplateValues` 和 `validateWecomResponse` 未定义。
|
||||
|
||||
- [ ] **步骤 3:实现最少的模板与响应代码**
|
||||
|
||||
在 `internal/server/wecom_notification.go` 中定义受支持变量列表,先用 `json.Marshal` 编码每个字符串,再替换精确的 `{{name}}` 标记;若保留任何 `{{` 或 `}}`,或者 `json.Unmarshal` 后不是非空 `map[string]json.RawMessage`,返回错误。响应处理必须要求 HTTP 2xx、可解析 JSON,且 `errcode` 为零。
|
||||
|
||||
```go
|
||||
type wecomTemplateValues map[string]string
|
||||
|
||||
func renderWecomPayload(template string, values wecomTemplateValues) ([]byte, error) {
|
||||
for _, name := range wecomTemplateVariableNames {
|
||||
encoded, _ := json.Marshal(values[name])
|
||||
template = strings.ReplaceAll(template, "{{"+name+"}}", string(encoded))
|
||||
}
|
||||
if strings.Contains(template, "{{") || strings.Contains(template, "}}") {
|
||||
return nil, errors.New("wecom.payload_template contains an unsupported variable")
|
||||
}
|
||||
var payload map[string]json.RawMessage
|
||||
if err := json.Unmarshal([]byte(template), &payload); err != nil || len(payload) == 0 {
|
||||
return nil, errors.New("wecom.payload_template must render to a non-empty JSON object")
|
||||
}
|
||||
return []byte(template), nil
|
||||
}
|
||||
|
||||
func validateWecomResponse(status int, body []byte) error {
|
||||
var result struct { ErrCode int `json:"errcode"` }
|
||||
if status < http.StatusOK || status >= http.StatusMultipleChoices || json.Unmarshal(body, &result) != nil || result.ErrCode != 0 {
|
||||
return fmt.Errorf("%w: WeCom response was not successful", errProviderRejected)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func wecomTestValues(now time.Time) wecomTemplateValues {
|
||||
return wecomTemplateValues{
|
||||
"event": "test", "title": "vocat", "message": "vocat notification test",
|
||||
"timestamp": now.UTC().Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
|
||||
func sendWecomNotification(ctx context.Context, config map[string]any, values wecomTemplateValues) error {
|
||||
payload, err := renderWecomPayload(configString(config, "payload_template"), values)
|
||||
if err != nil { return err }
|
||||
client, err := restrictedHTTPClient(ctx, 8*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 fmt.Errorf("create WeCom notification request: %w", err) }
|
||||
request.Header.Set("Content-Type", "application/json; charset=utf-8")
|
||||
request.Header.Set("User-Agent", "vocat-wecom-notification/1")
|
||||
response, err := client.Do(request)
|
||||
if err != nil { return fmt.Errorf("send WeCom notification: %w", err) }
|
||||
body, readErr := io.ReadAll(io.LimitReader(response.Body, 64<<10)); response.Body.Close()
|
||||
if readErr != nil { return fmt.Errorf("read WeCom response: %w", readErr) }
|
||||
if err := validateWecomResponse(response.StatusCode, body); err != nil { return err }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **步骤 4:运行测试验证通过**
|
||||
|
||||
运行:`go test ./internal/server -run 'TestRenderWecomPayload|TestValidateWecomResponse' -count=1`
|
||||
|
||||
预期:PASS。
|
||||
|
||||
- [ ] **步骤 5:提交本任务**
|
||||
|
||||
运行:`git add internal/server/wecom_notification.go internal/server/wecom_notification_test.go && git commit -m "feat: add WeCom payload renderer"`
|
||||
|
||||
预期:创建包含模板渲染和响应判定的提交。若 Git 作者身份仍未配置,停止提交但保留已验证的工作区改动,不自行设置身份。
|
||||
|
||||
### 任务 2:设置 API 与敏感 Webhook URL
|
||||
|
||||
**文件:**
|
||||
- 修改:`internal/server/settings_api_test.go`
|
||||
- 修改:`internal/store/settings.go`
|
||||
- 修改:`internal/server/settings_api.go`
|
||||
|
||||
- [ ] **步骤 1:编写失败的 API 测试**
|
||||
|
||||
```go
|
||||
func TestWecomNotificationSettingsPreserveWebhookURLs(t *testing.T) {
|
||||
test := newSettingsAPITest(t)
|
||||
body := `{"wecom":{"enabled":true,"urls":["https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=secret"],"payload_template":"{\\\"msgtype\\\":\\\"text\\\",\\\"text\\\":{\\\"content\\\":{{message}}}}"}}`
|
||||
recorder := test.request(t, http.MethodPut, "/api/settings/notifications", body)
|
||||
if recorder.Code != http.StatusOK { t.Fatalf("status = %d", recorder.Code) }
|
||||
if bytes.Contains(recorder.Body.Bytes(), []byte("key=secret")) { t.Fatal("response leaked webhook URL") }
|
||||
stored, err := test.database.NotificationSetting(context.Background(), "wecom")
|
||||
if err != nil || !bytes.Contains(stored.Config, []byte("key=secret")) { t.Fatalf("stored = %s, err = %v", stored.Config, err) }
|
||||
}
|
||||
|
||||
func TestWecomNotificationSettingsRejectMalformedTemplate(t *testing.T) {
|
||||
test := newSettingsAPITest(t)
|
||||
recorder := test.request(t, http.MethodPut, "/api/settings/notifications", `{"wecom":{"enabled":true,"urls":["https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=x"],"payload_template":"[]"}}`)
|
||||
if recorder.Code != http.StatusBadRequest { t.Fatalf("status = %d", recorder.Code) }
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **步骤 2:运行测试验证失败**
|
||||
|
||||
运行:`go test ./internal/server -run 'TestWecomNotificationSettings' -count=1`
|
||||
|
||||
预期:FAIL,设置 API 返回 `invalid_notification_channel`。
|
||||
|
||||
- [ ] **步骤 3:实现 API 契约、保存和测试端点**
|
||||
|
||||
在 `notificationChannels` 中加入 `wecom`,在 `notificationFields` 中登记 `urls: strings` 和 `payload_template: wecom_template`。将 `urls` 加入 `DefaultNotificationSensitiveFields("wecom")`。在字段验证中对 `wecom_template` 调用 `renderWecomPayload`,以默认测试变量确认模板会生成对象;在 `validateNotificationTestConfig`、`handleNotificationTest` 和发送分支中支持 `wecom`。
|
||||
|
||||
```go
|
||||
"wecom": {"urls": "strings", "payload_template": "wecom_template"},
|
||||
|
||||
case "wecom":
|
||||
return []string{"urls"}
|
||||
|
||||
case "wecom":
|
||||
err = sendWecomNotificationTest(r.Context(), resolved)
|
||||
```
|
||||
|
||||
将上段 `payload_template` 的字段类型实现为 `wecom_template`,避免只按普通字符串检查:
|
||||
|
||||
```go
|
||||
case "wecom_template":
|
||||
var template string
|
||||
if err := json.Unmarshal(raw, &template); err != nil || len(template) > 32768 {
|
||||
return fmt.Errorf("%s must be a template string", field)
|
||||
}
|
||||
_, err := renderWecomPayload(template, wecomTestValues(time.Unix(0, 0)))
|
||||
return err
|
||||
|
||||
case "wecom":
|
||||
if len(configStrings(config, "urls")) == 0 || configString(config, "payload_template") == "" {
|
||||
return errors.New("wecom.urls and wecom.payload_template are required")
|
||||
}
|
||||
```
|
||||
|
||||
测试消息的变量必须为 `event: "test"`、`title: "vocat"`、`message: "vocat notification test"` 和当前 UTC RFC3339 时间;它应经过与生产消息完全相同的渲染和发送路径。
|
||||
|
||||
- [ ] **步骤 4:运行测试验证通过**
|
||||
|
||||
运行:`go test ./internal/server -run 'TestWecomNotificationSettings|TestNotificationSettingsAlwaysReturns' -count=1`
|
||||
|
||||
预期:PASS,GET/PUT 响应不会泄露 `key`,但数据库保留原 URL。
|
||||
|
||||
- [ ] **步骤 5:提交本任务**
|
||||
|
||||
运行:`git add internal/server/settings_api.go internal/server/settings_api_test.go internal/store/settings.go && git commit -m "feat: configure WeCom notifications"`
|
||||
|
||||
预期:创建设置 API 与敏感配置提交;作者身份未配置时遵循任务 1 的处理方式。
|
||||
|
||||
### 任务 3:接入短信与自动任务分发
|
||||
|
||||
**文件:**
|
||||
- 修改:`internal/server/sms_notifications_test.go`
|
||||
- 修改:`internal/server/sms_notifications.go`
|
||||
- 修改:`internal/server/automatic_task_notifications.go`
|
||||
|
||||
- [ ] **步骤 1:编写失败的事件变量测试**
|
||||
|
||||
```go
|
||||
func TestWecomSMSValuesIncludeRenderedSMSFields(t *testing.T) {
|
||||
message := smsNotification{DeviceID: "device-1", DeviceName: "客厅", DeviceLabel: "EC20", Number: "+447386", Time: time.Unix(1700000000, 0), Content: "hello"}
|
||||
values := wecomSMSValues(message)
|
||||
if values["event"] != "sms.received" || values["content"] != "hello" || values["device_label"] != "EC20" {
|
||||
t.Fatalf("values = %#v", values)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWecomAutomaticTaskValuesLeaveSMSFieldsEmpty(t *testing.T) {
|
||||
values := wecomAutomaticTaskValues(automaticTaskNotification{Title: "自动任务执行成功", Text: "任务已完成", Time: time.Unix(1700000000, 0)})
|
||||
if values["event"] != "automatic_task.completed" || values["message"] != "任务已完成" || values["number"] != "" {
|
||||
t.Fatalf("values = %#v", values)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **步骤 2:运行测试验证失败**
|
||||
|
||||
运行:`go test ./internal/server -run 'TestWecomSMSValues|TestWecomAutomaticTaskValues' -count=1`
|
||||
|
||||
预期:FAIL,两个事件变量构建函数未定义。
|
||||
|
||||
- [ ] **步骤 3:实现分发接入**
|
||||
|
||||
在企业微信模块中实现 `wecomSMSValues` 和 `wecomAutomaticTaskValues`,填充全部已声明变量,短信专属字段在自动任务事件中设为空字符串。然后将 `wecom` 加入以下分发列表与 switch:
|
||||
|
||||
```go
|
||||
var smsOnlyNotificationChannels = []string{"bark", "email", "pushplus", "webhook", "wecom"}
|
||||
|
||||
case "wecom":
|
||||
return sendWecomNotification(ctx, config, wecomSMSValues(message))
|
||||
```
|
||||
|
||||
```go
|
||||
channels := []string{"telegram", "bark", "email", "pushplus", "webhook", "wecom"}
|
||||
for _, channel := range channels {
|
||||
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) }
|
||||
}
|
||||
|
||||
case "wecom":
|
||||
return sendWecomNotification(ctx, config, wecomAutomaticTaskValues(message))
|
||||
```
|
||||
|
||||
保持既有游标、错误限流日志和其他通道的行为不变。
|
||||
|
||||
- [ ] **步骤 4:运行测试验证通过**
|
||||
|
||||
运行:`go test ./internal/server -run 'TestWecomSMSValues|TestWecomAutomaticTaskValues|TestValidateSMSNotificationConfig' -count=1`
|
||||
|
||||
预期:PASS,`validateSMSNotificationConfig` 也接受包含有效 URL 和模板的 `wecom` 配置。
|
||||
|
||||
- [ ] **步骤 5:提交本任务**
|
||||
|
||||
运行:`git add internal/server/wecom_notification.go internal/server/sms_notifications.go internal/server/sms_notifications_test.go internal/server/automatic_task_notifications.go && git commit -m "feat: dispatch WeCom notifications"`
|
||||
|
||||
预期:创建两类事件分发接入提交;作者身份未配置时遵循任务 1 的处理方式。
|
||||
|
||||
### 任务 4:企业微信配置界面
|
||||
|
||||
**文件:**
|
||||
- 修改:`web/src/types.ts`
|
||||
- 修改:`web/src/components/settings/model.ts`
|
||||
- 修改:`web/src/components/settings/PushTabs.tsx`
|
||||
- 修改:`web/src/pages/SettingsPage.tsx`
|
||||
|
||||
- [ ] **步骤 1:扩展前端类型和表单映射**
|
||||
|
||||
在 `NotificationSettings` 与 `NotifyForms` 中增加 `wecom`。新增以下表单类型和默认请求体;URL 数组保持一项一个输入行的既有 `UrlListEditor` 约定。
|
||||
|
||||
```ts
|
||||
export interface WecomForm {
|
||||
enabled: boolean;
|
||||
urls: string[];
|
||||
payloadTemplate: string;
|
||||
}
|
||||
|
||||
const DEFAULT_WECOM_PAYLOAD_TEMPLATE = `{
|
||||
"msgtype": "text",
|
||||
"text": { "content": {{message}} }
|
||||
}`;
|
||||
```
|
||||
|
||||
`formsFromNotifications` 读取 `payload_template`,`buildNotificationsPayload` 输出 `payload_template`,测试请求则修剪并移除空 URL。
|
||||
|
||||
- [ ] **步骤 2:实现企业微信页签与测试请求**
|
||||
|
||||
在 `PushTabs.tsx` 增加 `WecomTab`,显示启用开关、`UrlListEditor`、JSON `Textarea` 和变量说明。URL 列表文案必须明确“每个 Webhook URL 单独一行,点击添加 URL 增加”,不得提示使用分隔符。
|
||||
|
||||
```tsx
|
||||
<Field label={t("JSON 请求体模板")} hint={<span>变量必须作为 JSON 值使用,例如 <code>{'{{message}}'}</code>。</span>}>
|
||||
<Textarea value={value.payloadTemplate} onChange={(event) => onChange({ payloadTemplate: event.target.value })} disabled={off} rows={12} />
|
||||
</Field>
|
||||
```
|
||||
|
||||
在 `SettingsPage.tsx` 增加 `testingWecom`、`onTestWecom`、企业微信页签与组件渲染。测试请求使用 `POST /settings/notifications/wecom/test` 和企业微信表单 payload;成功与失败消息沿用现有通知测试模式。
|
||||
|
||||
- [ ] **步骤 3:运行前端构建验证**
|
||||
|
||||
运行:`npm run build`
|
||||
|
||||
工作目录:`web`
|
||||
|
||||
预期:Vite 类型检查与生产构建均以退出码 0 完成。
|
||||
|
||||
- [ ] **步骤 4:提交本任务**
|
||||
|
||||
运行:`git add web/src/types.ts web/src/components/settings/model.ts web/src/components/settings/PushTabs.tsx web/src/pages/SettingsPage.tsx && git commit -m "feat: add WeCom notification settings"`
|
||||
|
||||
预期:创建企业微信设置 UI 提交;作者身份未配置时遵循任务 1 的处理方式。
|
||||
|
||||
### 任务 5:完整验证
|
||||
|
||||
**文件:**
|
||||
- 修改:`internal/server/wecom_notification.go`
|
||||
- 修改:`internal/server/wecom_notification_test.go`
|
||||
- 修改:`internal/server/settings_api.go`
|
||||
- 修改:`internal/server/settings_api_test.go`
|
||||
- 修改:`internal/store/settings.go`
|
||||
- 修改:`internal/server/sms_notifications.go`
|
||||
- 修改:`internal/server/sms_notifications_test.go`
|
||||
- 修改:`internal/server/automatic_task_notifications.go`
|
||||
- 修改:`web/src/types.ts`
|
||||
- 修改:`web/src/components/settings/model.ts`
|
||||
- 修改:`web/src/components/settings/PushTabs.tsx`
|
||||
- 修改:`web/src/pages/SettingsPage.tsx`
|
||||
|
||||
- [ ] **步骤 1:格式化 Go 代码**
|
||||
|
||||
运行:`gofmt -w internal/server/wecom_notification.go internal/server/wecom_notification_test.go internal/server/settings_api.go internal/server/settings_api_test.go internal/server/sms_notifications.go internal/server/sms_notifications_test.go internal/server/automatic_task_notifications.go internal/store/settings.go`
|
||||
|
||||
预期:所有修改的 Go 文件采用项目标准格式。
|
||||
|
||||
- [ ] **步骤 2:运行前端生产构建**
|
||||
|
||||
运行:`npm run build`
|
||||
|
||||
工作目录:`web`
|
||||
|
||||
预期:退出码 0,并生成 `web/dist` 供 Go 的嵌入资源使用。
|
||||
|
||||
- [ ] **步骤 3:运行后端回归测试**
|
||||
|
||||
运行:`go test ./...`
|
||||
|
||||
预期:所有目标包通过,无失败测试;`cmd/vocat` 和 `web` 包从步骤 2 生成的 `web/dist` 读取嵌入资源。
|
||||
|
||||
- [ ] **步骤 4:检查最终变更**
|
||||
|
||||
运行:`git diff --check && git status --short`
|
||||
|
||||
预期:无空白错误;变更仅限企业微信通知、其测试与设计/计划文档。
|
||||
@@ -0,0 +1,55 @@
|
||||
# 企业微信消息推送设计
|
||||
|
||||
## 目标
|
||||
|
||||
新增独立的 `wecom` 通知通道,通过企业微信“消息推送(原群机器人)”Webhook 推送新收到的短信和自动任务执行结果。外部 API 契约与既有通知通道保持一致。
|
||||
|
||||
## 配置模型
|
||||
|
||||
`wecom` 配置包含:
|
||||
|
||||
- `enabled`:是否启用通道。
|
||||
- `urls`:一个或多个企业微信消息推送 Webhook URL。Web 设置页将每个 URL
|
||||
显示为独立输入行,通过“添加 URL”按钮新增输入行、通过删除按钮移除输入行;
|
||||
不使用逗号、空格或换行分隔多个 URL。
|
||||
- `payload_template`:完整 JSON 请求体模板。
|
||||
|
||||
Webhook URL 含有企业微信访问密钥,必须作为敏感配置存储、在读取接口中脱敏,并在日志和错误信息中避免泄露。URL 沿用现有出站 URL 校验与 SSRF 防护。
|
||||
|
||||
## 模板语义
|
||||
|
||||
用户在 Web 设置页编辑完整 JSON 请求体,以选择企业微信支持的任意消息格式,例如 `text`、`markdown`、`news` 或 `template_card`。
|
||||
|
||||
模板变量仅能作为 JSON 值出现,服务端使用 JSON 编码后的字符串替换,调用方不得在变量外添加引号。示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"msgtype": "text",
|
||||
"text": {
|
||||
"content": {{message}}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
可用变量:
|
||||
|
||||
- 通用:`{{event}}`、`{{title}}`、`{{message}}`、`{{timestamp}}`。
|
||||
- 短信事件:`{{content}}`、`{{number}}`、`{{device_id}}`、`{{device_name}}`、`{{device_label}}`、`{{time}}`。
|
||||
|
||||
自动任务使用通用变量;短信专属变量在自动任务中替换为空字符串。模板渲染后必须为非空 JSON 对象,不得保留模板变量;无效模板在保存和测试时拒绝。
|
||||
|
||||
## 发送流程
|
||||
|
||||
短信分发器为 `wecom` 维护独立游标,发送失败不会阻塞其他通知渠道。自动任务完成后,和 Telegram、Bark、邮件、PushPlus、通用 Webhook 一样,向已启用的 `wecom` 通道发送结果。
|
||||
|
||||
发送器逐一 POST 渲染后的 JSON 到所有配置 URL,使用现有受限 HTTP 客户端。除 HTTP 2xx 外,企业微信返回 JSON 的 `errcode` 非零也视为服务商拒绝。
|
||||
|
||||
## Web 与 API
|
||||
|
||||
设置 API 将 `wecom` 加入已知通道和配置字段白名单,并提供 `POST /api/settings/notifications/wecom/test`。Web 设置页新增“企业微信”页签、启用开关、逐行编辑的 Webhook URL 列表、JSON 模板编辑器和测试按钮。
|
||||
|
||||
默认模板使用 `text` 消息,发送一条可辨识的测试内容。
|
||||
|
||||
## 验证
|
||||
|
||||
后端测试覆盖:配置字段验证、模板的 JSON 转义和拒绝无效模板、企业微信请求载荷、非零 `errcode` 失败处理、通知设置 API 读写与敏感 Webhook URL 保留。前端构建用于验证新增表单与类型契约。
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
@@ -58,7 +99,14 @@ func (manager *Manager) SetNetwork(
|
||||
if candidate.QMIControl == "" || candidate.NetworkInterface == "" {
|
||||
return NetworkResult{}, fmt.Errorf("%w: QMI control device and network interface are required", ErrDataBackendUnavailable)
|
||||
}
|
||||
return setQMINetwork(ctx, candidate, request.Enabled, apn, ipVersion)
|
||||
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)
|
||||
@@ -67,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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, ¬ification.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")
|
||||
}
|
||||
}
|
||||
@@ -559,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
|
||||
}
|
||||
|
||||
@@ -24,10 +24,13 @@ var (
|
||||
)
|
||||
|
||||
type NetworkRequest struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
APN string `json:"apn"`
|
||||
IPVersion string `json:"ipVersion"`
|
||||
Backend string `json:"backend,omitempty"`
|
||||
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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/mail"
|
||||
@@ -57,7 +56,7 @@ func (s *Server) notifyAutomaticTask(ctx context.Context, task store.AutomaticTa
|
||||
}, "\n"),
|
||||
Time: run.FinishedAt, Task: task, Run: run,
|
||||
}
|
||||
for _, channel := range []string{"telegram", "bark", "email", "pushplus", "webhook"} {
|
||||
for _, channel := range []string{"telegram", "bark", "email", "pushplus", "webhook", "wecom"} {
|
||||
setting, err := s.store.NotificationSetting(ctx, channel)
|
||||
if errors.Is(err, store.ErrNotFound) || (err == nil && !setting.Enabled) {
|
||||
continue
|
||||
@@ -89,6 +88,8 @@ func sendAutomaticTaskNotification(ctx context.Context, channel string, config m
|
||||
return sendPushplusTextNotification(ctx, config, message.Title, message.Text)
|
||||
case "webhook":
|
||||
return sendAutomaticTaskWebhook(ctx, config, message)
|
||||
case "wecom":
|
||||
return sendWecomNotification(ctx, config, wecomAutomaticTaskValues(message))
|
||||
default:
|
||||
return fmt.Errorf("unsupported notification channel %q", channel)
|
||||
}
|
||||
@@ -266,13 +267,13 @@ func sendEmailTextNotification(ctx context.Context, config map[string]any, subje
|
||||
return err
|
||||
}
|
||||
}
|
||||
from, err := mail.ParseAddress(configString(config, "from_address"))
|
||||
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 := mail.ParseAddress(item)
|
||||
address, err := parseMailAddress(item)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -290,12 +291,7 @@ func sendEmailTextNotification(ctx context.Context, config map[string]any, subje
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
email := strings.Join([]string{
|
||||
"Date: " + time.Now().UTC().Format(time.RFC1123Z), "From: " + from.String(),
|
||||
"To: " + joinMailAddresses(recipients), "Subject: " + mime.QEncoding.Encode("UTF-8", subject),
|
||||
"MIME-Version: 1.0", "Content-Type: text/plain; charset=UTF-8", "Content-Transfer-Encoding: 8bit", "", text, "",
|
||||
}, "\r\n")
|
||||
if _, err := io.WriteString(writer, email); err != nil {
|
||||
if err := writePlainTextMail(writer, from, recipients, subject, text); err != nil {
|
||||
_ = writer.Close()
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -37,6 +37,13 @@ type automaticTaskExecutionError struct {
|
||||
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
|
||||
@@ -50,6 +57,14 @@ func (s *Server) StartAutomaticTasks(ctx context.Context) {
|
||||
}
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -117,9 +132,14 @@ func (scheduler *automaticTaskScheduler) execute(run store.AutomaticTaskRun) {
|
||||
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)
|
||||
output, err = scheduler.server.executeAutomaticTask(operationContext, task, progress)
|
||||
cancel()
|
||||
if err == nil {
|
||||
break
|
||||
@@ -129,7 +149,10 @@ func (scheduler *automaticTaskScheduler) execute(run store.AutomaticTaskRun) {
|
||||
break
|
||||
}
|
||||
if attempt <= task.RetryCount {
|
||||
scheduler.server.logger.Warn("automatic task attempt failed", "task_id", task.ID, "device_id", task.DeviceID, "attempt", attempt, "error", err)
|
||||
// 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
|
||||
@@ -151,13 +174,35 @@ func (scheduler *automaticTaskScheduler) execute(run store.AutomaticTaskRun) {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) executeAutomaticTask(ctx context.Context, task store.AutomaticTask) (string, error) {
|
||||
config, entry, physicalID, err := s.ensureAutomaticTaskProfile(ctx, task)
|
||||
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
|
||||
}
|
||||
networkWasEnabled := config.NetworkEnabled
|
||||
if err := s.prepareAutomaticTaskEnvironment(ctx, &config, entry, physicalID, task); err != nil {
|
||||
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
|
||||
@@ -166,17 +211,22 @@ func (s *Server) executeAutomaticTask(ctx context.Context, task store.AutomaticT
|
||||
}
|
||||
switch task.TaskType {
|
||||
case "sms":
|
||||
return s.executeAutomaticSMS(ctx, task, payload)
|
||||
progress("正在发送短信")
|
||||
output, err = s.executeAutomaticSMS(ctx, task, payload)
|
||||
case "call":
|
||||
return s.executeAutomaticCall(ctx, task, payload)
|
||||
progress("正在发起通话")
|
||||
output, err = s.executeAutomaticCall(ctx, task, payload)
|
||||
case "public_ip":
|
||||
return s.executeAutomaticPublicIP(ctx, config, physicalID, task.ProfileICCID, networkWasEnabled)
|
||||
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) (store.Device, device.Device, string, error) {
|
||||
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)
|
||||
@@ -188,6 +238,7 @@ func (s *Server) ensureAutomaticTaskProfile(ctx context.Context, task store.Auto
|
||||
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)
|
||||
}
|
||||
@@ -209,9 +260,10 @@ func (s *Server) ensureAutomaticTaskProfile(ctx context.Context, task store.Auto
|
||||
return config, entry, physicalID, nil
|
||||
}
|
||||
|
||||
func (s *Server) prepareAutomaticTaskEnvironment(ctx context.Context, config *store.Device, entry device.Device, physicalID string, task store.AutomaticTask) error {
|
||||
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")
|
||||
}
|
||||
@@ -222,7 +274,19 @@ func (s *Server) prepareAutomaticTaskEnvironment(ctx context.Context, config *st
|
||||
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 {
|
||||
policy, policyErr := s.store.CardPolicy(ctx, iccid)
|
||||
if errors.Is(policyErr, store.ErrNotFound) {
|
||||
policy = defaultCardPolicy(iccid)
|
||||
policyErr = nil
|
||||
}
|
||||
if policyErr != nil {
|
||||
return policyErr
|
||||
}
|
||||
policy.NetworkEnabled = false
|
||||
policy.VoWiFiEnabled = true
|
||||
policy.AirplaneEnabled = true
|
||||
policy.Source = "automatic_task"
|
||||
if err := s.store.UpsertCardPolicy(ctx, policy); err != nil {
|
||||
return err
|
||||
}
|
||||
if s.vowifi == nil {
|
||||
@@ -253,17 +317,29 @@ func (s *Server) prepareAutomaticTaskEnvironment(ctx context.Context, config *st
|
||||
}
|
||||
}
|
||||
}
|
||||
progress("正在开启蜂窝无线并启用自动选网")
|
||||
config.VoWiFiEnabled = false
|
||||
config.NetworkEnabled = task.TaskType == "public_ip"
|
||||
if err := s.store.UpsertDevice(ctx, *config); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.store.UpsertCardPolicy(ctx, store.CardPolicy{ICCID: iccid, NetworkEnabled: config.NetworkEnabled, VoWiFiEnabled: false, AirplaneEnabled: false, APN: config.APN, IPVersion: "IPV4V6", Source: "automatic_task"}); err != nil {
|
||||
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, device.NetworkRequest{Enabled: false, APN: config.APN, IPVersion: "IPV4V6", Backend: config.DeviceBackend}); err != nil {
|
||||
s.logger.Warn("automatic task could not stop unused cellular data", "device_id", config.ID, "error", err)
|
||||
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 {
|
||||
@@ -275,6 +351,7 @@ func (s *Server) prepareAutomaticTaskEnvironment(ctx context.Context, config *st
|
||||
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
|
||||
}
|
||||
@@ -282,8 +359,8 @@ func (s *Server) prepareAutomaticTaskEnvironment(ctx context.Context, config *st
|
||||
if !s.developerActive(ctx) {
|
||||
return errors.New("roaming public IP tasks require developer mode")
|
||||
}
|
||||
if _, err := s.devices.SetNetwork(ctx, physicalID, device.NetworkRequest{Enabled: true, APN: config.APN, IPVersion: "IPV4V6", Backend: config.DeviceBackend}); err != nil {
|
||||
s.rollbackAutomaticNetwork(config.ID, physicalID, iccid, *config)
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -407,10 +484,7 @@ func (s *Server) executeAutomaticCall(ctx context.Context, task store.AutomaticT
|
||||
return fmt.Sprintf("已拨打 %s,将在 %d 秒后自动挂断", payload.Phone, payload.DurationSeconds), nil
|
||||
}
|
||||
|
||||
func (s *Server) executeAutomaticPublicIP(ctx context.Context, config store.Device, physicalID, iccid string, networkWasEnabled bool) (string, error) {
|
||||
if !networkWasEnabled {
|
||||
defer s.rollbackAutomaticNetwork(config.ID, physicalID, iccid, config)
|
||||
}
|
||||
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")
|
||||
}
|
||||
@@ -422,19 +496,97 @@ func (s *Server) executeAutomaticPublicIP(ctx context.Context, config store.Devi
|
||||
return strings.TrimSpace(fmt.Sprintf("公网 IP %s · %s %s", info.IP, info.CountryCode, info.Region)), nil
|
||||
}
|
||||
|
||||
func (s *Server) rollbackAutomaticNetwork(deviceID, physicalID, iccid string, config store.Device) {
|
||||
cleanupContext, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
func (s *Server) restoreAutomaticTaskEnvironment(physicalID string, snapshot automaticTaskEnvironmentSnapshot) error {
|
||||
cleanupContext, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
if _, err := s.devices.SetNetwork(cleanupContext, physicalID, device.NetworkRequest{Enabled: false, APN: config.APN, IPVersion: "IPV4V6", Backend: config.DeviceBackend}); err != nil {
|
||||
s.logger.Warn("stop one-shot automatic roaming data", "device_id", deviceID, "error", err)
|
||||
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))
|
||||
}
|
||||
config.NetworkEnabled = false
|
||||
if err := s.store.UpsertDevice(cleanupContext, config); err != nil {
|
||||
s.logger.Warn("restore automatic roaming data setting", "device_id", deviceID, "error", err)
|
||||
restoreErrors = append(restoreErrors, fmt.Errorf("persist device policy: %w", err))
|
||||
}
|
||||
if err := s.store.UpsertCardPolicy(cleanupContext, store.CardPolicy{ICCID: iccid, NetworkEnabled: false, VoWiFiEnabled: false, AirplaneEnabled: false, APN: config.APN, IPVersion: "IPV4V6", Source: "automatic_task"}); err != nil {
|
||||
s.logger.Warn("restore automatic roaming card policy", "device_id", deviceID, "error", 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 {
|
||||
@@ -456,6 +608,10 @@ func (s *Server) routeAutomaticTasksAPI(w http.ResponseWriter, r *http.Request,
|
||||
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")
|
||||
@@ -481,12 +637,7 @@ func (s *Server) handleAutomaticTasks(w http.ResponseWriter, r *http.Request) {
|
||||
s.writeStoreError(w, err)
|
||||
return
|
||||
}
|
||||
runs, err := s.store.ListAutomaticTaskRuns(r.Context(), 100)
|
||||
if err != nil {
|
||||
s.writeStoreError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"tasks": tasks, "runs": runs}})
|
||||
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 {
|
||||
@@ -531,6 +682,21 @@ func (s *Server) handleAutomaticTask(w http.ResponseWriter, r *http.Request, id
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -262,11 +263,15 @@ func (s *Server) handleDevices(w http.ResponseWriter, r *http.Request) bool {
|
||||
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)
|
||||
_, policyErr := s.store.CardPolicy(r.Context(), iccid)
|
||||
if errors.Is(policyErr, store.ErrNotFound) {
|
||||
policyErr = s.store.UpsertCardPolicy(r.Context(), store.CardPolicy{
|
||||
ICCID: iccid, VoWiFiEnabled: true, AirplaneEnabled: true,
|
||||
IPVersion: "IPV4V6", Source: "default",
|
||||
})
|
||||
}
|
||||
if policyErr != nil {
|
||||
s.writeStoreError(w, policyErr)
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -552,6 +557,11 @@ func (s *Server) handleDevicePath(
|
||||
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
|
||||
@@ -1127,6 +1137,65 @@ func (s *Server) handleFlightMode(w http.ResponseWriter, r *http.Request, config
|
||||
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,
|
||||
@@ -1165,32 +1234,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", Backend: config.DeviceBackend,
|
||||
})
|
||||
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", Backend: config.DeviceBackend,
|
||||
})
|
||||
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,
|
||||
@@ -1253,8 +1365,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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,24 @@ 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
|
||||
@@ -286,6 +304,13 @@ func TestHandleESIMShapes(t *testing.T) {
|
||||
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()
|
||||
@@ -301,6 +326,14 @@ func TestHandleESIMShapes(t *testing.T) {
|
||||
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()
|
||||
@@ -340,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(),
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -15,6 +17,11 @@ 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, configuredIDs ...string) bool {
|
||||
configuredID := physicalID
|
||||
@@ -53,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":
|
||||
@@ -90,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).
|
||||
@@ -366,37 +420,68 @@ func (s *Server) handleEsimSwitch(w http.ResponseWriter, r *http.Request, config
|
||||
s.writeDeviceError(w, err)
|
||||
return
|
||||
}
|
||||
if err := s.store.UpsertCardPolicy(r.Context(), store.CardPolicy{
|
||||
ICCID: iccid, VoWiFiEnabled: true, AirplaneEnabled: true,
|
||||
IPVersion: "IPV4V6", Source: "default",
|
||||
}); err != nil {
|
||||
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 = true
|
||||
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)
|
||||
switch {
|
||||
case stateErr == nil && state.Enabled:
|
||||
_, err = s.vowifi.RequestReconnect(configuredID)
|
||||
default:
|
||||
_, err = s.vowifi.RequestEnabled(configuredID, true)
|
||||
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 in safe airplane mode but VoWiFi start was not queued", "device_id", configuredID, "iccid", iccid, "error", err)
|
||||
s.logger.Warn("profile switched but saved VoWiFi state was not queued", "device_id", configuredID, "iccid", iccid, "enabled", policy.VoWiFiEnabled, "error", err)
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"status": "switched", "iccid": iccid, "verified": true}})
|
||||
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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -414,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 {
|
||||
@@ -559,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()
|
||||
|
||||
@@ -24,6 +24,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"vocat/internal/device"
|
||||
"vocat/internal/store"
|
||||
)
|
||||
|
||||
@@ -39,6 +40,7 @@ var notificationChannels = []string{
|
||||
"webhook",
|
||||
"bark",
|
||||
"pushplus",
|
||||
"wecom",
|
||||
}
|
||||
|
||||
var notificationFields = map[string]map[string]string{
|
||||
@@ -60,6 +62,9 @@ var notificationFields = map[string]map[string]string{
|
||||
"pushplus": {
|
||||
"token": "string", "topic": "string", "channel": "string",
|
||||
},
|
||||
"wecom": {
|
||||
"urls": "strings", "payload_template": "string",
|
||||
},
|
||||
}
|
||||
|
||||
// routeSettingsAPI is intentionally independent of the main router so it can
|
||||
@@ -99,6 +104,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,10 +291,15 @@ 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)
|
||||
}
|
||||
}
|
||||
if channel == "wecom" && name == "payload_template" && value != "" {
|
||||
if _, err := renderWecomPayload(value, wecomTestValues(time.Unix(0, 0))); err != nil {
|
||||
return fmt.Errorf("%s is not a valid JSON template: %w", field, err)
|
||||
}
|
||||
}
|
||||
case "integer":
|
||||
var value int
|
||||
if err := json.Unmarshal(raw, &value); err != nil {
|
||||
@@ -315,6 +333,9 @@ func validateNotificationField(
|
||||
return fmt.Errorf("%s contains an invalid value", field)
|
||||
}
|
||||
if name == "urls" {
|
||||
if channel == "wecom" && value == store.SecretMask {
|
||||
continue
|
||||
}
|
||||
if _, err := parseOutboundURL(value, false); err != nil {
|
||||
return fmt.Errorf("%s contains an invalid HTTP URL", field)
|
||||
}
|
||||
@@ -366,7 +387,7 @@ func (s *Server) handleNotificationTest(
|
||||
writeError(w, http.StatusNotFound, "not_found", "notification channel was not found")
|
||||
return
|
||||
}
|
||||
if channel != "webhook" && channel != "telegram" && channel != "email" && channel != "bark" {
|
||||
if channel != "webhook" && channel != "telegram" && channel != "email" && channel != "bark" && channel != "wecom" {
|
||||
writeError(
|
||||
w,
|
||||
http.StatusNotImplemented,
|
||||
@@ -417,6 +438,8 @@ func (s *Server) handleNotificationTest(
|
||||
err = sendEmailNotificationTest(r.Context(), resolved)
|
||||
case "bark":
|
||||
err = sendBarkNotificationTest(r.Context(), resolved)
|
||||
case "wecom":
|
||||
err = sendWecomNotificationTest(r.Context(), resolved)
|
||||
}
|
||||
if err != nil {
|
||||
redacted := store.RedactText(err.Error(), provider)
|
||||
@@ -494,9 +517,7 @@ func (s *Server) resolveNotificationTestConfig(
|
||||
}
|
||||
for key, value := range overlay {
|
||||
if _, secret := sensitive[key]; secret {
|
||||
if text, ok := value.(string); !ok || text == "" || text == store.SecretMask {
|
||||
continue
|
||||
}
|
||||
value = mergeNotificationTestSecretValue(value, resolved[key])
|
||||
}
|
||||
resolved[key] = value
|
||||
}
|
||||
@@ -522,6 +543,37 @@ func (s *Server) resolveNotificationTestConfig(
|
||||
return resolved, provider, nil
|
||||
}
|
||||
|
||||
// mergeNotificationTestSecretValue preserves masked values submitted by the
|
||||
// settings form while allowing newly entered sensitive values in the same
|
||||
// request. WeCom URLs are a sensitive list, unlike the string-based secrets
|
||||
// used by the other notification channels.
|
||||
func mergeNotificationTestSecretValue(incoming, existing any) any {
|
||||
if incoming == nil {
|
||||
return existing
|
||||
}
|
||||
switch next := incoming.(type) {
|
||||
case string:
|
||||
if next == "" || next == store.SecretMask {
|
||||
return existing
|
||||
}
|
||||
case []any:
|
||||
previous, ok := existing.([]any)
|
||||
if !ok {
|
||||
return incoming
|
||||
}
|
||||
merged := make([]any, len(next))
|
||||
for index, value := range next {
|
||||
if index < len(previous) {
|
||||
merged[index] = mergeNotificationTestSecretValue(value, previous[index])
|
||||
} else {
|
||||
merged[index] = value
|
||||
}
|
||||
}
|
||||
return merged
|
||||
}
|
||||
return incoming
|
||||
}
|
||||
|
||||
func validateNotificationTestConfig(channel string, config map[string]any) error {
|
||||
switch channel {
|
||||
case "webhook":
|
||||
@@ -540,6 +592,8 @@ func validateNotificationTestConfig(channel string, config map[string]any) error
|
||||
if len(urls) > 8 {
|
||||
return errors.New("bark test is limited to 8 URLs")
|
||||
}
|
||||
case "wecom":
|
||||
return validateWecomNotificationConfig(config)
|
||||
case "telegram":
|
||||
token := configString(config, "bot_token")
|
||||
if token == "" || token == store.SecretMask {
|
||||
@@ -763,13 +817,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 +841,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 +860,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(
|
||||
@@ -1192,13 +1257,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,
|
||||
VoWiFiEnabled: true,
|
||||
AirplaneEnabled: true,
|
||||
IPVersion: "IPV4V6",
|
||||
Source: "default",
|
||||
}
|
||||
policy = defaultCardPolicy(iccid)
|
||||
} else if err != nil {
|
||||
s.writeStoreError(w, err)
|
||||
return
|
||||
@@ -1213,65 +1272,87 @@ 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"`
|
||||
CustomPhoneNumber *string `json:"custom_phone_number"`
|
||||
}
|
||||
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 && request.CustomPhoneNumber == 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.CustomPhoneNumber != nil {
|
||||
phoneNumber, phoneErr := normalizeCustomPhoneNumber(*request.CustomPhoneNumber)
|
||||
if phoneErr != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_card_policy", phoneErr.Error())
|
||||
return
|
||||
}
|
||||
policy.CustomPhoneNumber = phoneNumber
|
||||
}
|
||||
if request.VoWiFiEnabled != nil {
|
||||
policy.VoWiFiEnabled = *request.VoWiFiEnabled
|
||||
}
|
||||
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 *request.VoWiFiEnabled {
|
||||
*request.AirplaneEnabled = true
|
||||
if policy.VoWiFiEnabled {
|
||||
policy.AirplaneEnabled = true
|
||||
policy.NetworkEnabled = false
|
||||
}
|
||||
policy := store.CardPolicy{
|
||||
ICCID: iccid,
|
||||
NetworkEnabled: false,
|
||||
VoWiFiEnabled: *request.VoWiFiEnabled,
|
||||
AirplaneEnabled: *request.AirplaneEnabled,
|
||||
APN: request.APN,
|
||||
IPVersion: request.IPVersion,
|
||||
Source: "manual",
|
||||
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
|
||||
@@ -1283,6 +1364,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
|
||||
@@ -1295,15 +1629,42 @@ func validICCID(value string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func normalizeCustomPhoneNumber(value string) (string, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return "", nil
|
||||
}
|
||||
var normalized strings.Builder
|
||||
digitCount := 0
|
||||
for index, character := range value {
|
||||
switch {
|
||||
case character >= '0' && character <= '9':
|
||||
normalized.WriteRune(character)
|
||||
digitCount++
|
||||
case character == '+' && index == 0:
|
||||
normalized.WriteRune(character)
|
||||
case character == ' ' || character == '-' || character == '(' || character == ')':
|
||||
// Common visual separators are accepted but not persisted.
|
||||
default:
|
||||
return "", errors.New("custom phone number may contain only digits, a leading plus sign, spaces, parentheses, or hyphens")
|
||||
}
|
||||
}
|
||||
if digitCount < 3 || digitCount > 20 {
|
||||
return "", errors.New("custom phone number must contain between 3 and 20 digits")
|
||||
}
|
||||
return normalized.String(), nil
|
||||
}
|
||||
|
||||
func cardPolicyResponse(policy store.CardPolicy) map[string]any {
|
||||
response := map[string]any{
|
||||
"iccid": policy.ICCID,
|
||||
"network_enabled": false,
|
||||
"vowifi_enabled": policy.VoWiFiEnabled,
|
||||
"airplane_enabled": policy.AirplaneEnabled,
|
||||
"apn": policy.APN,
|
||||
"ip_version": policy.IPVersion,
|
||||
"source": policy.Source,
|
||||
"iccid": policy.ICCID,
|
||||
"network_enabled": false,
|
||||
"vowifi_enabled": policy.VoWiFiEnabled,
|
||||
"airplane_enabled": policy.AirplaneEnabled,
|
||||
"apn": policy.APN,
|
||||
"ip_version": policy.IPVersion,
|
||||
"custom_phone_number": policy.CustomPhoneNumber,
|
||||
"source": policy.Source,
|
||||
}
|
||||
if !policy.CreatedAt.IsZero() {
|
||||
response["created_at"] = policy.CreatedAt
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
@@ -134,6 +135,103 @@ func TestNotificationSettingsAlwaysReturnsFiveChannelsAndPreservesSecrets(t *tes
|
||||
}
|
||||
}
|
||||
|
||||
func TestWecomNotificationSettingsPreserveWebhookURLs(t *testing.T) {
|
||||
test := newSettingsAPITest(t)
|
||||
webhookURL := "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=wecom-secret"
|
||||
template := `{"msgtype":"text","text":{"content":{{message}}}}`
|
||||
first, err := json.Marshal(map[string]any{
|
||||
"wecom": map[string]any{
|
||||
"enabled": true, "urls": []string{webhookURL}, "payload_template": template,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
recorder := test.request(t, http.MethodPut, "/api/settings/notifications", string(first))
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("first PUT status = %d, body = %s", recorder.Code, recorder.Body)
|
||||
}
|
||||
if bytes.Contains(recorder.Body.Bytes(), []byte("wecom-secret")) {
|
||||
t.Fatalf("PUT response leaked webhook URL: %s", recorder.Body)
|
||||
}
|
||||
response := decodeSettingsResponse(t, recorder)
|
||||
wecom := response["data"].(map[string]any)["wecom"].(map[string]any)
|
||||
urls, ok := wecom["urls"].([]any)
|
||||
if !ok || len(urls) != 1 || urls[0] != store.SecretMask {
|
||||
t.Fatalf("redacted WeCom URLs = %#v", wecom["urls"])
|
||||
}
|
||||
|
||||
second, err := json.Marshal(map[string]any{
|
||||
"wecom": map[string]any{
|
||||
"enabled": true, "urls": []string{store.SecretMask}, "payload_template": template,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
recorder = test.request(t, http.MethodPut, "/api/settings/notifications", string(second))
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("masked PUT status = %d, body = %s", recorder.Code, recorder.Body)
|
||||
}
|
||||
stored, err := test.database.NotificationSetting(context.Background(), "wecom")
|
||||
if err != nil || !bytes.Contains(stored.Config, []byte("wecom-secret")) {
|
||||
t.Fatalf("stored WeCom config = %s, err = %v", stored.Config, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveWecomNotificationTestConfigAcceptsUnsavedWebhookURLs(t *testing.T) {
|
||||
test := newSettingsAPITest(t)
|
||||
raw, err := json.Marshal(map[string]any{
|
||||
"urls": []string{"https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=unsaved"},
|
||||
"payload_template": `{"msgtype":"text","text":{"content":{{message}}}}`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resolved, _, err := test.server.resolveNotificationTestConfig(context.Background(), "wecom", raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
urls, ok := resolved["urls"].([]any)
|
||||
if !ok || len(urls) != 1 || urls[0] != "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=unsaved" {
|
||||
t.Fatalf("resolved URLs = %#v", resolved["urls"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveWecomNotificationTestConfigMergesMaskedAndUnsavedWebhookURLs(t *testing.T) {
|
||||
test := newSettingsAPITest(t)
|
||||
storedURL := "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=stored"
|
||||
unsavedURL := "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=unsaved"
|
||||
storedConfig, err := json.Marshal(map[string]any{
|
||||
"urls": []string{storedURL},
|
||||
"payload_template": `{"msgtype":"text","text":{"content":{{message}}}}`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := test.database.UpsertNotificationSetting(context.Background(), store.NotificationSetting{
|
||||
Channel: "wecom",
|
||||
Config: storedConfig,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err := json.Marshal(map[string]any{
|
||||
"urls": []string{store.SecretMask, unsavedURL},
|
||||
"payload_template": `{"msgtype":"text","text":{"content":{{message}}}}`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resolved, _, err := test.server.resolveNotificationTestConfig(context.Background(), "wecom", raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
urls, ok := resolved["urls"].([]any)
|
||||
if !ok || len(urls) != 2 || urls[0] != storedURL || urls[1] != unsavedURL {
|
||||
t.Fatalf("resolved URLs = %#v", resolved["urls"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationSettingsRejectsUnknownAndMalformedInput(t *testing.T) {
|
||||
test := newSettingsAPITest(t)
|
||||
cases := []struct {
|
||||
@@ -425,10 +523,35 @@ func TestCardPolicyDefaultValidationAndPersistence(t *testing.T) {
|
||||
policy := response["data"].(map[string]any)
|
||||
if policy["iccid"] != iccid || policy["source"] != "default" ||
|
||||
policy["ip_version"] != "IPV4V6" || policy["vowifi_enabled"] != true ||
|
||||
policy["airplane_enabled"] != true {
|
||||
policy["airplane_enabled"] != true || policy["custom_phone_number"] != "" {
|
||||
t.Fatalf("default policy = %#v", policy)
|
||||
}
|
||||
|
||||
recorder = test.request(
|
||||
t,
|
||||
http.MethodPut,
|
||||
"/api/cards/"+iccid+"/policy",
|
||||
`{"custom_phone_number":"+86 (138) 0013-8000"}`,
|
||||
)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("custom phone policy status = %d, body = %s", recorder.Code, recorder.Body)
|
||||
}
|
||||
response = decodeSettingsResponse(t, recorder)
|
||||
policy = response["data"].(map[string]any)
|
||||
if policy["custom_phone_number"] != "+8613800138000" {
|
||||
t.Fatalf("normalized custom phone number = %#v", policy)
|
||||
}
|
||||
|
||||
recorder = test.request(
|
||||
t,
|
||||
http.MethodPut,
|
||||
"/api/cards/"+iccid+"/policy",
|
||||
`{"custom_phone_number":"+86-CALL-ME"}`,
|
||||
)
|
||||
if recorder.Code != http.StatusBadRequest {
|
||||
t.Fatalf("invalid custom phone status = %d, body = %s", recorder.Code, recorder.Body)
|
||||
}
|
||||
|
||||
recorder = test.request(
|
||||
t,
|
||||
http.MethodPut,
|
||||
@@ -455,10 +578,116 @@ func TestCardPolicyDefaultValidationAndPersistence(t *testing.T) {
|
||||
t.Fatalf("saved policy = %#v", policy)
|
||||
}
|
||||
stored, err := test.database.CardPolicy(context.Background(), iccid)
|
||||
if err != nil || !stored.VoWiFiEnabled || !stored.AirplaneEnabled || stored.APN != "ims" {
|
||||
if err != nil || !stored.VoWiFiEnabled || !stored.AirplaneEnabled || stored.APN != "ims" || stored.CustomPhoneNumber != "+8613800138000" {
|
||||
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" || stored.CustomPhoneNumber != "+8613800138000" {
|
||||
t.Fatalf("partially updated policy = %+v, %v", stored, err)
|
||||
}
|
||||
|
||||
// Clearing the override restores system-number display without affecting the
|
||||
// rest of this ICCID's policy.
|
||||
recorder = test.request(t, http.MethodPut, "/api/cards/"+iccid+"/policy", `{"custom_phone_number":""}`)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("clear custom phone status = %d, body = %s", recorder.Code, recorder.Body)
|
||||
}
|
||||
stored, err = test.database.CardPolicy(context.Background(), iccid)
|
||||
if err != nil || stored.CustomPhoneNumber != "" || stored.APN != "ims" {
|
||||
t.Fatalf("cleared custom phone 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)
|
||||
@@ -583,3 +812,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/mail"
|
||||
@@ -25,7 +24,7 @@ import (
|
||||
|
||||
const smsNotificationPollInterval = 2 * time.Second
|
||||
|
||||
var smsOnlyNotificationChannels = []string{"bark", "email", "pushplus", "webhook"}
|
||||
var smsOnlyNotificationChannels = []string{"bark", "email", "pushplus", "webhook", "wecom"}
|
||||
|
||||
type smsNotification struct {
|
||||
DeviceID string
|
||||
@@ -144,7 +143,7 @@ func (s *Server) smsNotificationConfig(ctx context.Context, channel string) (map
|
||||
|
||||
func validateSMSNotificationConfig(channel string, config map[string]any) error {
|
||||
switch channel {
|
||||
case "bark", "email", "webhook":
|
||||
case "bark", "email", "webhook", "wecom":
|
||||
if err := validateNotificationTestConfig(channel, config); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -203,6 +202,8 @@ func sendSMSNotification(ctx context.Context, channel string, config map[string]
|
||||
return sendPushplusSMSNotification(ctx, config, message)
|
||||
case "webhook":
|
||||
return sendWebhookSMSNotification(ctx, config, message)
|
||||
case "wecom":
|
||||
return sendWecomNotification(ctx, config, wecomSMSValues(message))
|
||||
default:
|
||||
return fmt.Errorf("unsupported SMS notification channel %q", channel)
|
||||
}
|
||||
@@ -402,13 +403,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 +427,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)
|
||||
}
|
||||
|
||||
@@ -36,12 +36,46 @@ func TestRenderSMSWebhookTemplate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWecomSMSValuesIncludeRenderedSMSFields(t *testing.T) {
|
||||
location := time.FixedZone("UTC+8", 8*60*60)
|
||||
message := smsNotification{
|
||||
DeviceID: "device-1", DeviceName: "客厅", DeviceLabel: "EC20",
|
||||
Number: "+447386", Time: time.Date(2026, 8, 8, 17, 25, 35, 0, location), Content: "hello",
|
||||
}
|
||||
values := wecomSMSValues(message)
|
||||
if values["event"] != "sms.received" || values["title"] != "收到新短信" || values["message"] != message.Text() {
|
||||
t.Fatalf("common values = %#v", values)
|
||||
}
|
||||
wantLocalTime := message.Time.Local().Format("2006-01-02 15:04:05")
|
||||
if values["content"] != "hello" || values["number"] != "+447386" || values["device_label"] != "EC20" || values["time"] != wantLocalTime {
|
||||
t.Fatalf("SMS values = %#v", values)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWecomAutomaticTaskValuesLeaveSMSFieldsEmpty(t *testing.T) {
|
||||
values := wecomAutomaticTaskValues(automaticTaskNotification{
|
||||
Title: "自动任务执行成功", Text: "任务已完成", Time: time.Unix(1_700_000_000, 0),
|
||||
})
|
||||
if values["event"] != "automatic_task.completed" || values["title"] != "自动任务执行成功" || values["message"] != "任务已完成" {
|
||||
t.Fatalf("common values = %#v", values)
|
||||
}
|
||||
for _, name := range []string{"content", "number", "device_id", "device_name", "device_label", "time"} {
|
||||
if values[name] != "" {
|
||||
t.Fatalf("%s = %q, want empty", name, values[name])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSMSNotificationConfig(t *testing.T) {
|
||||
valid := map[string]map[string]any{
|
||||
"bark": {"urls": []any{"https://api.day.app/key"}},
|
||||
"email": {"smtp_host": "smtp.example.com", "from_address": "[email protected]", "to_addresses": []any{"[email protected]"}},
|
||||
"pushplus": {"token": "secret"},
|
||||
"webhook": {"urls": []any{"https://example.com/hook"}},
|
||||
"wecom": {
|
||||
"urls": []any{"https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=secret"},
|
||||
"payload_template": `{"msgtype":"text","text":{"content":{{message}}}}`,
|
||||
},
|
||||
}
|
||||
for channel, config := range valid {
|
||||
if err := validateSMSNotificationConfig(channel, config); err != nil {
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var wecomTemplateVariableNames = []string{
|
||||
"event",
|
||||
"title",
|
||||
"message",
|
||||
"timestamp",
|
||||
"content",
|
||||
"number",
|
||||
"device_id",
|
||||
"device_name",
|
||||
"device_label",
|
||||
"time",
|
||||
}
|
||||
|
||||
type wecomTemplateValues map[string]string
|
||||
|
||||
func renderWecomPayload(template string, values wecomTemplateValues) ([]byte, error) {
|
||||
for _, name := range wecomTemplateVariableNames {
|
||||
encoded, err := json.Marshal(values[name])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode WeCom template value %q: %w", name, err)
|
||||
}
|
||||
template = strings.ReplaceAll(template, "{{"+name+"}}", string(encoded))
|
||||
}
|
||||
if strings.Contains(template, "{{") {
|
||||
return nil, errors.New("wecom.payload_template contains an unsupported variable")
|
||||
}
|
||||
|
||||
var payload map[string]json.RawMessage
|
||||
if err := json.Unmarshal([]byte(template), &payload); err != nil || len(payload) == 0 {
|
||||
return nil, errors.New("wecom.payload_template must render to a non-empty JSON object")
|
||||
}
|
||||
return []byte(template), nil
|
||||
}
|
||||
|
||||
func validateWecomResponse(status int, body []byte) error {
|
||||
var result struct {
|
||||
ErrCode *int `json:"errcode"`
|
||||
}
|
||||
if status < http.StatusOK || status >= http.StatusMultipleChoices ||
|
||||
json.Unmarshal(body, &result) != nil || result.ErrCode == nil || *result.ErrCode != 0 {
|
||||
return fmt.Errorf("%w: WeCom response was not successful", errProviderRejected)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func wecomTestValues(now time.Time) wecomTemplateValues {
|
||||
return wecomTemplateValues{
|
||||
"event": "test", "title": "vocat", "message": "vocat notification test",
|
||||
"timestamp": now.UTC().Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
|
||||
func wecomSMSValues(message smsNotification) wecomTemplateValues {
|
||||
return wecomTemplateValues{
|
||||
"event": "sms.received",
|
||||
"title": "收到新短信",
|
||||
"message": message.Text(),
|
||||
"timestamp": message.Time.UTC().Format(time.RFC3339),
|
||||
"content": message.Content,
|
||||
"number": message.Number,
|
||||
"device_id": message.DeviceID,
|
||||
"device_name": message.DeviceName,
|
||||
"device_label": message.DeviceLabel,
|
||||
"time": message.Time.Local().Format("2006-01-02 15:04:05"),
|
||||
}
|
||||
}
|
||||
|
||||
func wecomAutomaticTaskValues(message automaticTaskNotification) wecomTemplateValues {
|
||||
return wecomTemplateValues{
|
||||
"event": "automatic_task.completed",
|
||||
"title": message.Title,
|
||||
"message": message.Text,
|
||||
"timestamp": message.Time.UTC().Format(time.RFC3339),
|
||||
"content": "",
|
||||
"number": "",
|
||||
"device_id": "",
|
||||
"device_name": "",
|
||||
"device_label": "",
|
||||
"time": "",
|
||||
}
|
||||
}
|
||||
|
||||
func validateWecomNotificationConfig(config map[string]any) error {
|
||||
urls := configStrings(config, "urls")
|
||||
if len(urls) == 0 {
|
||||
return errors.New("wecom.urls must contain at least one URL")
|
||||
}
|
||||
if len(urls) > 8 {
|
||||
return errors.New("wecom.urls cannot contain more than 8 URLs")
|
||||
}
|
||||
template := configString(config, "payload_template")
|
||||
if template == "" {
|
||||
return errors.New("wecom.payload_template is required")
|
||||
}
|
||||
_, err := renderWecomPayload(template, wecomTestValues(time.Unix(0, 0)))
|
||||
return err
|
||||
}
|
||||
|
||||
func sendWecomNotification(ctx context.Context, config map[string]any, values wecomTemplateValues) error {
|
||||
payload, err := renderWecomPayload(configString(config, "payload_template"), values)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
client, err := restrictedHTTPClient(ctx, 8*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 fmt.Errorf("create WeCom notification request: %w", err)
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json; charset=utf-8")
|
||||
request.Header.Set("User-Agent", "vocat-wecom-notification/1")
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("send WeCom notification: %w", err)
|
||||
}
|
||||
body, readErr := io.ReadAll(io.LimitReader(response.Body, 64<<10))
|
||||
closeErr := response.Body.Close()
|
||||
if readErr != nil {
|
||||
return fmt.Errorf("read WeCom response: %w", readErr)
|
||||
}
|
||||
if closeErr != nil {
|
||||
return fmt.Errorf("close WeCom response: %w", closeErr)
|
||||
}
|
||||
if err := validateWecomResponse(response.StatusCode, body); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sendWecomNotificationTest(ctx context.Context, config map[string]any) error {
|
||||
return sendWecomNotification(ctx, config, wecomTestValues(time.Now()))
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRenderWecomPayloadEscapesTemplateValues(t *testing.T) {
|
||||
payload, err := renderWecomPayload(
|
||||
`{"msgtype":"text","text":{"content":{{message}},"number":{{number}}}}`,
|
||||
wecomTemplateValues{
|
||||
"message": "quote: \"\nline",
|
||||
"number": "+447386",
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, want := string(payload), `{"msgtype":"text","text":{"content":"quote: \"\nline","number":"+447386"}}`; got != want {
|
||||
t.Fatalf("payload = %s, want %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderWecomPayloadRejectsInvalidTemplate(t *testing.T) {
|
||||
for _, template := range []string{
|
||||
`{"text":{{unknown}}}`,
|
||||
`[]`,
|
||||
`{"msgtype":"text"`,
|
||||
} {
|
||||
t.Run(template, func(t *testing.T) {
|
||||
if _, err := renderWecomPayload(template, wecomTemplateValues{}); err == nil {
|
||||
t.Fatalf("template %q was accepted", template)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateWecomResponse(t *testing.T) {
|
||||
if err := validateWecomResponse(http.StatusOK, []byte(`{"errcode":0,"errmsg":"ok"}`)); err != nil {
|
||||
t.Fatalf("successful response = %v", err)
|
||||
}
|
||||
for _, response := range []struct {
|
||||
status int
|
||||
body string
|
||||
}{
|
||||
{http.StatusBadGateway, `{"errcode":0}`},
|
||||
{http.StatusOK, `{"errcode":40058,"errmsg":"invalid"}`},
|
||||
{http.StatusOK, `{}`},
|
||||
{http.StatusOK, `not-json`},
|
||||
} {
|
||||
if err := validateWecomResponse(response.status, []byte(response.body)); !errors.Is(err, errProviderRejected) {
|
||||
t.Fatalf("validateWecomResponse(%d, %s) = %v", response.status, response.body, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -179,16 +179,88 @@ func (s *Store) UpdateAutomaticTaskRun(ctx context.Context, run AutomaticTaskRun
|
||||
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, `SELECT id, task_id, device_id, scheduled_at,
|
||||
started_at, finished_at, status, attempts, output, error, created_at, updated_at
|
||||
FROM automatic_task_runs ORDER BY id DESC LIMIT ?`, limit)
|
||||
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() {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -70,3 +71,112 @@ func TestDeletingAutomaticTaskRemovesRunHistory(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,48 @@ 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")
|
||||
@@ -603,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 {
|
||||
@@ -617,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)
|
||||
}
|
||||
}
|
||||
@@ -694,6 +737,43 @@ func TestNotificationAndAppSecretPreservation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationArraySecretPreservation(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
database := openTestStore(t, ":memory:")
|
||||
originalURL := "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=first-secret"
|
||||
if err := database.UpsertNotificationSetting(ctx, NotificationSetting{
|
||||
Channel: "wecom", Enabled: true,
|
||||
Config: json.RawMessage(`{"urls":["` + originalURL + `"]}`),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
setting, err := database.NotificationSetting(ctx, "wecom")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var redacted map[string]any
|
||||
if err := json.Unmarshal(setting.Redacted().Config, &redacted); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
urls, ok := redacted["urls"].([]any)
|
||||
if !ok || len(urls) != 1 || urls[0] != SecretMask {
|
||||
t.Fatalf("redacted URLs = %#v", redacted["urls"])
|
||||
}
|
||||
if err := database.UpsertNotificationSetting(ctx, NotificationSetting{
|
||||
Channel: "wecom", Enabled: true,
|
||||
Config: json.RawMessage(`{"urls":["` + SecretMask + `","https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=second-secret"]}`),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
setting, err = database.NotificationSetting(ctx, "wecom")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Contains(setting.Config, []byte(originalURL)) || !bytes.Contains(setting.Config, []byte("second-secret")) {
|
||||
t.Fatalf("stored URLs = %s", setting.Config)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventsPoliciesAndTraffic(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
database := openTestStore(t, ":memory:")
|
||||
@@ -741,7 +821,7 @@ func TestEventsPoliciesAndTraffic(t *testing.T) {
|
||||
|
||||
if err := database.UpsertCardPolicy(ctx, CardPolicy{
|
||||
ICCID: "89860001", NetworkEnabled: true, VoWiFiEnabled: true,
|
||||
APN: "ims", IPVersion: "ipv4v6",
|
||||
APN: "ims", IPVersion: "ipv4v6", CustomPhoneNumber: "+8613800138000",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -751,7 +831,7 @@ func TestEventsPoliciesAndTraffic(t *testing.T) {
|
||||
t.Fatalf("RF-safe VoWiFi policy was rejected: %v", err)
|
||||
}
|
||||
policy, err := database.CardPolicy(ctx, "89860001")
|
||||
if err != nil || !policy.VoWiFiEnabled {
|
||||
if err != nil || !policy.VoWiFiEnabled || policy.CustomPhoneNumber != "+8613800138000" {
|
||||
t.Fatalf("CardPolicy() = %+v, %v", policy, err)
|
||||
}
|
||||
safePolicy, err := database.CardPolicy(ctx, "89860002")
|
||||
|
||||
@@ -186,6 +186,80 @@ func migrationStatements(version int) []string {
|
||||
`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'))`,
|
||||
}
|
||||
case 15:
|
||||
return []string{
|
||||
`ALTER TABLE card_policies
|
||||
ADD COLUMN custom_phone_number TEXT NOT NULL DEFAULT ''`,
|
||||
}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -302,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
|
||||
@@ -338,12 +339,9 @@ func (value NotificationSetting) SensitiveValues() []string {
|
||||
}
|
||||
values := make([]string, 0, len(value.SensitiveFields))
|
||||
for _, field := range value.SensitiveFields {
|
||||
if secret, ok := getJSONPath(document, field).(string); ok &&
|
||||
secret != "" && secret != SecretMask {
|
||||
values = append(values, secret)
|
||||
}
|
||||
collectJSONStringValues(getJSONPath(document, field), &values)
|
||||
}
|
||||
return values
|
||||
return uniqueNonemptyStrings(values)
|
||||
}
|
||||
|
||||
type AppSetting struct {
|
||||
@@ -476,15 +474,32 @@ type LogFilter struct {
|
||||
}
|
||||
|
||||
type CardPolicy struct {
|
||||
ICCID string
|
||||
NetworkEnabled bool
|
||||
VoWiFiEnabled bool
|
||||
AirplaneEnabled bool
|
||||
APN string
|
||||
IPVersion string
|
||||
Source string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
ICCID string
|
||||
NetworkEnabled bool
|
||||
VoWiFiEnabled bool
|
||||
AirplaneEnabled bool
|
||||
APN string
|
||||
IPVersion string
|
||||
CustomPhoneNumber string
|
||||
Source string
|
||||
CreatedAt time.Time
|
||||
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 {
|
||||
@@ -558,8 +573,8 @@ func redactJSONFields(value json.RawMessage, fields []string, replacement string
|
||||
return json.RawMessage(`{}`)
|
||||
}
|
||||
for _, field := range fields {
|
||||
if getJSONPath(document, field) != nil {
|
||||
setJSONPath(document, field, replacement)
|
||||
if current := getJSONPath(document, field); current != nil {
|
||||
setJSONPath(document, field, redactJSONValue(current, replacement))
|
||||
}
|
||||
}
|
||||
encoded, err := json.Marshal(document)
|
||||
@@ -584,16 +599,55 @@ func mergeJSONSecrets(
|
||||
}
|
||||
for _, field := range fields {
|
||||
value := getJSONPath(next, field)
|
||||
text, stringValue := value.(string)
|
||||
if value == nil || (stringValue && (text == "" || text == SecretMask)) {
|
||||
if previous := getJSONPath(current, field); previous != nil {
|
||||
setJSONPath(next, field, previous)
|
||||
}
|
||||
if previous := getJSONPath(current, field); previous != nil {
|
||||
setJSONPath(next, field, mergeJSONSecretValue(value, previous))
|
||||
}
|
||||
}
|
||||
return json.Marshal(next)
|
||||
}
|
||||
|
||||
func redactJSONValue(value any, replacement string) any {
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
return replacement
|
||||
case []any:
|
||||
result := make([]any, len(typed))
|
||||
for index, item := range typed {
|
||||
result[index] = redactJSONValue(item, replacement)
|
||||
}
|
||||
return result
|
||||
default:
|
||||
return replacement
|
||||
}
|
||||
}
|
||||
|
||||
func mergeJSONSecretValue(incoming, existing any) any {
|
||||
if incoming == nil {
|
||||
return existing
|
||||
}
|
||||
switch next := incoming.(type) {
|
||||
case string:
|
||||
if next == "" || next == SecretMask {
|
||||
return existing
|
||||
}
|
||||
case []any:
|
||||
previous, ok := existing.([]any)
|
||||
if !ok {
|
||||
return incoming
|
||||
}
|
||||
merged := make([]any, len(next))
|
||||
for index, value := range next {
|
||||
if index < len(previous) {
|
||||
merged[index] = mergeJSONSecretValue(value, previous[index])
|
||||
} else {
|
||||
merged[index] = value
|
||||
}
|
||||
}
|
||||
return merged
|
||||
}
|
||||
return incoming
|
||||
}
|
||||
|
||||
func getJSONPath(document map[string]any, path string) any {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return nil
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -22,6 +22,8 @@ func DefaultNotificationSensitiveFields(channel string) []string {
|
||||
return []string{"secret"}
|
||||
case "pushplus":
|
||||
return []string{"token"}
|
||||
case "wecom":
|
||||
return []string{"urls"}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
@@ -360,6 +362,7 @@ func maskedJSONValue(value json.RawMessage) bool {
|
||||
|
||||
func (s *Store) UpsertCardPolicy(ctx context.Context, value CardPolicy) error {
|
||||
value.ICCID = strings.TrimSpace(value.ICCID)
|
||||
value.CustomPhoneNumber = strings.TrimSpace(value.CustomPhoneNumber)
|
||||
if value.ICCID == "" {
|
||||
return errors.New("card policy ICCID is required")
|
||||
}
|
||||
@@ -381,20 +384,21 @@ func (s *Store) UpsertCardPolicy(ctx context.Context, value CardPolicy) error {
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
INSERT INTO card_policies (
|
||||
iccid, network_enabled, vowifi_enabled, airplane_enabled,
|
||||
apn, ip_version, source, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
apn, ip_version, custom_phone_number, source, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(iccid) DO UPDATE SET
|
||||
network_enabled = excluded.network_enabled,
|
||||
vowifi_enabled = excluded.vowifi_enabled,
|
||||
airplane_enabled = excluded.airplane_enabled,
|
||||
apn = excluded.apn,
|
||||
ip_version = excluded.ip_version,
|
||||
custom_phone_number = excluded.custom_phone_number,
|
||||
source = excluded.source,
|
||||
updated_at = excluded.updated_at
|
||||
`,
|
||||
value.ICCID, boolInt(value.NetworkEnabled), boolInt(value.VoWiFiEnabled),
|
||||
boolInt(value.AirplaneEnabled), value.APN, value.IPVersion,
|
||||
value.Source, createdAt.Unix(), updatedAt.Unix(),
|
||||
value.CustomPhoneNumber, value.Source, createdAt.Unix(), updatedAt.Unix(),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("upsert card policy %q: %w", value.ICCID, err)
|
||||
@@ -440,7 +444,7 @@ func (s *Store) DeleteCardPolicy(ctx context.Context, iccid string) error {
|
||||
|
||||
const cardPolicySelect = `
|
||||
SELECT iccid, network_enabled, vowifi_enabled, airplane_enabled,
|
||||
apn, ip_version, source, created_at, updated_at
|
||||
apn, ip_version, custom_phone_number, source, created_at, updated_at
|
||||
FROM card_policies`
|
||||
|
||||
func cardPolicy(row rowScanner) (CardPolicy, error) {
|
||||
@@ -449,7 +453,7 @@ func cardPolicy(row rowScanner) (CardPolicy, error) {
|
||||
var createdAt, updatedAt int64
|
||||
err := row.Scan(
|
||||
&value.ICCID, &networkEnabled, &vowifiEnabled, &airplaneEnabled,
|
||||
&value.APN, &value.IPVersion, &value.Source, &createdAt, &updatedAt,
|
||||
&value.APN, &value.IPVersion, &value.CustomPhoneNumber, &value.Source, &createdAt, &updatedAt,
|
||||
)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return CardPolicy{}, ErrNotFound
|
||||
@@ -465,6 +469,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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
const schemaVersion = 10
|
||||
const schemaVersion = 15
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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 "))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -257,6 +257,7 @@ func (orchestrator *Orchestrator) Enable(ctx context.Context) (State, error) {
|
||||
}
|
||||
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)),
|
||||
|
||||
@@ -203,6 +203,7 @@ type ProxyRoute struct {
|
||||
|
||||
type ProxyRequest struct {
|
||||
DeviceID string
|
||||
ICCID string
|
||||
HomeMCC string
|
||||
HomeMNC string
|
||||
CountryCode string
|
||||
|
||||
@@ -210,7 +210,6 @@ User=root
|
||||
Group=root
|
||||
WorkingDirectory=/opt/vocat
|
||||
EnvironmentFile=${ENV_FILE}
|
||||
Environment=VOCAT_ADDR=0.0.0.0:7575
|
||||
Environment=VOCAT_DATABASE_PATH=/opt/vocat/data/vocat.db
|
||||
ExecStart=${BINARY_PATH}
|
||||
Restart=on-failure
|
||||
|
||||
@@ -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.
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -0,0 +1,13 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" id="flag-icons-asean" viewBox="0 0 640 480">
|
||||
<path fill="#0039a6" stroke-width="4.52548" d="M0 0h640v480H0z"/>
|
||||
<circle cx="320" cy="240" r="144" fill="#fff" stroke-width="4.8"/>
|
||||
<circle cx="320" cy="240" r="137.3" fill="#ed2939" stroke-width="4.8"/>
|
||||
<use xlink:href="#asean-a" transform="matrix(-1 0 0 1 640 0)"/>
|
||||
<g id="asean-a">
|
||||
<path fill="#f9e300" d="M82.7 50c5-3 7.3-9 7.3-15h-2.3v.2c0 2-.3 9.3-5.8 14.8a22 22 0 0 1 5.8 14.8v.2H90c0-6-2.4-12-7.3-15" transform="translate(-40)scale(4.8)"/>
|
||||
<path fill="#f9e300" d="M87 35.2V35h-2.4v.3c0 2-.4 9-4.3 14.7 4 5.7 4.3 12.7 4.3 14.7v.3H87v-.2c0-2-.5-9.3-5.8-14.8a22 22 0 0 0 5.8-14.8" transform="translate(-40)scale(4.8)"/>
|
||||
<path fill="#f9e300" d="m79.4 50 .2-.2a27 27 0 0 0 4.2-14.5V35h-2.2v.4c0 2.1-.3 8.8-3 14.6a38 38 0 0 1 3 14.6v.4h2.2v-.3c0-2-.2-8.3-4.2-14.5" transform="translate(-40)scale(4.8)"/>
|
||||
<path fill="#f9e300" d="M77.8 50a37 37 0 0 0 3-15h-2.3v.7c0 2.5-.2 8.6-1.5 14.3a70 70 0 0 1 1.5 14.3v.7h2.2v-.4c0-2.1-.2-9-2.8-14.5" transform="translate(-40)scale(4.8)"/>
|
||||
<path fill="#f9e300" d="M76.2 50a69 69 0 0 0 1.4-14.3V35h-2.2v30h2.2v-.7c0-2.4 0-8.6-1.4-14.2" transform="translate(-40)scale(4.8)"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-at" viewBox="0 0 640 480">
|
||||
<path fill="#fff" d="M0 160h640v160H0z"/>
|
||||
<path fill="#c8102e" d="M0 0h640v160H0zm0 320h640v160H0z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 195 B |
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-au" viewBox="0 0 640 480">
|
||||
<path fill="#00008B" d="M0 0h640v480H0z"/>
|
||||
<path fill="#fff" d="m37.5 0 122 90.5L281 0h39v31l-120 89.5 120 89V240h-40l-120-89.5L40.5 240H0v-30l119.5-89L0 32V0z"/>
|
||||
<path fill="red" d="M212 140.5 320 220v20l-135.5-99.5zm-92 10 3 17.5-96 72H0zM320 0v1.5l-124.5 94 1-22L295 0zM0 0l119.5 88h-30L0 21z"/>
|
||||
<path fill="#fff" d="M120.5 0v240h80V0zM0 80v80h320V80z"/>
|
||||
<path fill="red" d="M0 96.5v48h320v-48zM136.5 0v240h48V0z"/>
|
||||
<path fill="#fff" d="m527 396.7-20.5 2.6 2.2 20.5-14.8-14.4-14.7 14.5 2-20.5-20.5-2.4 17.3-11.2-10.9-17.5 19.6 6.5 6.9-19.5 7.1 19.4 19.5-6.7-10.7 17.6zm-3.7-117.2 2.7-13-9.8-9 13.2-1.5 5.5-12.1 5.5 12.1 13.2 1.5-9.8 9 2.7 13-11.6-6.6zm-104.1-60-20.3 2.2 1.8 20.3-14.4-14.5-14.8 14.1 2.4-20.3-20.2-2.7 17.3-10.8-10.5-17.5 19.3 6.8L387 178l6.7 19.3 19.4-6.3-10.9 17.3 17.1 11.2ZM623 186.7l-20.9 2.7 2.3 20.9-15.1-14.7-15 14.8 2.1-21-20.9-2.4 17.7-11.5-11.1-17.9 20 6.7 7-19.8 7.2 19.8 19.9-6.9-11 18zm-96.1-83.5-20.7 2.3 1.9 20.8-14.7-14.8-15.1 14.4 2.4-20.7-20.7-2.8 17.7-11L467 73.5l19.7 6.9 7.3-19.5 6.8 19.7 19.8-6.5-11.1 17.6zM234 385.7l-45.8 5.4 4.6 45.9-32.8-32.4-33 32.2 4.9-45.9-45.8-5.8 38.9-24.8-24-39.4 43.6 15 15.8-43.4 15.5 43.5 43.7-14.7-24.3 39.2 38.8 25.1Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,186 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-aw" viewBox="0 0 640 480">
|
||||
<defs>
|
||||
<clipPath id="aw-a">
|
||||
<path fill-opacity=".7" d="M0 0h288v216H0z"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
<g clip-path="url(#aw-a)" transform="scale(2.2222)">
|
||||
<path fill="#39c" d="M0 0v216h324V0z"/>
|
||||
<path fill="#ff0" d="M0 144v12h324v-12zm0 24v12h324v-12z"/>
|
||||
</g>
|
||||
<path fill="#9cc" d="m142.7 28 2.9 3zm-3 6 3 3zm5.9 0 3 3z"/>
|
||||
<path fill="#ccf" d="m139.7 37 3 2.9-3-3m5.9 0 3 3z"/>
|
||||
<path fill="#6cc" d="m136.7 42.8 3 3z"/>
|
||||
<path fill="#c66" d="m142.7 42.8 2.9 3z"/>
|
||||
<path fill="#6cc" d="m148.6 42.8 2.9 3z"/>
|
||||
<path fill="#ccf" d="m136.7 45.8 3 3zm11.9 0 2.9 3z"/>
|
||||
<path fill="#fcc" d="m139.7 48.7 3 3zm5.9 0 3 3z"/>
|
||||
<path fill="#6cc" d="m133.8 51.7 3 3z"/>
|
||||
<path fill="#c00" stroke="#fff" stroke-width="3.7" d="m142.2 34-20.7 78.5L42.8 134l78.4 20.5 21 78.4 20.9-78.4 78.4-21-78.4-20.9-21-78.4z"/>
|
||||
<path fill="#6cc" d="m151.5 51.7 3 3z"/>
|
||||
<path fill="#9cf" d="m133.8 54.6 3 3zm17.7 0 3 3z"/>
|
||||
<path fill="#fcc" d="m136.7 57.6 3 3zm11.9 0 2.9 3z"/>
|
||||
<path fill="#69c" d="m130.8 60.5 3 3z"/>
|
||||
<path fill="#c33" d="m137.7 62.5 1 2zm11.8 0 1 2z"/>
|
||||
<path fill="#69c" d="m154.5 60.5 3 3z"/>
|
||||
<path fill="#9cf" d="m130.8 63.5 3 3zm23.7 0 3 3z"/>
|
||||
<path fill="#fcc" d="m133.8 66.4 3 3zm17.7 0 3 3z"/>
|
||||
<path fill="#69c" d="m127.9 69.4 3 3zm29.5 0 3 3z"/>
|
||||
<path fill="#9cc" d="m127.9 72.3 3 3zm29.5 0 3 3z"/>
|
||||
<path fill="#cff" d="m127.9 75.3 3 3zm29.5 0 3 3z"/>
|
||||
<path fill="#69c" d="m125 78.3 2.9 2.9z"/>
|
||||
<path fill="#fcc" d="m130.8 78.3 3 2.9zm23.7 0 3 3z"/>
|
||||
<path fill="#69c" d="m160.4 78.3 3 2.9z"/>
|
||||
<path fill="#9cc" d="m125 81.2 2.9 3z"/>
|
||||
<path fill="#c33" d="m131.8 83.2 1 2zm23.6 0 1 2z"/>
|
||||
<path fill="#9cc" d="m160.4 81.2 3 3z"/>
|
||||
<path fill="#cff" d="m125 84.2 2.9 3zm35.5 0 3 3z"/>
|
||||
<path fill="#fcc" d="m127.9 87.1 3 3zm29.5 0 3 3z"/>
|
||||
<path fill="#9cc" d="m122 90 3 3z"/>
|
||||
<path fill="#c33" d="m128.9 92 1 2zm29.5 0 1 2z"/>
|
||||
<path fill="#9cc" d="m163.3 90 3 3z"/>
|
||||
<path fill="#ccf" d="m122 93 3 3zm41.3 0 3 3z"/>
|
||||
<path fill="#fcc" d="m125 96 2.9 3zm35.5 0 3 3z"/>
|
||||
<path fill="#9cc" d="m119 99 3 2.9z"/>
|
||||
<path fill="#c33" d="m126 100.9.9 2zm35.4 0 1 2z"/>
|
||||
<path fill="#9cc" d="m166.3 99 3 2.9z"/>
|
||||
<path fill="#ccf" d="m119 101.9 3 3zm47.3 0 3 3z"/>
|
||||
<path fill="#fcc" d="m122 104.8 3 3zm41.3 0 3 3z"/>
|
||||
<path fill="#9cc" d="m116 107.8 3 3z"/>
|
||||
<path fill="#c33" d="m122 107.8 3 3zm41.3 0 3 3z"/>
|
||||
<path fill="#9cc" d="m169.2 107.8 3 3zm-62 3 3 2.9z"/>
|
||||
<path fill="#ccf" d="m110.2 110.7 3 3zm65 0 2.9 3z"/>
|
||||
<path fill="#9cc" d="m178 110.7 3 3zm-79.6 3 3 3z"/>
|
||||
<path fill="#ccf" d="m101.3 113.7 3 3z"/>
|
||||
<path fill="#fcc" d="m113.1 113.7 3 3z"/>
|
||||
<path fill="#c33" d="m116 113.7 3 3zm53.2 0 3 3z"/>
|
||||
<path fill="#fcc" d="m172.2 113.7 3 3z"/>
|
||||
<path fill="#ccf" d="m184 113.7 3 3z"/>
|
||||
<path fill="#9cc" d="m187 113.7 2.9 3z"/>
|
||||
<path fill="#69c" d="m86.6 116.6 3 3z"/>
|
||||
<path fill="#9cc" d="m89.5 116.6 3 3z"/>
|
||||
<path fill="#cff" d="m92.5 116.6 3 3z"/>
|
||||
<path fill="#fcc" d="m104.3 116.6 3 3z"/>
|
||||
<path fill="#c33" d="m109.2 117.6 2 1zm67.9 0 2 1z"/>
|
||||
<path fill="#fcc" d="m181 116.6 3 3z"/>
|
||||
<path fill="#cff" d="m192.8 116.6 3 3z"/>
|
||||
<path fill="#9cc" d="m195.8 116.6 3 3z"/>
|
||||
<path fill="#69c" d="m198.7 116.6 3 3zm-121 3 3 3z"/>
|
||||
<path fill="#9cc" d="m80.7 119.6 3 3z"/>
|
||||
<path fill="#cff" d="m83.6 119.6 3 3z"/>
|
||||
<path fill="#fcc" d="m95.4 119.6 3 3z"/>
|
||||
<path fill="#c33" d="m100.3 120.6 2 1zm85.6 0 2 1z"/>
|
||||
<path fill="#fcc" d="m189.9 119.6 3 3z"/>
|
||||
<path fill="#cff" d="m201.7 119.6 3 3z"/>
|
||||
<path fill="#9cc" d="m204.6 119.6 3 3z"/>
|
||||
<path fill="#69c" d="m207.6 119.6 3 3zm-138.8 3 3 2.9z"/>
|
||||
<path fill="#9cf" d="m71.8 122.5 3 3z"/>
|
||||
<path fill="#fcc" d="m86.6 122.5 3 3z"/>
|
||||
<path fill="#c33" d="m91.5 123.5 2 1zm103.3 0 2 1z"/>
|
||||
<path fill="#fcc" d="m198.7 122.5 3 3z"/>
|
||||
<path fill="#9cf" d="m213.5 122.5 3 3z"/>
|
||||
<path fill="#69c" d="m216.4 122.5 3 3z"/>
|
||||
<path fill="#6cc" d="m60 125.5 3 3z"/>
|
||||
<path fill="#9cf" d="m63 125.5 2.9 3z"/>
|
||||
<path fill="#fcc" d="m74.8 125.5 2.9 3zm135.8 0 2.9 3z"/>
|
||||
<path fill="#9cf" d="m222.3 125.5 3 3z"/>
|
||||
<path fill="#6cc" d="m225.3 125.5 3 3zm-174.2 3 3 2.9z"/>
|
||||
<path fill="#ccf" d="m54 128.4 3 3z"/>
|
||||
<path fill="#fcc" d="m65.9 128.4 3 3z"/>
|
||||
<path fill="#c33" d="m70.8 129.4 2 1zm144.7 0 2 1z"/>
|
||||
<path fill="#fcc" d="m219.4 128.4 3 3z"/>
|
||||
<path fill="#ccf" d="m231.2 128.4 3 3z"/>
|
||||
<path fill="#6cc" d="m234.2 128.4 3 3z"/>
|
||||
<path fill="#9cc" d="m42.3 131.4 3 3z"/>
|
||||
<path fill="#ccf" d="m45.2 131.4 3 3z"/>
|
||||
<path fill="#fcc" d="m57 131.4 3 3zm171.3 0 3 3z"/>
|
||||
<path fill="#ccf" d="m240 131.4 3 3z"/>
|
||||
<path fill="#9cc" d="m243 131.4 3 3zm-206.6 3 3 2.9z"/>
|
||||
<path fill="#c66" d="m51.1 134.3 3 3zm183 0 3 3z"/>
|
||||
<path fill="#9cc" d="m249 134.3 2.9 3zm-206.6 3 3 3z"/>
|
||||
<path fill="#ccf" d="m45.2 137.3 3 3z"/>
|
||||
<path fill="#fcc" d="m57 137.3 3 3zm171.3 0 3 3z"/>
|
||||
<path fill="#ccf" d="m240 137.3 3 3z"/>
|
||||
<path fill="#9cc" d="m243 137.3 3 3z"/>
|
||||
<path fill="#6cc" d="m51.1 140.3 3 2.9z"/>
|
||||
<path fill="#ccf" d="m54 140.3 3 2.9z"/>
|
||||
<path fill="#fcc" d="m65.9 140.3 3 2.9z"/>
|
||||
<path fill="#c33" d="m70.8 141.2 2 1zm144.7 0 2 1z"/>
|
||||
<path fill="#fcc" d="m219.4 140.3 3 2.9z"/>
|
||||
<path fill="#ccf" d="m231.2 140.3 3 2.9z"/>
|
||||
<path fill="#6cc" d="m234.2 140.3 3 2.9zm-174.2 3 3 3z"/>
|
||||
<path fill="#9cf" d="m63 143.2 2.9 3z"/>
|
||||
<path fill="#fcc" d="m74.8 143.2 2.9 3zm135.8 0 2.9 3z"/>
|
||||
<path fill="#9cf" d="m222.3 143.2 3 3z"/>
|
||||
<path fill="#6cc" d="m225.3 143.2 3 3z"/>
|
||||
<path fill="#69c" d="m68.8 146.2 3 2.9z"/>
|
||||
<path fill="#9cf" d="m71.8 146.2 3 2.9z"/>
|
||||
<path fill="#fcc" d="m86.6 146.2 3 2.9z"/>
|
||||
<path fill="#c33" d="m91.5 147.1 2 1zm103.3 0 2 1z"/>
|
||||
<path fill="#fcc" d="m198.7 146.2 3 2.9z"/>
|
||||
<path fill="#9cf" d="m213.5 146.2 3 2.9z"/>
|
||||
<path fill="#69c" d="m216.4 146.2 3 2.9zm-138.7 3 3 3z"/>
|
||||
<path fill="#9cc" d="m80.7 149.1 3 3z"/>
|
||||
<path fill="#cff" d="m83.6 149.1 3 3z"/>
|
||||
<path fill="#fcc" d="m95.4 149.1 3 3z"/>
|
||||
<path fill="#c33" d="m100.3 150 2 1zm85.6 0 2 1z"/>
|
||||
<path fill="#fcc" d="m189.9 149.1 3 3z"/>
|
||||
<path fill="#cff" d="m201.7 149.1 3 3z"/>
|
||||
<path fill="#9cc" d="m204.6 149.1 3 3z"/>
|
||||
<path fill="#69c" d="m207.6 149.1 3 3zm-121 3 2.9 2.9z"/>
|
||||
<path fill="#9cc" d="m89.5 152 3 3z"/>
|
||||
<path fill="#cff" d="m92.5 152 3 3z"/>
|
||||
<path fill="#fcc" d="m104.3 152 3 3z"/>
|
||||
<path fill="#c33" d="m109.2 153 2 1zm67.9 0 2 1z"/>
|
||||
<path fill="#fcc" d="m181 152 3 3z"/>
|
||||
<path fill="#cff" d="m192.8 152 3 3z"/>
|
||||
<path fill="#9cc" d="m195.8 152 3 3z"/>
|
||||
<path fill="#69c" d="m198.7 152 3 3z"/>
|
||||
<path fill="#9cc" d="m98.4 155 3 3z"/>
|
||||
<path fill="#ccf" d="m101.3 155 3 3z"/>
|
||||
<path fill="#fcc" d="m113.1 155 3 3z"/>
|
||||
<path fill="#c33" d="m116 155 3 3zm53.2 0 3 3z"/>
|
||||
<path fill="#fcc" d="m172.2 155 3 3z"/>
|
||||
<path fill="#ccf" d="m184 155 3 3z"/>
|
||||
<path fill="#9cc" d="m187 155 2.9 3zm-79.7 3 3 3z"/>
|
||||
<path fill="#ccf" d="m110.2 158 3 3zm65 0 2.9 3z"/>
|
||||
<path fill="#9cc" d="m178 158 3 3zm-62 3 3 2.9z"/>
|
||||
<path fill="#c33" d="m122 161 3 2.9zm41.3 0 3 3z"/>
|
||||
<path fill="#9cc" d="m169.2 161 3 2.9z"/>
|
||||
<path fill="#fcc" d="m122 163.9 3 3zm41.3 0 3 3z"/>
|
||||
<path fill="#ccf" d="m119 166.8 3 3z"/>
|
||||
<path fill="#c33" d="m126 168.8.9 2zm35.4 0 1 2z"/>
|
||||
<path fill="#ccf" d="m166.3 166.8 3 3z"/>
|
||||
<path fill="#9cc" d="m119 169.8 3 3zm47.3 0 3 3z"/>
|
||||
<path fill="#fcc" d="m125 172.7 2.9 3zm35.5 0 3 3z"/>
|
||||
<path fill="#ccf" d="m122 175.7 3 3z"/>
|
||||
<path fill="#c33" d="m128.9 177.6 1 2zm29.5 0 1 2z"/>
|
||||
<path fill="#ccf" d="m163.3 175.7 3 3z"/>
|
||||
<path fill="#9cc" d="m122 178.6 3 3zm41.3 0 3 3z"/>
|
||||
<path fill="#fcc" d="m127.9 181.6 3 3zm29.5 0 3 3z"/>
|
||||
<path fill="#cff" d="m125 184.5 2.9 3z"/>
|
||||
<path fill="#c33" d="m131.8 186.5 1 2zm23.6 0 1 2z"/>
|
||||
<path fill="#cff" d="m160.4 184.5 3 3z"/>
|
||||
<path fill="#9cc" d="m125 187.5 2.9 3zm35.5 0 3 3z"/>
|
||||
<path fill="#69c" d="m125 190.4 2.9 3z"/>
|
||||
<path fill="#fcc" d="m130.8 190.4 3 3zm23.7 0 3 3z"/>
|
||||
<path fill="#69c" d="m160.4 190.4 3 3z"/>
|
||||
<path fill="#cff" d="m127.9 193.4 3 3zm29.5 0 3 3z"/>
|
||||
<path fill="#9cc" d="m127.9 196.3 3 3zm29.5 0 3 3z"/>
|
||||
<path fill="#69c" d="m127.9 199.3 3 3zm29.5 0 3 3z"/>
|
||||
<path fill="#fcc" d="m133.8 202.2 3 3zm17.7 0 3 3z"/>
|
||||
<path fill="#9cf" d="m130.8 205.2 3 3z"/>
|
||||
<path fill="#c33" d="m137.7 207.2 1 2zm11.8 0 1 2z"/>
|
||||
<path fill="#9cf" d="m154.5 205.2 3 3z"/>
|
||||
<path fill="#69c" d="m130.8 208.2 3 2.9zm23.7 0 3 3z"/>
|
||||
<path fill="#fcc" d="m136.7 211.1 3 3zm11.9 0 2.9 3z"/>
|
||||
<path fill="#9cf" d="m133.8 214 3 3zm17.7 0 3 3z"/>
|
||||
<path fill="#6cc" d="m133.8 217 3 3zm17.7 0 3 3z"/>
|
||||
<path fill="#fcc" d="m139.7 220 3 3zm5.9 0 3 3z"/>
|
||||
<path fill="#ccf" d="m136.7 222.9 3 3zm11.9 0 2.9 3z"/>
|
||||
<path fill="#6cc" d="m136.7 225.9 3 3z"/>
|
||||
<path fill="#c66" d="m142.7 225.9 2.9 3z"/>
|
||||
<path fill="#6cc" d="m148.6 225.9 2.9 3z"/>
|
||||
<path fill="#ccf" d="m139.7 231.8 3 3zm5.9 0 3 3z"/>
|
||||
<path fill="#9cc" d="m139.7 234.7 3 3zm5.9 0 3 3zm-3 6 3 2.9z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 8.8 KiB |
@@ -0,0 +1,18 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-ax" viewBox="0 0 640 480">
|
||||
<defs>
|
||||
<clipPath id="ax-a">
|
||||
<path fill-opacity=".7" d="M106.3 0h1133.3v850H106.3z"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
<g clip-path="url(#ax-a)" transform="matrix(.56472 0 0 .56482 -60 -.1)">
|
||||
<path fill="#0053a5" d="M0 0h1300v850H0z"/>
|
||||
<g fill="#ffce00">
|
||||
<path d="M400 0h250v850H400z"/>
|
||||
<path d="M0 300h1300v250H0z"/>
|
||||
</g>
|
||||
<g fill="#d21034">
|
||||
<path d="M475 0h100v850H475z"/>
|
||||
<path d="M0 375h1300v100H0z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 556 B |
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-az" viewBox="0 0 640 480">
|
||||
<path fill="#3f9c35" d="M.1 0h640v480H.1z"/>
|
||||
<path fill="#ed2939" d="M.1 0h640v320H.1z"/>
|
||||
<path fill="#00b9e4" d="M.1 0h640v160H.1z"/>
|
||||
<circle cx="304" cy="240" r="72" fill="#fff"/>
|
||||
<circle cx="320" cy="240" r="60" fill="#ed2939"/>
|
||||
<path fill="#fff" d="m384 200 7.7 21.5 20.6-9.8-9.8 20.7L424 240l-21.5 7.7 9.8 20.6-20.6-9.8L384 280l-7.7-21.5-20.6 9.8 9.8-20.6L344 240l21.5-7.7-9.8-20.6 20.6 9.8z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 501 B |
@@ -0,0 +1,12 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-ba" viewBox="0 0 640 480">
|
||||
<defs>
|
||||
<clipPath id="ba-a">
|
||||
<path fill-opacity=".7" d="M-85.3 0h682.6v512H-85.3z"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
<g fill-rule="evenodd" clip-path="url(#ba-a)" transform="translate(80)scale(.9375)">
|
||||
<path fill="#009" d="M-85.3 0h682.6v512H-85.3z"/>
|
||||
<path fill="#FC0" d="m56.5 0 511 512.3V.3z"/>
|
||||
<path fill="#FFF" d="M439.9 481.5 412 461.2l-28.6 20.2 10.8-33.2-28.2-20.5h35l10.8-33.2 10.7 33.3h35l-28 20.7zm81.3 10.4-35-.1-10.7-33.3-10.8 33.2h-35l28.2 20.5-10.8 33.2 28.6-20.2 28 20.3-10.5-33zM365.6 384.7l28-20.7-35-.1-10.7-33.2-10.8 33.2-35-.1 28.2 20.5-10.8 33.3 28.6-20.3 28 20.4zm-64.3-64.5 28-20.6-35-.1-10.7-33.3-10.9 33.2h-34.9l28.2 20.5-10.8 33.2 28.6-20.2 27.9 20.3zm-63.7-63.6 28-20.7h-35L220 202.5l-10.8 33.2h-35l28.2 20.4-10.8 33.3 28.6-20.3 28 20.4-10.5-33zm-64.4-64.3 28-20.6-35-.1-10.7-33.3-10.9 33.2h-34.9L138 192l-10.8 33.2 28.6-20.2 27.9 20.3-10.4-33zm-63.6-63.9 27.9-20.7h-35L91.9 74.3 81 107.6H46L74.4 128l-10.9 33.2L92.1 141l27.8 20.4zm-64-64 27.9-20.7h-35L27.9 10.3 17 43.6h-35L10.4 64l-11 33.3L28.1 77l27.8 20.4zm-64-64L9.4-20.3h-35l-10.7-33.3L-47-20.4h-35L-53.7 0l-10.8 33.2L-35.9 13l27.8 20.4z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" id="flag-icons-bb" viewBox="0 0 640 480">
|
||||
<path fill="#00267f" d="M0 0h640v480H0z"/>
|
||||
<path fill="#ffc726" d="M213.3 0h213.4v480H213.3z"/>
|
||||
<path id="bb-a" fill="#000001" d="M319.8 135.5c-7 19-14 38.6-29.2 53.7 4.7-1.6 13-3 18.2-2.8v79.5l-22.4 3.3c-.8 0-1-1.3-1-3-2.2-24.7-8-45.5-14.8-67-.5-2.9-9-14-2.4-12 .8 0 9.5 3.6 8.2 1.9a85 85 0 0 0-46.4-24c-1.5-.3-2.4.5-1 2.2 22.4 34.6 41.3 75.5 41.1 124 8.8 0 30-5.2 38.7-5.2v56.1H320l2.5-156.7z"/>
|
||||
<use xlink:href="#bb-a" width="100%" height="100%" transform="matrix(-1 0 0 1 639.5 0)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 628 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-bd" viewBox="0 0 640 480">
|
||||
<path fill="#006a4e" d="M0 0h640v480H0z"/>
|
||||
<circle cx="280" cy="240" r="160" fill="#f42a41"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 187 B |
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-be" viewBox="0 0 640 480">
|
||||
<g fill-rule="evenodd" stroke-width="1pt">
|
||||
<path fill="#000001" d="M0 0h213.3v480H0z"/>
|
||||
<path fill="#ffd90c" d="M213.3 0h213.4v480H213.3z"/>
|
||||
<path fill="#f31830" d="M426.7 0H640v480H426.7z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 302 B |
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-bf" viewBox="0 0 640 480">
|
||||
<g fill-rule="evenodd">
|
||||
<path fill="#de0000" d="M640 479.6H.4V0H640z"/>
|
||||
<path fill="#35a100" d="M639.6 480H0V240.2h639.6z"/>
|
||||
<path fill="#fff300" d="m254.6 276.2-106-72.4h131L320 86.6 360.4 204l131-.1-106 72.4 40.5 117.3-106-72.6L214 393.4"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 353 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-bg" viewBox="0 0 640 480">
|
||||
<path fill="#fff" d="M0 0h640v160H0z"/>
|
||||
<path fill="#00966e" d="M0 160h640v160H0z"/>
|
||||
<path fill="#d62612" d="M0 320h640v160H0z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 225 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-bh" viewBox="0 0 640 480">
|
||||
<path fill="#fff" d="M0 0h640v480H0"/>
|
||||
<path fill="#ce1126" d="M640 0H96l110.7 48L96 96l110.7 48L96 192l110.7 48L96 288l110.7 48L96 384l110.7 48L96 480h544"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 252 B |
@@ -0,0 +1,15 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-bi" viewBox="0 0 640 480">
|
||||
<defs>
|
||||
<clipPath id="bi-a">
|
||||
<path fill-opacity=".7" d="M-90.5 0H592v512H-90.5z"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
<g fill-rule="evenodd" clip-path="url(#bi-a)" transform="translate(84.9)scale(.9375)">
|
||||
<path fill="#18b637" d="m-178 0 428.8 256L-178 512zm857.6 0L250.8 256l428.8 256z"/>
|
||||
<path fill="#cf0921" d="m-178 0 428.8 256L679.6 0zm0 512 428.8-256 428.8 256z"/>
|
||||
<path fill="#fff" d="M679.6 0h-79.9L-178 464.3V512h79.9L679.6 47.7z"/>
|
||||
<path fill="#fff" d="M398.9 256a148 148 0 1 1-296.1 0 148 148 0 0 1 296 0z"/>
|
||||
<path fill="#fff" d="M-178 0v47.7L599.7 512h79.9v-47.7L-98.1 0z"/>
|
||||
<path fill="#cf0921" stroke="#18b637" stroke-width="3.9" d="m280 200.2-19.3.3-10 16.4-9.9-16.4-19.2-.4 9.3-16.9-9.2-16.8 19.2-.4 10-16.4 9.9 16.5 19.2.4-9.3 16.8zm-64.6 111.6-19.2.3-10 16.4-9.9-16.4-19.2-.4 9.3-16.9-9.2-16.8 19.2-.4 10-16.4 9.9 16.5 19.2.4-9.3 16.8zm130.6 0-19.2.3-10 16.4-10-16.4-19.1-.4 9.3-16.9-9.3-16.8 19.2-.4 10-16.4 10 16.5 19.2.4-9.4 16.8z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-bj" viewBox="0 0 640 480">
|
||||
<defs>
|
||||
<clipPath id="bj-a">
|
||||
<path fill="gray" d="M67.6-154h666v666h-666z"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
<g clip-path="url(#bj-a)" transform="matrix(.961 0 0 .7207 -65 111)">
|
||||
<g fill-rule="evenodd" stroke-width="1pt">
|
||||
<path fill="#319400" d="M0-154h333v666H0z"/>
|
||||
<path fill="#ffd600" d="M333-154h666v333H333z"/>
|
||||
<path fill="#de2110" d="M333 179h666v333H333z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 499 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-bl" viewBox="0 0 640 480">
|
||||
<path fill="#fff" d="M0 0h640v480H0z"/>
|
||||
<path fill="#000091" d="M0 0h213.3v480H0z"/>
|
||||
<path fill="#e1000f" d="M426.7 0H640v480H426.7z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 231 B |