mirror of
https://github.com/MengMengCode/VoCat.git
synced 2026-08-15 12:23:42 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d8552b670 |
@@ -10,7 +10,6 @@
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
/fix
|
||||
|
||||
# ---- Cookie / secret files (NEVER commit) ----
|
||||
vc.jar
|
||||
|
||||
@@ -23,14 +23,14 @@ func runBootstrapAdmin(args []string) error {
|
||||
if err := flags.Parse(args); err != nil || flags.NArg() != 0 {
|
||||
return errors.New("usage: vocat bootstrap-admin [--database path] [--username name]")
|
||||
}
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
reader := bufio.NewReader(io.LimitReader(os.Stdin, 2049))
|
||||
password, err := reader.ReadString('\n')
|
||||
if err != nil && !errors.Is(err, io.EOF) {
|
||||
return fmt.Errorf("read password: %w", err)
|
||||
}
|
||||
password = strings.TrimSuffix(strings.TrimSuffix(password, "\n"), "\r")
|
||||
if password == "" {
|
||||
return errors.New("bootstrap password cannot be empty")
|
||||
if len(password) < 12 || len(password) > 1024 {
|
||||
return errors.New("bootstrap password must contain between 12 and 1024 characters")
|
||||
}
|
||||
adminUsername := strings.TrimSpace(*username)
|
||||
if len(adminUsername) < 1 || len(adminUsername) > 64 || strings.ContainsAny(adminUsername, "\r\n\t") {
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestInstallerValidatesDatabaseBeforeReplacingBinary(t *testing.T) {
|
||||
scriptBytes, err := os.ReadFile("../../scripts/install.sh")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
script := string(scriptBytes)
|
||||
mainStart := strings.LastIndex(script, "# --- Main ")
|
||||
if mainStart < 0 {
|
||||
t.Fatal("installer main section not found")
|
||||
}
|
||||
main := script[mainStart:]
|
||||
validateAt := strings.Index(main, `bootstrap_admin "${VOCAT_TMP}/vocat"`)
|
||||
installAt := strings.Index(main, "install_binary")
|
||||
if validateAt < 0 {
|
||||
t.Fatal("installer does not validate the database with the downloaded binary")
|
||||
}
|
||||
if installAt < 0 {
|
||||
t.Fatal("installer does not install the downloaded binary")
|
||||
}
|
||||
if validateAt > installAt {
|
||||
t.Fatal("installer replaces the current binary before validating database compatibility")
|
||||
}
|
||||
}
|
||||
+6
-6
@@ -198,7 +198,8 @@ func menuResetAdminCredentials(reader *bufio.Reader, m *menu) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %v", errMenuConfig, err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
database, err := store.Open(ctx, cfg.DatabasePath)
|
||||
if err != nil {
|
||||
@@ -214,6 +215,7 @@ func menuResetAdminCredentials(reader *bufio.Reader, m *menu) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %v", errMenuStore, err)
|
||||
}
|
||||
|
||||
fmt.Print(m.newUsername(admin.Username))
|
||||
username, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
@@ -523,7 +525,6 @@ func menuUpdate(m *menu, logger *slog.Logger) error {
|
||||
}
|
||||
fmt.Println(m.updateChecking())
|
||||
if err := update.Run(logger, []string{"--repo", repo}); err != nil {
|
||||
logger.Error("menu update failed", "error", err)
|
||||
return fmt.Errorf("%w: %v", errUpdateFailed, err)
|
||||
}
|
||||
return nil
|
||||
@@ -595,7 +596,7 @@ func (m *menu) msg(key string) string {
|
||||
"prompt": {"请选择: ", "Select: "},
|
||||
"invalid": {"无效选项,请重试。按 Ctrl+C 退出。", "Invalid choice, try again. Press Ctrl+C to exit."},
|
||||
"new_username": {"新用户名(直接回车保留 %s): ", "New username (Enter to keep %s): "},
|
||||
"new_pw": {"新密码: ", "New password: "},
|
||||
"new_pw": {"新密码 (至少 12 位): ", "New password (min 12 chars): "},
|
||||
"confirm_pw": {"确认新密码: ", "Confirm new password: "},
|
||||
"pw_changed": {"管理员账号密码已修改,现有 Web 会话已退出。", "Administrator credentials changed; existing Web sessions were signed out."},
|
||||
"current_web_address": {"当前 Web 监听地址: %s", "Current Web listening address: %s"},
|
||||
@@ -687,11 +688,10 @@ func (m *menu) errorPrefix(err error) string {
|
||||
}
|
||||
return "重启失败。"
|
||||
case errors.Is(err, errUpdateFailed):
|
||||
detail := strings.TrimPrefix(err.Error(), errUpdateFailed.Error()+": ")
|
||||
if m.lang == "en" {
|
||||
return "Update failed: " + detail
|
||||
return "Update failed."
|
||||
}
|
||||
return "更新失败: " + detail
|
||||
return "更新失败。"
|
||||
case errors.Is(err, errMenuConfig):
|
||||
if m.lang == "en" {
|
||||
return "Failed to load configuration."
|
||||
|
||||
+12
-48
@@ -1,7 +1,6 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
@@ -21,13 +20,8 @@ var (
|
||||
ErrInvalidCredentials = errors.New("invalid credentials")
|
||||
ErrUnauthorized = errors.New("unauthorized")
|
||||
ErrInvalidCSRF = errors.New("invalid csrf token")
|
||||
ErrEmptyPassword = errors.New("password cannot be empty")
|
||||
)
|
||||
|
||||
const bcryptPasswordLimit = 72
|
||||
|
||||
var longPasswordHashPrefix = []byte("$vocat-sha256$")
|
||||
|
||||
type Options struct {
|
||||
SessionTTL time.Duration
|
||||
BcryptCost int
|
||||
@@ -91,14 +85,14 @@ func (s *Service) EnsureAdmin(ctx context.Context, username string, password str
|
||||
current, err := s.store.CurrentAdmin(ctx)
|
||||
if err == nil &&
|
||||
current.Username == username &&
|
||||
comparePassword(current.PasswordHash, password) == nil {
|
||||
bcrypt.CompareHashAndPassword(current.PasswordHash, []byte(password)) == nil {
|
||||
return nil
|
||||
}
|
||||
if err != nil && !errors.Is(err, store.ErrNotFound) {
|
||||
return fmt.Errorf("auth: read configured admin: %w", err)
|
||||
}
|
||||
|
||||
passwordHash, err := hashPassword(password, s.bcryptCost)
|
||||
passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), s.bcryptCost)
|
||||
if err != nil {
|
||||
return fmt.Errorf("auth: hash admin password: %w", err)
|
||||
}
|
||||
@@ -133,8 +127,8 @@ func (s *Service) ResetAdminCredentials(ctx context.Context, username string, pa
|
||||
if len(username) < 1 || len(username) > 64 || strings.ContainsAny(username, "\r\n\t") {
|
||||
return errors.New("administrator username must contain between 1 and 64 characters without control whitespace")
|
||||
}
|
||||
if password == "" {
|
||||
return ErrEmptyPassword
|
||||
if len(password) < 12 || len(password) > 1024 {
|
||||
return errors.New("administrator password must contain between 12 and 1024 characters")
|
||||
}
|
||||
if err := s.EnsureAdmin(ctx, username, password); err != nil {
|
||||
return fmt.Errorf("auth: reset administrator credentials: %w", err)
|
||||
@@ -145,13 +139,13 @@ func (s *Service) ResetAdminCredentials(ctx context.Context, username string, pa
|
||||
func (s *Service) Login(ctx context.Context, username string, password string) (Credentials, error) {
|
||||
admin, err := s.store.AdminByUsername(ctx, strings.TrimSpace(username))
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
_ = comparePassword(s.dummyHash, password)
|
||||
_ = bcrypt.CompareHashAndPassword(s.dummyHash, []byte(password))
|
||||
return Credentials{}, ErrInvalidCredentials
|
||||
}
|
||||
if err != nil {
|
||||
return Credentials{}, fmt.Errorf("auth: find admin: %w", err)
|
||||
}
|
||||
if comparePassword(admin.PasswordHash, password) != nil {
|
||||
if bcrypt.CompareHashAndPassword(admin.PasswordHash, []byte(password)) != nil {
|
||||
return Credentials{}, ErrInvalidCredentials
|
||||
}
|
||||
|
||||
@@ -290,24 +284,24 @@ func (s *Service) ChangePassword(
|
||||
currentPassword string,
|
||||
newPassword string,
|
||||
) error {
|
||||
if newPassword == "" {
|
||||
return ErrEmptyPassword
|
||||
if len(newPassword) < 12 || len(newPassword) > 1024 {
|
||||
return errors.New("new password must contain between 12 and 1024 characters")
|
||||
}
|
||||
admin, err := s.store.AdminByUsername(ctx, strings.TrimSpace(username))
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
_ = comparePassword(s.dummyHash, currentPassword)
|
||||
_ = bcrypt.CompareHashAndPassword(s.dummyHash, []byte(currentPassword))
|
||||
return ErrInvalidCredentials
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("auth: find admin: %w", err)
|
||||
}
|
||||
if comparePassword(admin.PasswordHash, currentPassword) != nil {
|
||||
if bcrypt.CompareHashAndPassword(admin.PasswordHash, []byte(currentPassword)) != nil {
|
||||
return ErrInvalidCredentials
|
||||
}
|
||||
if comparePassword(admin.PasswordHash, newPassword) == nil {
|
||||
if bcrypt.CompareHashAndPassword(admin.PasswordHash, []byte(newPassword)) == nil {
|
||||
return errors.New("new password must differ from the current password")
|
||||
}
|
||||
passwordHash, err := hashPassword(newPassword, s.bcryptCost)
|
||||
passwordHash, err := bcrypt.GenerateFromPassword([]byte(newPassword), s.bcryptCost)
|
||||
if err != nil {
|
||||
return fmt.Errorf("auth: hash new password: %w", err)
|
||||
}
|
||||
@@ -317,36 +311,6 @@ func (s *Service) ChangePassword(
|
||||
return nil
|
||||
}
|
||||
|
||||
// hashPassword keeps ordinary bcrypt hashes compatible with existing
|
||||
// installations. bcrypt rejects inputs longer than 72 bytes, so only longer
|
||||
// passwords use a tagged SHA-256 pre-hash before bcrypt.
|
||||
func hashPassword(password string, cost int) ([]byte, error) {
|
||||
material := []byte(password)
|
||||
longPassword := len(material) > bcryptPasswordLimit
|
||||
if longPassword {
|
||||
digest := sha256.Sum256(material)
|
||||
material = digest[:]
|
||||
}
|
||||
passwordHash, err := bcrypt.GenerateFromPassword(material, cost)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !longPassword {
|
||||
return passwordHash, nil
|
||||
}
|
||||
return append(append([]byte(nil), longPasswordHashPrefix...), passwordHash...), nil
|
||||
}
|
||||
|
||||
func comparePassword(passwordHash []byte, password string) error {
|
||||
material := []byte(password)
|
||||
if bytes.HasPrefix(passwordHash, longPasswordHashPrefix) {
|
||||
digest := sha256.Sum256(material)
|
||||
material = digest[:]
|
||||
passwordHash = passwordHash[len(longPasswordHashPrefix):]
|
||||
}
|
||||
return bcrypt.CompareHashAndPassword(passwordHash, material)
|
||||
}
|
||||
|
||||
func randomToken() (string, error) {
|
||||
buffer := make([]byte, 32)
|
||||
if _, err := rand.Read(buffer); err != nil {
|
||||
|
||||
@@ -3,7 +3,6 @@ package auth
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -129,7 +128,7 @@ func TestResetAdminCredentialsValidatesInput(t *testing.T) {
|
||||
}{
|
||||
{name: "empty username", password: "replacement-password"},
|
||||
{name: "control whitespace", username: "bad\tname", password: "replacement-password"},
|
||||
{name: "empty password", username: "admin", password: ""},
|
||||
{name: "short password", username: "admin", password: "short"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if err := service.ResetAdminCredentials(context.Background(), test.username, test.password); err == nil {
|
||||
@@ -139,32 +138,6 @@ func TestResetAdminCredentialsValidatesInput(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetAdminCredentialsAcceptsPasswordsWithoutComplexityRules(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
for _, password := range []string{"1", strings.Repeat("x", 256)} {
|
||||
service := newTestService(t)
|
||||
if err := service.ResetAdminCredentials(ctx, "admin", password); err != nil {
|
||||
t.Fatalf("ResetAdminCredentials(%d-byte password) error = %v", len(password), err)
|
||||
}
|
||||
if _, err := service.Login(ctx, "admin", password); err != nil {
|
||||
t.Fatalf("Login(%d-byte password) error = %v", len(password), err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangePasswordAcceptsPasswordsWithoutComplexityRules(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
for _, password := range []string{"1", strings.Repeat("long-password-", 32)} {
|
||||
service := newTestService(t)
|
||||
if err := service.ChangePassword(ctx, "admin", "correct-password", password); err != nil {
|
||||
t.Fatalf("ChangePassword(%d-byte password) error = %v", len(password), err)
|
||||
}
|
||||
if _, err := service.Login(ctx, "admin", password); err != nil {
|
||||
t.Fatalf("Login(%d-byte password) error = %v", len(password), err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureAdminIfMissingDoesNotOverwriteChangedPassword(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
service := newTestService(t)
|
||||
|
||||
@@ -506,7 +506,7 @@ func (s *Server) handlePasswordChange(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case errors.Is(err, auth.ErrInvalidCredentials):
|
||||
writeError(w, http.StatusUnauthorized, "invalid_credentials", "current password is incorrect")
|
||||
case errors.Is(err, auth.ErrEmptyPassword):
|
||||
case strings.Contains(err.Error(), "between 12 and 1024"):
|
||||
writeError(w, http.StatusBadRequest, "weak_password", err.Error())
|
||||
case strings.Contains(err.Error(), "must differ"):
|
||||
writeError(w, http.StatusBadRequest, "password_reused", err.Error())
|
||||
|
||||
@@ -29,7 +29,7 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
errUnsafeDestination = errors.New("notification destination is not allowed")
|
||||
errUnsafeDestination = errors.New("notification destination is not public")
|
||||
errProviderRejected = errors.New("notification provider rejected the test")
|
||||
telegramTokenPattern = regexp.MustCompile(`^[0-9]{5,20}:[A-Za-z0-9_-]{20,128}$`)
|
||||
)
|
||||
@@ -459,7 +459,7 @@ func (s *Server) handleNotificationTest(
|
||||
w,
|
||||
http.StatusBadRequest,
|
||||
"unsafe_destination",
|
||||
"notification destination resolved to an unusable or protected system address",
|
||||
"notification destination must resolve only to public network addresses",
|
||||
)
|
||||
case errors.Is(err, errProviderRejected):
|
||||
writeError(
|
||||
@@ -905,12 +905,11 @@ func restrictedHTTPClient(
|
||||
},
|
||||
}
|
||||
if strings.TrimSpace(proxy) != "" {
|
||||
parsed, err := validateNotificationProxyURL(ctx, proxy)
|
||||
parsed, err := validateOutboundURL(ctx, proxy, false)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("validate notification proxy: %w", err)
|
||||
}
|
||||
transport.Proxy = http.ProxyURL(parsed)
|
||||
transport.DialContext = notificationProxyDialer(timeout)
|
||||
}
|
||||
return &http.Client{
|
||||
Transport: transport,
|
||||
@@ -953,17 +952,6 @@ func validateOutboundURL(
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func validateNotificationProxyURL(ctx context.Context, raw string) (*url.URL, error) {
|
||||
parsed, err := parseOutboundURL(raw, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := resolveNotificationProxyAddresses(ctx, parsed.Hostname()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func parseOutboundURL(raw string, requireHTTPS bool) (*url.URL, error) {
|
||||
parsed, err := url.Parse(strings.TrimSpace(raw))
|
||||
if err != nil || parsed.Hostname() == "" || parsed.IsAbs() == false {
|
||||
@@ -997,42 +985,17 @@ func restrictedDialer(timeout time.Duration) func(
|
||||
}
|
||||
}
|
||||
|
||||
func notificationProxyDialer(timeout time.Duration) func(
|
||||
context.Context,
|
||||
string,
|
||||
string,
|
||||
) (net.Conn, error) {
|
||||
return func(ctx context.Context, network string, address string) (net.Conn, error) {
|
||||
return dialNotification(ctx, network, address, timeout, true)
|
||||
}
|
||||
}
|
||||
|
||||
func dialRestricted(
|
||||
ctx context.Context,
|
||||
network string,
|
||||
address string,
|
||||
timeout time.Duration,
|
||||
) (net.Conn, error) {
|
||||
return dialNotification(ctx, network, address, timeout, false)
|
||||
}
|
||||
|
||||
func dialNotification(
|
||||
ctx context.Context,
|
||||
network string,
|
||||
address string,
|
||||
timeout time.Duration,
|
||||
allowLocal bool,
|
||||
) (net.Conn, error) {
|
||||
host, port, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse outbound address: %w", err)
|
||||
}
|
||||
var addresses []netip.Addr
|
||||
if allowLocal {
|
||||
addresses, err = resolveNotificationProxyAddresses(ctx, host)
|
||||
} else {
|
||||
addresses, err = resolvePublicAddresses(ctx, host)
|
||||
}
|
||||
addresses, err := resolvePublicAddresses(ctx, host)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1108,68 +1071,54 @@ func dialNotification(
|
||||
if len(failures) == 0 {
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
return nil, fmt.Errorf("dial notification destination: %w", errors.Join(failures...))
|
||||
return nil, fmt.Errorf("dial public notification destination: %w", errors.Join(failures...))
|
||||
}
|
||||
|
||||
type notificationAllowedNetworksKey struct{}
|
||||
|
||||
func (s *Server) notificationDestinationContext(ctx context.Context) context.Context {
|
||||
if ctx == nil {
|
||||
return context.Background()
|
||||
ctx = context.Background()
|
||||
}
|
||||
// Notification delivery is outbound administrator-configured traffic. It
|
||||
// must not inherit the inbound Web access policy: DNS Fake-IP ranges, LAN
|
||||
// gateways, and local proxies are valid notification paths.
|
||||
return ctx
|
||||
access := s.currentAccessConfig()
|
||||
return context.WithValue(ctx, notificationAllowedNetworksKey{}, append([]netip.Prefix(nil), access.cidrs...))
|
||||
}
|
||||
|
||||
func notificationAddressAllowed(_ context.Context, address netip.Addr) bool {
|
||||
func notificationAddressAllowed(ctx context.Context, address netip.Addr) bool {
|
||||
address = address.Unmap()
|
||||
if !notificationTransportAddress(address) {
|
||||
// Even an administrator-provided exception must never turn a notification
|
||||
// endpoint into a loopback or cloud-metadata request. Private/LAN and
|
||||
// benchmark ranges may be explicitly allowed for local push gateways and
|
||||
// DNS Fake-IP deployments, but these process-local destinations stay closed.
|
||||
if !address.IsValid() || address.IsUnspecified() || address.IsLoopback() ||
|
||||
address.IsMulticast() || address.IsLinkLocalUnicast() ||
|
||||
address == netip.MustParseAddr("100.100.100.200") {
|
||||
return false
|
||||
}
|
||||
for _, fakeIP := range notificationFakeIPNetworks {
|
||||
if fakeIP.Contains(address) {
|
||||
if publicNotificationAddress(address) {
|
||||
return true
|
||||
}
|
||||
prefixes, _ := ctx.Value(notificationAllowedNetworksKey{}).([]netip.Prefix)
|
||||
for _, prefix := range prefixes {
|
||||
if prefix.Contains(address) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if !address.IsGlobalUnicast() {
|
||||
return false
|
||||
}
|
||||
for _, blocked := range blockedNotificationDestinationNetworks {
|
||||
if blocked.Contains(address) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func notificationProxyAddressAllowed(address netip.Addr) bool {
|
||||
return notificationTransportAddress(address.Unmap())
|
||||
}
|
||||
|
||||
func notificationTransportAddress(address netip.Addr) bool {
|
||||
return address.IsValid() && !address.IsUnspecified() && !address.IsMulticast() &&
|
||||
!address.IsLinkLocalUnicast() && !address.IsLinkLocalMulticast() &&
|
||||
address != netip.MustParseAddr("255.255.255.255") &&
|
||||
address != netip.MustParseAddr("100.100.100.200")
|
||||
return false
|
||||
}
|
||||
|
||||
func resolvePublicAddresses(ctx context.Context, host string) ([]netip.Addr, error) {
|
||||
return resolveNotificationAddresses(ctx, host, false)
|
||||
}
|
||||
|
||||
func resolveNotificationProxyAddresses(ctx context.Context, host string) ([]netip.Addr, error) {
|
||||
return resolveNotificationAddresses(ctx, host, true)
|
||||
}
|
||||
|
||||
func resolveNotificationAddresses(ctx context.Context, host string, allowLocal bool) ([]netip.Addr, error) {
|
||||
normalized := strings.ToLower(strings.TrimSuffix(strings.TrimSpace(host), "."))
|
||||
if normalized == "" {
|
||||
if normalized == "" || normalized == "localhost" ||
|
||||
strings.HasSuffix(normalized, ".localhost") ||
|
||||
normalized == "metadata" ||
|
||||
strings.HasSuffix(normalized, ".internal") ||
|
||||
strings.HasSuffix(normalized, ".local") {
|
||||
return nil, fmt.Errorf("%w: blocked host name", errUnsafeDestination)
|
||||
}
|
||||
if literal, err := netip.ParseAddr(normalized); err == nil {
|
||||
literal = literal.Unmap()
|
||||
if (!allowLocal && !notificationAddressAllowed(ctx, literal)) ||
|
||||
(allowLocal && !notificationProxyAddressAllowed(literal)) {
|
||||
if !notificationAddressAllowed(ctx, literal) {
|
||||
return nil, fmt.Errorf("%w: %s", errUnsafeDestination, literal)
|
||||
}
|
||||
return []netip.Addr{literal}, nil
|
||||
@@ -1184,8 +1133,7 @@ func resolveNotificationAddresses(ctx context.Context, host string, allowLocal b
|
||||
result := make([]netip.Addr, 0, len(addresses))
|
||||
for _, address := range addresses {
|
||||
address = address.Unmap()
|
||||
if (!allowLocal && !notificationAddressAllowed(ctx, address)) ||
|
||||
(allowLocal && !notificationProxyAddressAllowed(address)) {
|
||||
if !notificationAddressAllowed(ctx, address) {
|
||||
return nil, fmt.Errorf("%w: %s", errUnsafeDestination, address)
|
||||
}
|
||||
result = append(result, address)
|
||||
@@ -1193,11 +1141,7 @@ func resolveNotificationAddresses(ctx context.Context, host string, allowLocal b
|
||||
return result, nil
|
||||
}
|
||||
|
||||
var notificationFakeIPNetworks = []netip.Prefix{
|
||||
netip.MustParsePrefix("198.18.0.0/15"),
|
||||
}
|
||||
|
||||
var blockedNotificationDestinationNetworks = []netip.Prefix{
|
||||
var blockedNotificationNetworks = []netip.Prefix{
|
||||
netip.MustParsePrefix("0.0.0.0/8"),
|
||||
netip.MustParsePrefix("10.0.0.0/8"),
|
||||
netip.MustParsePrefix("100.64.0.0/10"),
|
||||
@@ -1208,6 +1152,7 @@ var blockedNotificationDestinationNetworks = []netip.Prefix{
|
||||
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"),
|
||||
@@ -1222,6 +1167,19 @@ var blockedNotificationDestinationNetworks = []netip.Prefix{
|
||||
netip.MustParsePrefix("ff00::/8"),
|
||||
}
|
||||
|
||||
func publicNotificationAddress(address netip.Addr) bool {
|
||||
if !address.IsValid() || !address.IsGlobalUnicast() {
|
||||
return false
|
||||
}
|
||||
address = address.Unmap()
|
||||
for _, blocked := range blockedNotificationNetworks {
|
||||
if blocked.Contains(address) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func configString(config map[string]any, key string) string {
|
||||
value, _ := config[key].(string)
|
||||
return strings.TrimSpace(value)
|
||||
|
||||
@@ -369,10 +369,6 @@ func TestNotificationTestsBlockSSRFAndUnsupportedChannels(t *testing.T) {
|
||||
if recorder.Code != http.StatusBadRequest {
|
||||
t.Fatalf("Telegram metadata status = %d, body = %s", recorder.Code, recorder.Body)
|
||||
}
|
||||
response = decodeSettingsResponse(t, recorder)
|
||||
if response["error"].(map[string]any)["code"] != "unsafe_destination" {
|
||||
t.Fatalf("Telegram metadata response = %#v", response)
|
||||
}
|
||||
|
||||
recorder = test.request(
|
||||
t,
|
||||
@@ -766,28 +762,26 @@ func TestTrafficAnalysisIsUnavailableOutsideDeveloperMode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationDestinationAddressPolicyIsIndependentFromWebAccess(t *testing.T) {
|
||||
func TestNotificationDestinationAddressPolicy(t *testing.T) {
|
||||
blocked := []string{
|
||||
"0.0.0.0", "10.0.0.1", "100.100.100.200", "127.0.0.1",
|
||||
"169.254.169.254", "172.16.0.1", "192.168.1.1", "224.0.0.1",
|
||||
"255.255.255.255", "::", "::1", "fc00::1", "fe80::1", "ff02::1",
|
||||
"169.254.169.254", "172.16.0.1", "192.168.1.1", "198.18.0.1",
|
||||
"::1", "fc00::1", "fe80::1", "2001:db8::1",
|
||||
}
|
||||
for _, text := range blocked {
|
||||
address := netip.MustParseAddr(text)
|
||||
if notificationAddressAllowed(context.Background(), address) {
|
||||
t.Errorf("%s was incorrectly accepted for notification transport", text)
|
||||
if publicNotificationAddress(address) {
|
||||
t.Errorf("%s was incorrectly accepted as public", text)
|
||||
}
|
||||
}
|
||||
for _, text := range []string{
|
||||
"1.1.1.1", "198.18.0.1", "2606:4700:4700::1111",
|
||||
} {
|
||||
for _, text := range []string{"1.1.1.1", "8.8.8.8", "2606:4700:4700::1111"} {
|
||||
address := netip.MustParseAddr(text)
|
||||
if !notificationAddressAllowed(context.Background(), address) {
|
||||
t.Errorf("%s was incorrectly blocked for notification transport", text)
|
||||
if !publicNotificationAddress(address) {
|
||||
t.Errorf("%s was incorrectly blocked", text)
|
||||
}
|
||||
}
|
||||
if _, err := resolvePublicAddresses(context.Background(), "localhost"); err == nil {
|
||||
t.Fatal("local notification destination was not blocked")
|
||||
t.Fatal("localhost was not blocked")
|
||||
}
|
||||
if _, err := resolvePublicAddresses(
|
||||
context.Background(),
|
||||
@@ -795,53 +789,27 @@ func TestNotificationDestinationAddressPolicyIsIndependentFromWebAccess(t *testi
|
||||
); err == nil {
|
||||
t.Fatal("metadata IP was not blocked")
|
||||
}
|
||||
server := &Server{access: parsedAccessConfig{mode: "internal"}}
|
||||
notificationContext := server.notificationDestinationContext(context.Background())
|
||||
if addresses, err := resolvePublicAddresses(notificationContext, "198.18.0.1"); err != nil || len(addresses) != 1 {
|
||||
t.Fatalf("Fake-IP notification destination = %v, %v", addresses, err)
|
||||
allowedContext := context.WithValue(
|
||||
context.Background(),
|
||||
notificationAllowedNetworksKey{},
|
||||
[]netip.Prefix{netip.MustParsePrefix("198.18.0.0/15")},
|
||||
)
|
||||
if addresses, err := resolvePublicAddresses(allowedContext, "198.18.0.1"); err != nil || len(addresses) != 1 {
|
||||
t.Fatalf("explicit Fake-IP notification allowlist = %v, %v", addresses, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationProxyAcceptsLocalAddressWithoutWebAccessAllowlist(t *testing.T) {
|
||||
server := &Server{access: parsedAccessConfig{mode: "internal"}}
|
||||
ctx := server.notificationDestinationContext(context.Background())
|
||||
for _, host := range []string{"127.0.0.1", "10.0.0.1", "192.168.1.1", "198.18.0.1", "::1"} {
|
||||
if addresses, err := resolveNotificationProxyAddresses(ctx, host); err != nil || len(addresses) != 1 {
|
||||
t.Errorf("local notification proxy %s = %v, %v", host, addresses, err)
|
||||
if _, err := resolvePublicAddresses(allowedContext, "169.254.169.254"); err == nil {
|
||||
t.Fatal("unlisted metadata IP was allowed")
|
||||
}
|
||||
wideAllowedContext := context.WithValue(
|
||||
context.Background(),
|
||||
notificationAllowedNetworksKey{},
|
||||
[]netip.Prefix{netip.MustParsePrefix("0.0.0.0/0")},
|
||||
)
|
||||
for _, address := range []string{"127.0.0.1", "169.254.169.254", "100.100.100.200"} {
|
||||
if _, err := resolvePublicAddresses(wideAllowedContext, address); err == nil {
|
||||
t.Fatalf("non-overridable destination %s was allowed", address)
|
||||
}
|
||||
}
|
||||
if _, err := resolveNotificationProxyAddresses(ctx, "169.254.169.254"); err == nil {
|
||||
t.Fatal("cloud metadata address was accepted as a notification proxy")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestrictedNotificationClientConnectsThroughLocalProxy(t *testing.T) {
|
||||
var hits atomic.Int32
|
||||
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
|
||||
hits.Add(1)
|
||||
if request.URL.Host != "1.1.1.1" {
|
||||
t.Errorf("proxy request host = %q", request.URL.Host)
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer proxy.Close()
|
||||
|
||||
client, err := restrictedHTTPClient(context.Background(), 2*time.Second, proxy.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request, err := http.NewRequest(http.MethodGet, "http://1.1.1.1/test", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = response.Body.Close()
|
||||
if response.StatusCode != http.StatusNoContent || hits.Load() != 1 {
|
||||
t.Fatalf("local proxy status = %d, hits = %d", response.StatusCode, hits.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestrictedNotificationClientCapsTimeoutAndRedirects(t *testing.T) {
|
||||
|
||||
@@ -3,6 +3,7 @@ package server
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -57,8 +58,10 @@ func TestTelegramAPIURLRejectsMalformedTemplates(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramPollingAcceptsFakeIPWithoutWebAccessAllowlist(t *testing.T) {
|
||||
bot := &telegramBot{server: &Server{access: parsedAccessConfig{mode: "internal"}}}
|
||||
func TestTelegramPollingUsesExplicitFakeIPDestinationAllowlist(t *testing.T) {
|
||||
bot := &telegramBot{server: &Server{access: parsedAccessConfig{
|
||||
cidrs: []netip.Prefix{netip.MustParsePrefix("198.18.0.0/15")},
|
||||
}}}
|
||||
ctx := bot.notificationDestinationContext(context.Background())
|
||||
if _, err := validateTelegramAPIURL(ctx, "https://198.18.0.34", "123456:test-token", "getUpdates"); err != nil {
|
||||
t.Fatalf("explicitly allowed Telegram Fake-IP was rejected: %v", err)
|
||||
|
||||
@@ -220,124 +220,6 @@ func TestMigration8DefaultsExistingDevicesToPCIeType(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigration19AcceptsDevelopmentDatabaseAndPreservesCardData(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
path := filepath.Join(t.TempDir(), "development-schema.db")
|
||||
raw, err := sql.Open("sqlite", path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for version := 1; version <= 16; 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 ('ec20-1', 'EC20', 100, 100);
|
||||
INSERT INTO card_policies (
|
||||
iccid, network_enabled, vowifi_enabled, airplane_enabled,
|
||||
apn, ip_version, source, created_at, updated_at, custom_phone_number
|
||||
) VALUES (
|
||||
'8900000000000000019', 0, 1, 1,
|
||||
'ims', 'IPV4V6', 'user', 100, 100, '447700900019'
|
||||
);
|
||||
INSERT INTO card_apn_profiles (
|
||||
iccid, apn, ip_version, created_at, updated_at,
|
||||
username, password, proxy, mcc, mnc, roaming_ip_version, auth_type
|
||||
) VALUES (
|
||||
'8900000000000000019', 'mobile.example', 'IPV4V6', 100, 100,
|
||||
'user', 'secret', '', '234', '10', 'IP', 'PAP'
|
||||
);
|
||||
PRAGMA user_version = 16;
|
||||
`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := raw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
database := openTestStore(t, path)
|
||||
policy, err := database.CardPolicy(ctx, "8900000000000000019")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !policy.VoWiFiEnabled || !policy.AirplaneEnabled || policy.CustomPhoneNumber != "447700900019" {
|
||||
t.Fatalf("migrated card policy = %#v", policy)
|
||||
}
|
||||
profiles, err := database.ListCardAPNProfiles(ctx, "8900000000000000019")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(profiles) != 1 || profiles[0].APN != "mobile.example" || profiles[0].Username != "user" || profiles[0].AuthType != "PAP" {
|
||||
t.Fatalf("migrated APN profiles = %#v", profiles)
|
||||
}
|
||||
|
||||
var version int
|
||||
if err := database.db.QueryRowContext(ctx, `PRAGMA user_version`).Scan(&version); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if version != 19 {
|
||||
t.Fatalf("schema version = %d, want 19", version)
|
||||
}
|
||||
for _, column := range []string{
|
||||
"ims_apn", "ims_private_identity", "ims_public_identity", "ims_sms_center",
|
||||
"ims_transport", "ims_allow_imsi_derived_identity", "vowifi_eap_method",
|
||||
"vowifi_allow_sha1", "vowifi_use_modp1024",
|
||||
} {
|
||||
var count int
|
||||
if err := database.db.QueryRowContext(ctx, `
|
||||
SELECT COUNT(*) FROM pragma_table_info('devices') WHERE name = ?
|
||||
`, column).Scan(&count); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("migration 19 column %q count = %d", column, count)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigration19AcceptsDevelopmentColumnsAlreadyPresent(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
path := filepath.Join(t.TempDir(), "development-columns.db")
|
||||
raw, err := sql.Open("sqlite", path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for version := 1; version <= 18; version++ {
|
||||
for _, statement := range migrationStatements(version) {
|
||||
if _, err := raw.ExecContext(ctx, statement); err != nil {
|
||||
t.Fatalf("create v%d schema: %v", version, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
// The development build added these columns while still reporting schema
|
||||
// 18. Migration 19 must treat that layout as compatible rather than fail on
|
||||
// the first duplicate ALTER TABLE statement.
|
||||
for _, statement := range migrationStatements(19) {
|
||||
if _, err := raw.ExecContext(ctx, statement); err != nil {
|
||||
t.Fatalf("create development column: %v", err)
|
||||
}
|
||||
}
|
||||
if _, err := raw.ExecContext(ctx, `PRAGMA user_version = 18`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := raw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
database := openTestStore(t, path)
|
||||
var version int
|
||||
if err := database.db.QueryRowContext(ctx, `PRAGMA user_version`).Scan(&version); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if version != 19 {
|
||||
t.Fatalf("schema version = %d, want 19", version)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigration4PreservesIMSRedeliveryAndUsesReceiptTime(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
path := filepath.Join(t.TempDir(), "ims-redelivery.db")
|
||||
|
||||
@@ -264,123 +264,6 @@ func migrationStatements(version int) []string {
|
||||
return []string{
|
||||
`ALTER TABLE devices ADD COLUMN sim_pin TEXT NOT NULL DEFAULT ''`,
|
||||
}
|
||||
case 17:
|
||||
// Some development builds recorded automatic-task support in an older
|
||||
// migration. Recreate the objects idempotently so databases from either
|
||||
// history converge before later migrations run.
|
||||
return []string{
|
||||
`CREATE TABLE IF NOT EXISTS automatic_tasks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
|
||||
device_id TEXT NOT NULL,
|
||||
profile_iccid TEXT NOT NULL,
|
||||
profile_aid TEXT NOT NULL DEFAULT '',
|
||||
task_type TEXT NOT NULL CHECK (task_type IN ('sms', 'call', 'public_ip')),
|
||||
environment TEXT NOT NULL CHECK (environment IN ('vowifi', 'cellular')),
|
||||
interval_days INTEGER NOT NULL CHECK (interval_days BETWEEN 1 AND 365),
|
||||
start_date TEXT NOT NULL,
|
||||
run_time TEXT NOT NULL,
|
||||
timezone TEXT NOT NULL DEFAULT 'Local',
|
||||
payload_json TEXT NOT NULL DEFAULT '{}',
|
||||
retry_count INTEGER NOT NULL DEFAULT 0 CHECK (retry_count BETWEEN 0 AND 10),
|
||||
notify INTEGER NOT NULL DEFAULT 0 CHECK (notify IN (0, 1)),
|
||||
next_run_at INTEGER NOT NULL,
|
||||
last_run_at INTEGER NOT NULL DEFAULT 0,
|
||||
last_status TEXT NOT NULL DEFAULT '',
|
||||
last_error TEXT NOT NULL DEFAULT '',
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
FOREIGN KEY (device_id) REFERENCES devices(id) ON DELETE CASCADE
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS automatic_tasks_due_idx ON automatic_tasks(enabled, next_run_at, id)`,
|
||||
`CREATE INDEX IF NOT EXISTS automatic_tasks_device_idx ON automatic_tasks(device_id, next_run_at, id)`,
|
||||
`CREATE TABLE IF NOT EXISTS automatic_task_runs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
task_id INTEGER NOT NULL,
|
||||
device_id TEXT NOT NULL,
|
||||
scheduled_at INTEGER NOT NULL,
|
||||
started_at INTEGER NOT NULL DEFAULT 0,
|
||||
finished_at INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL CHECK (status IN ('queued', 'running', 'success', 'failed')),
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
output TEXT NOT NULL DEFAULT '',
|
||||
error TEXT NOT NULL DEFAULT '',
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
FOREIGN KEY (task_id) REFERENCES automatic_tasks(id) ON DELETE CASCADE
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS automatic_task_runs_task_idx ON automatic_task_runs(task_id, id DESC)`,
|
||||
`CREATE INDEX IF NOT EXISTS automatic_task_runs_status_idx ON automatic_task_runs(status, id)`,
|
||||
}
|
||||
case 18:
|
||||
// A short-lived schema lineage kept the original card-policy CHECK,
|
||||
// which rejected the supported VoWiFi + airplane-mode state. Rebuild
|
||||
// both related tables so all released and development databases converge
|
||||
// without dropping policies or custom APNs.
|
||||
return []string{
|
||||
`ALTER TABLE card_apn_profiles RENAME TO card_apn_profiles_v17`,
|
||||
`ALTER TABLE card_policies RENAME TO card_policies_v17`,
|
||||
`CREATE TABLE card_policies (
|
||||
iccid TEXT PRIMARY KEY,
|
||||
network_enabled INTEGER NOT NULL DEFAULT 0 CHECK (network_enabled IN (0, 1)),
|
||||
vowifi_enabled INTEGER NOT NULL DEFAULT 0 CHECK (vowifi_enabled IN (0, 1)),
|
||||
airplane_enabled INTEGER NOT NULL DEFAULT 0 CHECK (airplane_enabled IN (0, 1)),
|
||||
apn TEXT NOT NULL DEFAULT '',
|
||||
ip_version TEXT NOT NULL DEFAULT '',
|
||||
source TEXT NOT NULL DEFAULT '',
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
custom_phone_number TEXT NOT NULL DEFAULT ''
|
||||
)`,
|
||||
`INSERT INTO card_policies (
|
||||
iccid, network_enabled, vowifi_enabled, airplane_enabled,
|
||||
apn, ip_version, source, created_at, updated_at, custom_phone_number
|
||||
) SELECT
|
||||
iccid, network_enabled, vowifi_enabled, airplane_enabled,
|
||||
apn, ip_version, source, created_at, updated_at, custom_phone_number
|
||||
FROM card_policies_v17`,
|
||||
`CREATE TABLE card_apn_profiles_new (
|
||||
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,
|
||||
username TEXT NOT NULL DEFAULT '',
|
||||
password TEXT NOT NULL DEFAULT '',
|
||||
proxy TEXT NOT NULL DEFAULT '',
|
||||
mcc TEXT NOT NULL DEFAULT '',
|
||||
mnc TEXT NOT NULL DEFAULT '',
|
||||
roaming_ip_version TEXT NOT NULL DEFAULT 'IP' CHECK (roaming_ip_version IN ('IP', 'IPV6', 'IPV4V6')),
|
||||
auth_type TEXT NOT NULL DEFAULT 'NONE' CHECK (auth_type IN ('NONE', 'PAP', 'CHAP', 'PAP_OR_CHAP')),
|
||||
UNIQUE (iccid, apn, ip_version),
|
||||
FOREIGN KEY (iccid) REFERENCES card_policies(iccid) ON DELETE CASCADE
|
||||
)`,
|
||||
`INSERT INTO card_apn_profiles_new
|
||||
SELECT id, iccid, apn, ip_version, created_at, updated_at,
|
||||
username, password, proxy, mcc, mnc, roaming_ip_version, auth_type
|
||||
FROM card_apn_profiles_v17`,
|
||||
`DROP TABLE card_apn_profiles_v17`,
|
||||
`DROP TABLE card_policies_v17`,
|
||||
`ALTER TABLE card_apn_profiles_new RENAME TO card_apn_profiles`,
|
||||
`CREATE INDEX card_apn_profiles_iccid_idx ON card_apn_profiles(iccid, id)`,
|
||||
}
|
||||
case 19:
|
||||
// Compatibility columns written by the Qualcomm/IMS development build.
|
||||
// The stable server may leave them unused, but retaining them makes a
|
||||
// database created by that build safely readable after an upgrade.
|
||||
return []string{
|
||||
`ALTER TABLE devices ADD COLUMN ims_apn TEXT NOT NULL DEFAULT 'ims'`,
|
||||
`ALTER TABLE devices ADD COLUMN ims_private_identity TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE devices ADD COLUMN ims_public_identity TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE devices ADD COLUMN ims_sms_center TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE devices ADD COLUMN ims_transport TEXT NOT NULL DEFAULT 'tcp'`,
|
||||
`ALTER TABLE devices ADD COLUMN ims_allow_imsi_derived_identity INTEGER NOT NULL DEFAULT 1 CHECK (ims_allow_imsi_derived_identity IN (0, 1))`,
|
||||
`ALTER TABLE devices ADD COLUMN vowifi_eap_method TEXT NOT NULL DEFAULT 'aka'`,
|
||||
`ALTER TABLE devices ADD COLUMN vowifi_allow_sha1 INTEGER NOT NULL DEFAULT 0 CHECK (vowifi_allow_sha1 IN (0, 1))`,
|
||||
`ALTER TABLE devices ADD COLUMN vowifi_use_modp1024 INTEGER NOT NULL DEFAULT 0 CHECK (vowifi_use_modp1024 IN (0, 1))`,
|
||||
}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
const schemaVersion = 19
|
||||
const schemaVersion = 16
|
||||
|
||||
var ErrNotFound = errors.New("store: not found")
|
||||
|
||||
@@ -123,8 +123,7 @@ func migrate(ctx context.Context, db *sql.DB) error {
|
||||
duplicateAdditiveColumn := (nextVersion == 7 && strings.Contains(statement, "ADD COLUMN modem_imei")) ||
|
||||
(nextVersion == 8 && strings.Contains(statement, "ADD COLUMN device_type")) ||
|
||||
(nextVersion == 14 && strings.Contains(statement, "ADD COLUMN")) ||
|
||||
(nextVersion == 16 && strings.Contains(statement, "ADD COLUMN sim_pin")) ||
|
||||
(nextVersion == 19 && strings.Contains(statement, "ADD COLUMN"))
|
||||
(nextVersion == 16 && strings.Contains(statement, "ADD COLUMN sim_pin"))
|
||||
if duplicateAdditiveColumn && strings.Contains(strings.ToLower(err.Error()), "duplicate column name") {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
@@ -28,26 +22,3 @@ func TestAssetNamesFor(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadAssetWithProgressVerifiesPublishedSize(t *testing.T) {
|
||||
payload := bytes.Repeat([]byte("vocat"), 4096)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write(payload)
|
||||
}))
|
||||
defer server.Close()
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
|
||||
var destination bytes.Buffer
|
||||
asset := &Asset{Name: "vocat-test", BrowserDownloadURL: server.URL, Size: int64(len(payload))}
|
||||
if err := downloadAssetWithProgress(context.Background(), logger, asset, "", &destination); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(destination.Bytes(), payload) {
|
||||
t.Fatal("downloaded asset content differs")
|
||||
}
|
||||
|
||||
asset.Size++
|
||||
if err := downloadAssetWithProgress(context.Background(), logger, asset, "", io.Discard); err == nil {
|
||||
t.Fatal("download with a mismatched published size succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,14 +2,11 @@ package update
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Release mirrors the subset of the GitHub releases API response that the
|
||||
@@ -43,23 +40,6 @@ const (
|
||||
DefaultRepository = "MengMengCode/VoCat"
|
||||
)
|
||||
|
||||
var githubHTTPClient = &http.Client{
|
||||
Transport: &http.Transport{
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: 10 * time.Second,
|
||||
KeepAlive: 30 * time.Second,
|
||||
}).DialContext,
|
||||
ForceAttemptHTTP2: true,
|
||||
TLSHandshakeTimeout: 15 * time.Second,
|
||||
ResponseHeaderTimeout: 20 * time.Second,
|
||||
ExpectContinueTimeout: time.Second,
|
||||
TLSClientConfig: &tls.Config{
|
||||
MinVersion: tls.VersionTLS12,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// LatestRelease fetches the newest published release for repo (form
|
||||
// "owner/name"). A non-empty token is sent as a Bearer header, which is
|
||||
// required for private repositories and lifts the unauthenticated rate limit.
|
||||
@@ -81,7 +61,7 @@ func LatestRelease(ctx context.Context, repo, token string) (*Release, error) {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
|
||||
resp, err := githubHTTPClient.Do(req)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("update: fetch latest release: %w", err)
|
||||
}
|
||||
@@ -140,7 +120,7 @@ func downloadAsset(ctx context.Context, url, token string, dst io.Writer) error
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
resp, err := githubHTTPClient.Do(req)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update: download asset: %w", err)
|
||||
}
|
||||
|
||||
@@ -15,14 +15,12 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"vocat/internal/buildinfo"
|
||||
@@ -151,7 +149,7 @@ func applyUpdate(ctx context.Context, logger *slog.Logger, opts Options, release
|
||||
}()
|
||||
|
||||
logger.Info("downloading binary", "asset", asset.Name, "size", asset.Size, "url", asset.BrowserDownloadURL)
|
||||
if err := downloadAssetWithProgress(ctx, logger, asset, opts.Token, tmp); err != nil {
|
||||
if err := downloadAsset(ctx, asset.BrowserDownloadURL, opts.Token, tmp); err != nil {
|
||||
cleanup()
|
||||
return err
|
||||
}
|
||||
@@ -208,68 +206,6 @@ func applyUpdate(ctx context.Context, logger *slog.Logger, opts Options, release
|
||||
return nil
|
||||
}
|
||||
|
||||
type downloadProgressWriter struct {
|
||||
destination io.Writer
|
||||
downloaded atomic.Int64
|
||||
}
|
||||
|
||||
func (writer *downloadProgressWriter) Write(data []byte) (int, error) {
|
||||
written, err := writer.destination.Write(data)
|
||||
writer.downloaded.Add(int64(written))
|
||||
return written, err
|
||||
}
|
||||
|
||||
func downloadAssetWithProgress(
|
||||
ctx context.Context,
|
||||
logger *slog.Logger,
|
||||
asset *Asset,
|
||||
token string,
|
||||
destination io.Writer,
|
||||
) error {
|
||||
progress := &downloadProgressWriter{destination: destination}
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
ticker := time.NewTicker(5 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
downloaded := progress.downloaded.Load()
|
||||
percent := float64(0)
|
||||
if asset.Size > 0 {
|
||||
percent = float64(downloaded) * 100 / float64(asset.Size)
|
||||
}
|
||||
logger.Info(
|
||||
"download progress",
|
||||
"asset", asset.Name,
|
||||
"downloaded", downloaded,
|
||||
"total", asset.Size,
|
||||
"percent", fmt.Sprintf("%.1f", percent),
|
||||
)
|
||||
}
|
||||
}
|
||||
}()
|
||||
err := downloadAsset(ctx, asset.BrowserDownloadURL, token, progress)
|
||||
close(done)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if asset.Size > 0 && progress.downloaded.Load() != asset.Size {
|
||||
return fmt.Errorf(
|
||||
"update: asset size mismatch for %s: downloaded %d bytes, expected %d",
|
||||
asset.Name,
|
||||
progress.downloaded.Load(),
|
||||
asset.Size,
|
||||
)
|
||||
}
|
||||
logger.Info("download completed", "asset", asset.Name, "bytes", progress.downloaded.Load())
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateExecutable catches incompatible architectures and missing dynamic
|
||||
// loaders before the working installation is touched. A valid checksum alone
|
||||
// cannot detect those packaging errors.
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
package vowifi
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const att310280EPDG = "epdg.epc.att.net"
|
||||
|
||||
// AssignedRoutePLMN returns a narrowly matched ePDG route PLMN without
|
||||
// changing the subscription PLMN used for AKA identities. Some multi-profile
|
||||
// and MVNO SIMs authenticate against their own HPLMN but use a host network's
|
||||
// VoWiFi access gateway.
|
||||
func AssignedRoutePLMN(iccid, imsi string) (string, string, bool) {
|
||||
iccid = strings.TrimSpace(iccid)
|
||||
imsi = strings.TrimSpace(imsi)
|
||||
switch {
|
||||
case strings.HasPrefix(iccid, "894416") && strings.HasPrefix(imsi, "204047"):
|
||||
// XeSIM/Lebara: keep 204/04 for AKA and use Vodafone UK's ePDG.
|
||||
return "234", "15", true
|
||||
case strings.HasPrefix(iccid, "894430") && strings.HasPrefix(imsi, "23433"):
|
||||
// CTExcel UK: keep 234/33 for AKA and use the EE UK ePDG used by
|
||||
// the initial VoWiFi provisioning path.
|
||||
return "234", "30", true
|
||||
default:
|
||||
return "", "", false
|
||||
}
|
||||
}
|
||||
|
||||
// IsATT310280 reports whether the live subscription is on AT&T's three-digit
|
||||
// 310/280 PLMN. It is shared by SWu and IMS so the carrier exception cannot
|
||||
// drift between protocol layers.
|
||||
func IsATT310280(identity SIMIdentity) bool {
|
||||
mcc := strings.TrimSpace(identity.HomeMCC)
|
||||
mnc := strings.TrimLeft(strings.TrimSpace(identity.HomeMNC), "0")
|
||||
imsi := strings.TrimSpace(identity.IMSI)
|
||||
return mcc == "310" && mnc == "280" && strings.HasPrefix(imsi, "310280")
|
||||
}
|
||||
|
||||
func applyAssignedCarrierRoute(identity SIMIdentity) SIMIdentity {
|
||||
if strings.TrimSpace(identity.EPDG) != "" {
|
||||
return identity
|
||||
}
|
||||
if routeMCC, routeMNC, ok := AssignedRoutePLMN(identity.ICCID, identity.IMSI); ok {
|
||||
identity.EPDG = standardEPDGHostname(routeMCC, routeMNC)
|
||||
}
|
||||
return identity
|
||||
}
|
||||
|
||||
func standardEPDGHostname(mcc, mnc string) string {
|
||||
mnc = strings.TrimSpace(mnc)
|
||||
for len(mnc) < 3 {
|
||||
mnc = "0" + mnc
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
"epdg.epc.mnc%s.mcc%s.pub.3gppnetwork.org",
|
||||
mnc,
|
||||
strings.TrimSpace(mcc),
|
||||
)
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
package vowifi
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestAssignedRoutePLMNUsesNarrowCardAndSubscriptionMatches(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
iccid string
|
||||
imsi string
|
||||
wantMCC string
|
||||
wantMNC string
|
||||
wantAssigned bool
|
||||
}{
|
||||
{name: "XeSIM Lebara route", iccid: "89441600001001576265", imsi: "204047666157626", wantMCC: "234", wantMNC: "15", wantAssigned: true},
|
||||
{name: "CTExcel initial route", iccid: "8944303773524055208", imsi: "234336570712415", wantMCC: "234", wantMNC: "30", wantAssigned: true},
|
||||
{name: "XeSIM ICCID without matching subscription", iccid: "89441600001001576265", imsi: "204041666157626"},
|
||||
{name: "similar ICCID must not match", iccid: "89441000001001576265", imsi: "204047666157626"},
|
||||
{name: "generic EE SIM must not match CTExcel", iccid: "8944110000000000000", imsi: "234336570712415"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
mcc, mnc, assigned := AssignedRoutePLMN(test.iccid, test.imsi)
|
||||
if mcc != test.wantMCC || mnc != test.wantMNC || assigned != test.wantAssigned {
|
||||
t.Fatalf("AssignedRoutePLMN() = %q/%q,%v, want %q/%q,%v", mcc, mnc, assigned, test.wantMCC, test.wantMNC, test.wantAssigned)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyAssignedCarrierRoutePreservesAuthenticationPLMN(t *testing.T) {
|
||||
identity := applyAssignedCarrierRoute(SIMIdentity{
|
||||
ICCID: "8944303773524055208", IMSI: "234336570712415",
|
||||
HomeMCC: "234", HomeMNC: "33",
|
||||
})
|
||||
if identity.HomeMCC != "234" || identity.HomeMNC != "33" {
|
||||
t.Fatalf("authentication PLMN = %s/%s, want 234/33", identity.HomeMCC, identity.HomeMNC)
|
||||
}
|
||||
if identity.EPDG != "epdg.epc.mnc030.mcc234.pub.3gppnetwork.org" {
|
||||
t.Fatalf("route ePDG = %q", identity.EPDG)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsATT310280RequiresMatchingPLMNAndIMSI(t *testing.T) {
|
||||
if !IsATT310280(SIMIdentity{IMSI: "310280229187733", HomeMCC: "310", HomeMNC: "280"}) {
|
||||
t.Fatal("AT&T 310/280 identity was not recognized")
|
||||
}
|
||||
for _, identity := range []SIMIdentity{
|
||||
{IMSI: "310410229187733", HomeMCC: "310", HomeMNC: "280"},
|
||||
{IMSI: "310280229187733", HomeMCC: "310", HomeMNC: "28"},
|
||||
{IMSI: "310280229187733", HomeMCC: "311", HomeMNC: "280"},
|
||||
} {
|
||||
if IsATT310280(identity) {
|
||||
t.Fatalf("unrelated identity matched AT&T 310/280: %#v", identity)
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
-135
@@ -97,10 +97,9 @@ type ec20RadioCheckpoint struct {
|
||||
}
|
||||
|
||||
var (
|
||||
_ SIMIdentityReader = (*EC20Adapter)(nil)
|
||||
_ AKAProvider = (*EC20Adapter)(nil)
|
||||
_ PreferredAKAProvider = (*EC20Adapter)(nil)
|
||||
_ RadioController = (*EC20Adapter)(nil)
|
||||
_ SIMIdentityReader = (*EC20Adapter)(nil)
|
||||
_ AKAProvider = (*EC20Adapter)(nil)
|
||||
_ RadioController = (*EC20Adapter)(nil)
|
||||
)
|
||||
|
||||
func NewEC20Adapter(
|
||||
@@ -173,7 +172,6 @@ func (adapter *EC20Adapter) ReadIdentity(
|
||||
HomeMCC: homeMCC,
|
||||
HomeMNC: homeMNC,
|
||||
}
|
||||
identity = applyAssignedCarrierRoute(identity)
|
||||
adapter.mu.Lock()
|
||||
adapter.bindings[iccid] = ec20SIMBinding{
|
||||
deviceID: deviceID,
|
||||
@@ -210,11 +208,6 @@ func (adapter *EC20Adapter) readHomePLMN(
|
||||
iccid string,
|
||||
imsi string,
|
||||
) (string, string, error) {
|
||||
// AT&T 310/280 is a three-digit MNC. Prefer the assigned subscription
|
||||
// prefix when EF_AD is stale or ambiguous after a profile switch.
|
||||
if strings.HasPrefix(strings.TrimSpace(imsi), "310280") {
|
||||
return "310", "280", nil
|
||||
}
|
||||
mncLength, efErr := adapter.readExplicitMNCLength(ctx, deviceID)
|
||||
if efErr == nil {
|
||||
if len(imsi) < 3+mncLength {
|
||||
@@ -245,10 +238,9 @@ func assignedHomePLMN(imsi string) (mcc, mnc string, ok bool) {
|
||||
prefix string
|
||||
mncLength int
|
||||
}{
|
||||
{prefix: "20404", mncLength: 2}, // Vodafone NL core; some Lebara subscriptions.
|
||||
{prefix: "23415", mncLength: 2}, // Vodafone UK.
|
||||
{prefix: "23487", mncLength: 2}, // Lebara Mobile UK.
|
||||
{prefix: "310280", mncLength: 3}, // AT&T / RedPocket GSMA.
|
||||
{prefix: "20404", mncLength: 2}, // Vodafone NL core; some Lebara subscriptions.
|
||||
{prefix: "23415", mncLength: 2}, // Vodafone UK.
|
||||
{prefix: "23487", mncLength: 2}, // Lebara Mobile UK.
|
||||
}
|
||||
for _, assignment := range assignments {
|
||||
if strings.HasPrefix(imsi, assignment.prefix) {
|
||||
@@ -399,46 +391,11 @@ func (adapter *EC20Adapter) Authenticate(
|
||||
ctx context.Context,
|
||||
identity SIMIdentity,
|
||||
challenge AKAChallenge,
|
||||
) (AKAResult, error) {
|
||||
return adapter.authenticateWithApplication(ctx, identity, challenge, "")
|
||||
}
|
||||
|
||||
func (adapter *EC20Adapter) AuthenticateWithPreference(
|
||||
ctx context.Context,
|
||||
identity SIMIdentity,
|
||||
challenge AKAChallenge,
|
||||
preference string,
|
||||
) (AKAResult, error) {
|
||||
return adapter.authenticateWithApplication(ctx, identity, challenge, preference)
|
||||
}
|
||||
|
||||
func (adapter *EC20Adapter) authenticateWithApplication(
|
||||
ctx context.Context,
|
||||
identity SIMIdentity,
|
||||
challenge AKAChallenge,
|
||||
preference string,
|
||||
) (AKAResult, error) {
|
||||
binding, err := adapter.bindingFor(identity)
|
||||
if err != nil {
|
||||
return AKAResult{}, err
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(preference), "isim_strict") && binding.application != "ISIM" {
|
||||
aid, application, err := adapter.discoverPreferredAKAApplication(
|
||||
ctx,
|
||||
binding.deviceID,
|
||||
isimAIDPrefix,
|
||||
"ISIM",
|
||||
)
|
||||
if err != nil {
|
||||
return AKAResult{}, err
|
||||
}
|
||||
binding.aid = aid
|
||||
binding.application = application
|
||||
binding.basicChannel = false
|
||||
adapter.mu.Lock()
|
||||
adapter.bindings[binding.iccid] = binding
|
||||
adapter.mu.Unlock()
|
||||
}
|
||||
if binding.aid == "" {
|
||||
if _, err := adapter.CheckReady(ctx, identity); err != nil {
|
||||
return AKAResult{}, err
|
||||
@@ -448,14 +405,6 @@ func (adapter *EC20Adapter) authenticateWithApplication(
|
||||
return AKAResult{}, err
|
||||
}
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(preference), "isim_strict") && binding.application != "ISIM" {
|
||||
return AKAResult{}, fmt.Errorf(
|
||||
"%w: ISIM strict requested, selected %s (%s)",
|
||||
ErrEC20ApplicationAbsent,
|
||||
binding.application,
|
||||
binding.aid,
|
||||
)
|
||||
}
|
||||
if err := adapter.verifyLiveICCID(ctx, binding); err != nil {
|
||||
return AKAResult{}, err
|
||||
}
|
||||
@@ -958,28 +907,6 @@ func (adapter *EC20Adapter) discoverAKAApplication(
|
||||
return usimAIDPrefix, "USIM", nil
|
||||
}
|
||||
|
||||
func (adapter *EC20Adapter) discoverPreferredAKAApplication(
|
||||
ctx context.Context,
|
||||
deviceID string,
|
||||
aidPrefix string,
|
||||
application string,
|
||||
) (string, string, error) {
|
||||
response, err := adapter.execute(ctx, deviceID, "AT+CUAD")
|
||||
if err == nil {
|
||||
data, parseErr := parseCUADData(response)
|
||||
if parseErr == nil {
|
||||
for _, candidate := range collectApplicationAIDs(data) {
|
||||
if strings.HasPrefix(candidate, aidPrefix) {
|
||||
return candidate, application, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// AT+CUAD is optional. Returning the standard AID prefix still lets CCHO
|
||||
// perform the authoritative application probe on older EC20 firmware.
|
||||
return aidPrefix, application, nil
|
||||
}
|
||||
|
||||
func (adapter *EC20Adapter) openLogicalChannel(
|
||||
ctx context.Context,
|
||||
deviceID string,
|
||||
@@ -1219,27 +1146,12 @@ func parseCRSMData(response modem.Response) ([]byte, error) {
|
||||
}
|
||||
|
||||
func parseCUADData(response modem.Response) ([]byte, error) {
|
||||
// EC20 firmware may split the BER-TLV stream across adjacent quoted chunks
|
||||
// and continuation lines. Concatenating every hex fragment prevents an ISIM
|
||||
// AID after a USIM entry from being silently discarded.
|
||||
var encoded strings.Builder
|
||||
collect := false
|
||||
for _, line := range response.Lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(strings.ToUpper(line), "+CUAD:") {
|
||||
collect = true
|
||||
line = strings.TrimSpace(line[len("+CUAD:"):])
|
||||
} else if !collect {
|
||||
continue
|
||||
}
|
||||
for _, fragment := range quotedHexFragments(line) {
|
||||
encoded.WriteString(fragment)
|
||||
}
|
||||
}
|
||||
if encoded.Len() == 0 {
|
||||
fields := parseCSV(valueAfterATPrefix(response, "+CUAD:"))
|
||||
if len(fields) == 0 {
|
||||
return nil, errors.New("CUAD response has no data")
|
||||
}
|
||||
data, err := hex.DecodeString(encoded.String())
|
||||
value := fields[len(fields)-1]
|
||||
data, err := hex.DecodeString(strings.Trim(value, `"`))
|
||||
if err != nil || len(data) == 0 {
|
||||
return nil, errors.New("CUAD response data is invalid")
|
||||
}
|
||||
@@ -1249,48 +1161,11 @@ func parseCUADData(response modem.Response) ([]byte, error) {
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func quotedHexFragments(line string) []string {
|
||||
var fragments []string
|
||||
for {
|
||||
start := strings.IndexByte(line, '"')
|
||||
if start < 0 {
|
||||
break
|
||||
}
|
||||
line = line[start+1:]
|
||||
end := strings.IndexByte(line, '"')
|
||||
if end < 0 {
|
||||
break
|
||||
}
|
||||
fragment := strings.ToUpper(strings.TrimSpace(line[:end]))
|
||||
line = line[end+1:]
|
||||
if fragment == "" || len(fragment)%2 != 0 {
|
||||
continue
|
||||
}
|
||||
valid := true
|
||||
for _, character := range fragment {
|
||||
if (character < '0' || character > '9') && (character < 'A' || character > 'F') {
|
||||
valid = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if valid {
|
||||
fragments = append(fragments, fragment)
|
||||
}
|
||||
}
|
||||
return fragments
|
||||
}
|
||||
|
||||
func collectApplicationAIDs(data []byte) []string {
|
||||
var result []string
|
||||
var walk func([]byte)
|
||||
walk = func(value []byte) {
|
||||
for len(value) > 0 {
|
||||
for len(value) > 0 && value[0] == 0xff {
|
||||
value = value[1:]
|
||||
}
|
||||
if len(value) == 0 {
|
||||
return
|
||||
}
|
||||
tag, constructed, body, consumed, err := decodeBERTLV(value)
|
||||
if err != nil || consumed == 0 {
|
||||
return
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
@@ -422,7 +421,6 @@ func TestAssignedHomePLMNIncludesLebaraUKCores(t *testing.T) {
|
||||
"204040123456789": "204/04",
|
||||
"234150123456789": "234/15",
|
||||
"234870123456789": "234/87",
|
||||
"310280229187733": "310/280",
|
||||
}
|
||||
for imsi, want := range tests {
|
||||
mcc, mnc, ok := assignedHomePLMN(imsi)
|
||||
@@ -432,23 +430,6 @@ func TestAssignedHomePLMNIncludesLebaraUKCores(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEC20AdapterTreatsATT310280AsThreeDigitMNC(t *testing.T) {
|
||||
t.Parallel()
|
||||
transcript := &ec20Transcript{t: t, steps: identityTranscriptStepsWithoutEFAD("310280229187733")}
|
||||
adapter, err := NewEC20Adapter(transcript, EC20AdapterOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
identity, err := adapter.ReadIdentity(context.Background(), "ec20-1")
|
||||
if err != nil {
|
||||
t.Fatalf("ReadIdentity: %v", err)
|
||||
}
|
||||
if identity.HomeMCC != "310" || identity.HomeMNC != "280" {
|
||||
t.Fatalf("home PLMN = %s/%s, want 310/280", identity.HomeMCC, identity.HomeMNC)
|
||||
}
|
||||
transcript.assertDone()
|
||||
}
|
||||
|
||||
func TestEC20AdapterRadioTransactionRestoresCFUNAndPDPContexts(
|
||||
t *testing.T,
|
||||
) {
|
||||
@@ -617,78 +598,3 @@ func synchronizationFailureUSIMResponse() []byte {
|
||||
raw = append(raw, auts...)
|
||||
return append(raw, 0x90, 0x00)
|
||||
}
|
||||
|
||||
func TestCollectApplicationAIDsSkipsCUADPadding(t *testing.T) {
|
||||
t.Parallel()
|
||||
response := modem.Response{Lines: []string{
|
||||
`+CUAD: "61184F10A0000000871002FFFFFFFF890302000050045553494DFFFFFFFFFFFFFFFFFFFFFFFF""61184F10A0000000871004FFFFFFFF890302000050044953494DFFFFFFFFFFFFFFFFFFFFFFFF"`,
|
||||
`"FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF"`,
|
||||
}}
|
||||
data, err := parseCUADData(response)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
aids := collectApplicationAIDs(data)
|
||||
want := []string{
|
||||
"A0000000871002FFFFFFFF8903020000",
|
||||
"A0000000871004FFFFFFFF8903020000",
|
||||
}
|
||||
if !reflect.DeepEqual(aids, want) {
|
||||
t.Fatalf("AIDs = %v, want %v", aids, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEC20AdapterISIMStrictUsesCUADFullAID(t *testing.T) {
|
||||
var challenge AKAChallenge
|
||||
for index := range challenge.RAND {
|
||||
challenge.RAND[index] = byte(index)
|
||||
challenge.AUTN[index] = byte(0xf0 + index)
|
||||
}
|
||||
authAPDU := buildUSIMAuthenticateAPDU(challenge)
|
||||
authCommand := fmt.Sprintf(
|
||||
`AT+CGLA=1,%d,"%s"`,
|
||||
len(authAPDU)*2,
|
||||
strings.ToUpper(hex.EncodeToString(authAPDU)),
|
||||
)
|
||||
encodedResponse := strings.ToUpper(hex.EncodeToString(successfulUSIMResponse()))
|
||||
fullISIM := "A0000000871004FFFFFFFF8903020000"
|
||||
cuad := `61184F10A0000000871002FFFFFFFF890302000050045553494D61184F10A0000000871004FFFFFFFF890302000050044953494D`
|
||||
transcript := &ec20Transcript{
|
||||
t: t,
|
||||
steps: []ec20TranscriptStep{
|
||||
{command: "AT+CPIN?", lines: []string{"+CPIN: READY"}},
|
||||
{command: "AT+CIMI", lines: []string{"310280229187733"}},
|
||||
{command: "AT+CCID", lines: []string{"+CCID: 89012804332291663965"}},
|
||||
{command: "AT+CGSN", lines: []string{"863212060022487"}},
|
||||
{command: "AT+CUAD", lines: []string{`+CUAD: "` + cuad + `"`}},
|
||||
{command: "AT+CCID", lines: []string{"+CCID: 89012804332291663965"}},
|
||||
{command: `AT+CCHO="` + fullISIM + `"`, lines: []string{"+CCHO: 1"}},
|
||||
{
|
||||
command: authCommand,
|
||||
sensitive: true,
|
||||
lines: []string{fmt.Sprintf(
|
||||
`+CGLA: %d,"%s"`,
|
||||
len(encodedResponse),
|
||||
encodedResponse,
|
||||
)},
|
||||
},
|
||||
{command: "AT+CCHC=1"},
|
||||
},
|
||||
}
|
||||
adapter, err := NewEC20Adapter(transcript, EC20AdapterOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
identity, err := adapter.ReadIdentity(context.Background(), "ec20-1")
|
||||
if err != nil {
|
||||
t.Fatalf("ReadIdentity: %v", err)
|
||||
}
|
||||
result, err := adapter.AuthenticateWithPreference(context.Background(), identity, challenge, "isim_strict")
|
||||
if err != nil {
|
||||
t.Fatalf("AuthenticateWithPreference: %v", err)
|
||||
}
|
||||
if !bytes.Equal(result.RES, []byte{1, 2, 3, 4, 5, 6, 7, 8}) {
|
||||
t.Fatalf("RES = %x", result.RES)
|
||||
}
|
||||
transcript.assertDone()
|
||||
}
|
||||
|
||||
@@ -168,7 +168,6 @@ func authenticateAKA(
|
||||
provider vowifi.AKAProvider,
|
||||
identity vowifi.SIMIdentity,
|
||||
challenge digestChallenge,
|
||||
preference string,
|
||||
) (akaMaterial, error) {
|
||||
nonce, err := decodeAKANonce(challenge.Nonce)
|
||||
if err != nil {
|
||||
@@ -179,18 +178,9 @@ func authenticateAKA(
|
||||
var akaChallenge vowifi.AKAChallenge
|
||||
copy(akaChallenge.RAND[:], nonce[:16])
|
||||
copy(akaChallenge.AUTN[:], nonce[16:32])
|
||||
var result vowifi.AKAResult
|
||||
if preferred, ok := provider.(vowifi.PreferredAKAProvider); ok && strings.TrimSpace(preference) != "" {
|
||||
result, err = preferred.AuthenticateWithPreference(ctx, identity, akaChallenge, preference)
|
||||
} else {
|
||||
result, err = provider.Authenticate(ctx, identity, akaChallenge)
|
||||
}
|
||||
result, err := provider.Authenticate(ctx, identity, akaChallenge)
|
||||
if err != nil {
|
||||
application := "USIM"
|
||||
if strings.EqualFold(strings.TrimSpace(preference), "isim_strict") {
|
||||
application = "ISIM"
|
||||
}
|
||||
return akaMaterial{}, fmt.Errorf("ims: %s AKA authentication failed: %w", application, err)
|
||||
return akaMaterial{}, fmt.Errorf("ims: USIM AKA authentication failed: %w", err)
|
||||
}
|
||||
if result.SynchronizationFailure || len(result.AUTS) > 0 {
|
||||
if !result.SynchronizationFailure || len(result.AUTS) != 14 {
|
||||
|
||||
@@ -16,21 +16,6 @@ type recordingAKA struct {
|
||||
challenges []vowifi.AKAChallenge
|
||||
}
|
||||
|
||||
type recordingPreferredAKA struct {
|
||||
recordingAKA
|
||||
preference string
|
||||
}
|
||||
|
||||
func (aka *recordingPreferredAKA) AuthenticateWithPreference(
|
||||
ctx context.Context,
|
||||
identity vowifi.SIMIdentity,
|
||||
challenge vowifi.AKAChallenge,
|
||||
preference string,
|
||||
) (vowifi.AKAResult, error) {
|
||||
aka.preference = preference
|
||||
return aka.Authenticate(ctx, identity, challenge)
|
||||
}
|
||||
|
||||
func (aka *recordingAKA) CheckReady(context.Context, vowifi.SIMIdentity) (vowifi.AKAEvidence, error) {
|
||||
return vowifi.AKAEvidence{Ready: true, Application: "usim"}, nil
|
||||
}
|
||||
@@ -75,7 +60,6 @@ func TestAuthenticateAKAMapsNonceToTypedChallenge(t *testing.T) {
|
||||
aka,
|
||||
vowifi.SIMIdentity{IMSI: "001010123456789"},
|
||||
digestChallenge{Nonce: base64.StdEncoding.EncodeToString(nonceBytes)},
|
||||
"",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("authenticateAKA() error = %v", err)
|
||||
@@ -109,7 +93,6 @@ func TestAuthenticateAKAReturnsSynchronizationEvidence(t *testing.T) {
|
||||
aka,
|
||||
vowifi.SIMIdentity{},
|
||||
digestChallenge{Nonce: nonce},
|
||||
"",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("authenticateAKA() error = %v", err)
|
||||
@@ -119,26 +102,6 @@ func TestAuthenticateAKAReturnsSynchronizationEvidence(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthenticateAKAUsesPreferredApplicationWhenSupported(t *testing.T) {
|
||||
nonce := base64.StdEncoding.EncodeToString(make([]byte, 32))
|
||||
aka := &recordingPreferredAKA{recordingAKA: recordingAKA{
|
||||
result: vowifi.AKAResult{RES: []byte{1, 2, 3, 4}},
|
||||
}}
|
||||
_, err := authenticateAKA(
|
||||
context.Background(),
|
||||
aka,
|
||||
vowifi.SIMIdentity{IMSI: "310280229187733"},
|
||||
digestChallenge{Nonce: nonce},
|
||||
"isim_strict",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("authenticateAKA() error = %v", err)
|
||||
}
|
||||
if aka.preference != "isim_strict" {
|
||||
t.Fatalf("preference = %q, want isim_strict", aka.preference)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDigestAuthorizationCarriesAUTSWithEmptyResponse(t *testing.T) {
|
||||
authorization := buildDigestAuthorization(
|
||||
digestChallenge{
|
||||
|
||||
@@ -271,22 +271,13 @@ func deriveIdentities(identity vowifi.SIMIdentity, config Config) (identitySet,
|
||||
mnc = "0" + mnc
|
||||
}
|
||||
domain := fmt.Sprintf("ims.mnc%s.mcc%s.3gppnetwork.org", mnc, mcc)
|
||||
privateDomain := domain
|
||||
publicDomain := domain
|
||||
if vowifi.IsATT310280(identity) {
|
||||
// AT&T provisions the IMPI and IMPU in its ISIM domains rather than
|
||||
// the generic 3GPP PLMN IMS domain.
|
||||
domain = "one.att.net"
|
||||
privateDomain = "private.att.net"
|
||||
publicDomain = "one.att.net"
|
||||
}
|
||||
privateIdentity := config.PrivateIdentity
|
||||
if privateIdentity == "" {
|
||||
privateIdentity = imsi + "@" + privateDomain
|
||||
privateIdentity = imsi + "@" + domain
|
||||
}
|
||||
publicIdentity := config.PublicIdentity
|
||||
if publicIdentity == "" {
|
||||
publicIdentity = "sip:" + imsi + "@" + publicDomain
|
||||
publicIdentity = "sip:" + imsi + "@" + domain
|
||||
}
|
||||
if strings.ContainsAny(privateIdentity+publicIdentity, "\r\n") ||
|
||||
!strings.Contains(privateIdentity, "@") ||
|
||||
@@ -545,9 +536,6 @@ func newSession(
|
||||
}
|
||||
protectedClientPort := provider.config.ProtectedClientPort
|
||||
protectedServerPort := provider.config.ProtectedServerPort
|
||||
if vowifi.IsATT310280(request.Identity) && protectedServerPort == 0 {
|
||||
protectedServerPort = 6000
|
||||
}
|
||||
if securityEncryptionForIdentity(request.Identity) == "null" {
|
||||
if protectedClientPort == 0 {
|
||||
protectedClientPort = 5062
|
||||
@@ -566,10 +554,6 @@ func newSession(
|
||||
return nil, err
|
||||
}
|
||||
proposal.encryption = securityEncryptionForIdentity(request.Identity)
|
||||
if vowifi.IsATT310280(request.Identity) {
|
||||
proposal.integrityAlgorithms = []string{"hmac-sha-1-96"}
|
||||
proposal.encryptionAlgorithmsList = []string{"aes-cbc"}
|
||||
}
|
||||
session.securityProposal = proposal
|
||||
protectedTCP, err := net.ListenTCP(
|
||||
"tcp",
|
||||
@@ -748,11 +732,7 @@ func (session *Session) register(ctx context.Context, expires int) (*sipResponse
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
preference := ""
|
||||
if vowifi.IsATT310280(session.request.Identity) {
|
||||
preference = "isim_strict"
|
||||
}
|
||||
material, err := authenticateAKA(ctx, session.provider.aka, session.request.Identity, challenge, preference)
|
||||
material, err := authenticateAKA(ctx, session.provider.aka, session.request.Identity, challenge)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -812,10 +792,6 @@ func (session *Session) buildRegister(
|
||||
authorizationHeader string,
|
||||
authorization string,
|
||||
) ([]byte, error) {
|
||||
att310280 := vowifi.IsATT310280(session.request.Identity)
|
||||
if att310280 {
|
||||
expires = 18400
|
||||
}
|
||||
branch, err := randomHex(12)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -834,17 +810,6 @@ func (session *Session) buildRegister(
|
||||
session.instanceID,
|
||||
"urn%3Aurn-7%3A3gpp-service.ims.icsi.mmtel",
|
||||
)
|
||||
if att310280 {
|
||||
contact = fmt.Sprintf(
|
||||
`<sip:%s@%s;transport=%s>;+g.3gpp.accesstype="wlan1";audio;+g.3gpp.smsip;`+
|
||||
`+g.3gpp.icsi-ref="%s";+sip.instance="<%s>"`,
|
||||
session.identity.user,
|
||||
contactAddress,
|
||||
session.transport,
|
||||
"urn%3Aurn-7%3A3gpp-service.ims.icsi.mmtel",
|
||||
session.instanceID,
|
||||
)
|
||||
}
|
||||
o2Germany := usesO2GermanyIMSProfile(session.request.Identity)
|
||||
supported := "path, gruu"
|
||||
allow := "REGISTER, INVITE, ACK, CANCEL, BYE, OPTIONS"
|
||||
@@ -855,13 +820,6 @@ func (session *Session) buildRegister(
|
||||
supported = "path, gruu, outbound, sec-agree, 100rel, timer"
|
||||
allow = "INVITE, ACK, CANCEL, BYE, PRACK, UPDATE, INFO, MESSAGE, OPTIONS"
|
||||
}
|
||||
if att310280 {
|
||||
supported = "path,sec-agree,gruu"
|
||||
}
|
||||
userAgent := strings.TrimSpace(session.provider.config.UserAgent)
|
||||
if att310280 && (userAgent == "" || userAgent == "vocat/1") {
|
||||
userAgent = "SimAdmin VoWiFi"
|
||||
}
|
||||
lines := []string{
|
||||
"REGISTER " + requestURI + " SIP/2.0",
|
||||
fmt.Sprintf("Via: SIP/2.0/%s %s;branch=z9hG4bK%s;rport", transportUpper, local, branch),
|
||||
@@ -875,23 +833,14 @@ func (session *Session) buildRegister(
|
||||
fmt.Sprintf("Expires: %d", expires),
|
||||
"Supported: " + supported,
|
||||
"Allow: " + allow,
|
||||
"User-Agent: " + userAgent,
|
||||
"User-Agent: " + session.provider.config.UserAgent,
|
||||
}
|
||||
if o2Germany {
|
||||
lines = append(lines, "P-Preferred-Identity: <"+session.identity.public+">")
|
||||
} else if att310280 {
|
||||
lines = append(lines,
|
||||
"P-Preferred-Identity: <"+session.identity.public+">",
|
||||
`P-Visited-Network-ID: "one.att.net"`,
|
||||
"P-Access-Network-Info: IEEE-802.11;i-wlan-node-id=000000000000;network-provided",
|
||||
"Cellular-Network-Info: 3GPP-E-UTRAN-FDD;utran-cell-id-3gpp=3102800000000;cell-info-age=0",
|
||||
"Accept-Contact: *;+g.3gpp.smsip",
|
||||
`Accept-Contact: *;+g.3gpp.icsi-ref="urn%3Aurn-7%3A3gpp-service.ims.icsi.mmtel"`,
|
||||
)
|
||||
}
|
||||
if session.securityOffered() {
|
||||
lines = append(lines,
|
||||
"Security-Client: "+session.securityClientValue(),
|
||||
"Security-Client: "+session.securityProposal.headerValue(),
|
||||
"Require: sec-agree",
|
||||
"Proxy-Require: sec-agree",
|
||||
)
|
||||
|
||||
@@ -442,75 +442,6 @@ func TestO2GermanyInitialRegisterMatchesSupportedIMSProfile(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestATT310280DeriveIdentitiesUsesISIMDomains(t *testing.T) {
|
||||
identities, err := deriveIdentities(vowifi.SIMIdentity{
|
||||
IMSI: "310280229187733", HomeMCC: "310", HomeMNC: "280",
|
||||
}, Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("deriveIdentities() error = %v", err)
|
||||
}
|
||||
if identities.domain != "one.att.net" ||
|
||||
identities.private != "[email protected]" ||
|
||||
identities.public != "sip:[email protected]" {
|
||||
t.Fatalf("AT&T identities = %#v", identities)
|
||||
}
|
||||
}
|
||||
|
||||
func TestATT310280InitialRegisterMatchesProvisionedProfile(t *testing.T) {
|
||||
client, server := net.Pipe()
|
||||
defer client.Close()
|
||||
defer server.Close()
|
||||
|
||||
identity := vowifi.SIMIdentity{
|
||||
IMSI: "310280229187733", HomeMCC: "310", HomeMNC: "280",
|
||||
}
|
||||
identities, err := deriveIdentities(identity, Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
session := &Session{
|
||||
provider: &Provider{config: Config{SecurityMode: SecurityRequired, UserAgent: "vocat/1"}},
|
||||
request: vowifi.IMSRequest{Identity: identity},
|
||||
identity: identities,
|
||||
endpoint: pcscfEndpoint{host: "pcscf.example", port: 5060},
|
||||
transport: "tcp",
|
||||
conn: client,
|
||||
callID: "att-test",
|
||||
fromTag: "tag",
|
||||
instanceID: "urn:uuid:test",
|
||||
securityProposal: securityProposal{
|
||||
spiClient: 1546543, spiServer: 1546542,
|
||||
portClient: 32773, portServer: 6000,
|
||||
integrityAlgorithms: []string{"hmac-sha-1-96"},
|
||||
encryptionAlgorithmsList: []string{"aes-cbc"},
|
||||
},
|
||||
}
|
||||
packet, err := session.buildRegister(1, 3600, "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("buildRegister() error = %v", err)
|
||||
}
|
||||
request := string(packet)
|
||||
for _, want := range []string{
|
||||
"REGISTER sip:one.att.net SIP/2.0",
|
||||
"Expires: 18400",
|
||||
"Supported: path,sec-agree,gruu",
|
||||
"User-Agent: SimAdmin VoWiFi",
|
||||
`+g.3gpp.accesstype="wlan1";audio;+g.3gpp.smsip`,
|
||||
"P-Preferred-Identity: <sip:[email protected]>",
|
||||
`P-Visited-Network-ID: "one.att.net"`,
|
||||
"P-Access-Network-Info: IEEE-802.11;i-wlan-node-id=000000000000;network-provided",
|
||||
"Cellular-Network-Info: 3GPP-E-UTRAN-FDD;utran-cell-id-3gpp=3102800000000;cell-info-age=0",
|
||||
"Accept-Contact: *;+g.3gpp.smsip",
|
||||
"Security-Client: ipsec-3gpp; alg=hmac-sha-1-96; ealg=aes-cbc; prot=esp; mod=trans; spi-c=1546543; spi-s=1546542; port-c=32773; port-s=6000",
|
||||
`username="[email protected]"`,
|
||||
`uri="sip:one.att.net"`,
|
||||
} {
|
||||
if !strings.Contains(request, want) {
|
||||
t.Fatalf("AT&T REGISTER omits %q:\n%s", want, request)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func serveRefreshFailure(listener *net.UDPConn, nonce string) error {
|
||||
var callID string
|
||||
for step := 0; step < 3; step++ {
|
||||
|
||||
@@ -12,8 +12,6 @@ import (
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"vocat/internal/vowifi"
|
||||
)
|
||||
|
||||
type SecurityMode string
|
||||
@@ -145,19 +143,6 @@ func (proposal securityProposal) headerValue() string {
|
||||
return strings.Join(values, ", ")
|
||||
}
|
||||
|
||||
func (session *Session) securityClientValue() string {
|
||||
if vowifi.IsATT310280(session.request.Identity) {
|
||||
return fmt.Sprintf(
|
||||
"ipsec-3gpp; alg=hmac-sha-1-96; ealg=aes-cbc; prot=esp; mod=trans; spi-c=%d; spi-s=%d; port-c=%d; port-s=%d",
|
||||
session.securityProposal.spiClient,
|
||||
session.securityProposal.spiServer,
|
||||
session.securityProposal.portClient,
|
||||
session.securityProposal.portServer,
|
||||
)
|
||||
}
|
||||
return session.securityProposal.headerValue()
|
||||
}
|
||||
|
||||
func (proposal securityProposal) encryptionAlgorithm() string {
|
||||
if strings.EqualFold(strings.TrimSpace(proposal.encryption), "null") {
|
||||
return "null"
|
||||
|
||||
@@ -645,13 +645,18 @@ func DeriveEPDG(identity SIMIdentity) (string, error) {
|
||||
}
|
||||
return strings.ToLower(configured), nil
|
||||
}
|
||||
if IsATT310280(identity) {
|
||||
return att310280EPDG, nil
|
||||
}
|
||||
if err := identity.validate(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return standardEPDGHostname(identity.HomeMCC, identity.HomeMNC), nil
|
||||
mnc := strings.TrimSpace(identity.HomeMNC)
|
||||
for len(mnc) < 3 {
|
||||
mnc = "0" + mnc
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
"epdg.epc.mnc%s.mcc%s.pub.3gppnetwork.org",
|
||||
mnc,
|
||||
strings.TrimSpace(identity.HomeMCC),
|
||||
), nil
|
||||
}
|
||||
|
||||
func normalizeProxyRoute(route ProxyRoute) (ProxyRoute, error) {
|
||||
|
||||
@@ -53,18 +53,18 @@ func (adapter *PCSCAdapter) ReadIdentity(ctx context.Context, deviceID string) (
|
||||
mncLength := identity.MNCLength
|
||||
if mncLength != 2 && mncLength != 3 {
|
||||
if mcc, mnc, ok := assignedHomePLMN(identity.IMSI); ok {
|
||||
return applyAssignedCarrierRoute(SIMIdentity{ICCID: identity.ICCID, IMSI: identity.IMSI, HomeMCC: mcc, HomeMNC: mnc, SMSC: identity.SMSC}), nil
|
||||
return SIMIdentity{ICCID: identity.ICCID, IMSI: identity.IMSI, HomeMCC: mcc, HomeMNC: mnc, SMSC: identity.SMSC}, nil
|
||||
}
|
||||
return SIMIdentity{}, ErrEC20MNCUnavailable
|
||||
}
|
||||
if len(identity.IMSI) < 3+mncLength {
|
||||
return SIMIdentity{}, errors.New("vocat: USB SIM IMSI is shorter than its EF_AD home PLMN")
|
||||
}
|
||||
return applyAssignedCarrierRoute(SIMIdentity{
|
||||
return SIMIdentity{
|
||||
ICCID: identity.ICCID, IMSI: identity.IMSI,
|
||||
HomeMCC: identity.IMSI[:3], HomeMNC: identity.IMSI[3 : 3+mncLength],
|
||||
SMSC: identity.SMSC,
|
||||
}), nil
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (adapter *PCSCAdapter) ReadSMSCenter(ctx context.Context, deviceID string) (string, error) {
|
||||
|
||||
@@ -125,16 +125,6 @@ func TestDeriveEPDGUsesExplicitPLMNAndNeverIMSIHeuristics(t *testing.T) {
|
||||
},
|
||||
want: "epdg.epc.mnc260.mcc310.pub.3gppnetwork.org",
|
||||
},
|
||||
{
|
||||
name: "AT&T 310280 uses carrier endpoint",
|
||||
identity: SIMIdentity{
|
||||
ICCID: "89012804332291663965",
|
||||
IMSI: "310280229187733",
|
||||
HomeMCC: "310",
|
||||
HomeMNC: "280",
|
||||
},
|
||||
want: "epdg.epc.att.net",
|
||||
},
|
||||
{
|
||||
name: "explicit endpoint",
|
||||
identity: SIMIdentity{
|
||||
|
||||
@@ -317,14 +317,6 @@ type AKAProvider interface {
|
||||
Authenticate(context.Context, SIMIdentity, AKAChallenge) (AKAResult, error)
|
||||
}
|
||||
|
||||
// PreferredAKAProvider optionally lets an AKA provider select a carrier-
|
||||
// provisioned application such as ISIM. Providers that only expose USIM keep
|
||||
// implementing AKAProvider unchanged.
|
||||
type PreferredAKAProvider interface {
|
||||
AKAProvider
|
||||
AuthenticateWithPreference(context.Context, SIMIdentity, AKAChallenge, string) (AKAResult, error)
|
||||
}
|
||||
|
||||
// RadioController owns the host/modem radio projection. EnterVoWiFiRFOff must
|
||||
// not toggle the independent pure-airplane policy; Restore must return to the
|
||||
// captured pre-transaction state.
|
||||
|
||||
+4
-10
@@ -345,14 +345,11 @@ FIRST_INSTALL=0
|
||||
INITIAL_ADMIN_PASSWORD=""
|
||||
|
||||
bootstrap_admin() {
|
||||
local candidate="${1:-$BINARY_PATH}"
|
||||
local secret result
|
||||
secret=$(od -An -N16 -tx1 /dev/urandom | tr -d ' \n')
|
||||
[ -n "$secret" ] || die "Failed to generate a random secret." "Failed to generate a random secret."
|
||||
result=$(printf '%s\n' "$secret" | "$candidate" bootstrap-admin --database /opt/vocat/data/vocat.db --username admin) || \
|
||||
die \
|
||||
"待安装版本无法读取或升级现有数据库;当前程序尚未被替换,请检查数据库与版本兼容性。" \
|
||||
"The candidate version cannot read or migrate the existing database; the installed program was not replaced. Check database and version compatibility."
|
||||
result=$(printf '%s\n' "$secret" | "$BINARY_PATH" bootstrap-admin --database /opt/vocat/data/vocat.db --username admin) || \
|
||||
die "Failed to initialize the administrator." "Failed to initialize the administrator."
|
||||
if [ "$result" = "created" ]; then
|
||||
FIRST_INSTALL=1
|
||||
INITIAL_ADMIN_PASSWORD="$secret"
|
||||
@@ -535,12 +532,9 @@ fi
|
||||
resolve_target_version
|
||||
skip_if_equal
|
||||
download_and_verify
|
||||
ensure_data_dir
|
||||
# Validate the database with the downloaded binary before replacing the
|
||||
# installed program. In particular, a release with an older schema must never
|
||||
# overwrite a newer working binary and leave the service in a restart loop.
|
||||
bootstrap_admin "${VOCAT_TMP}/vocat"
|
||||
install_binary
|
||||
ensure_data_dir
|
||||
bootstrap_admin
|
||||
setup_env
|
||||
write_service
|
||||
enable_and_start
|
||||
|
||||
+2
-38
@@ -83,29 +83,7 @@ export interface RequestOptions extends Omit<RequestInit, "body"> {
|
||||
raw?: boolean;
|
||||
}
|
||||
|
||||
async function refreshCSRFToken(): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch("/api/auth/session", {
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
credentials: "include",
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) notifyUnauthorized();
|
||||
return false;
|
||||
}
|
||||
const payload = await response.json() as { data?: { csrf_token?: string } };
|
||||
const token = payload?.data?.csrf_token;
|
||||
if (!token) return false;
|
||||
sessionStorage.setItem(CSRF_KEY, token);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function requestAPI<T>(path: string, options: RequestOptions, retryCSRF: boolean): Promise<T> {
|
||||
export async function api<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||
const method = (options.method || "GET").toUpperCase();
|
||||
const headers = new Headers(options.headers);
|
||||
const formBody = typeof FormData !== "undefined" && options.body instanceof FormData;
|
||||
@@ -138,6 +116,7 @@ async function requestAPI<T>(path: string, options: RequestOptions, retryCSRF: b
|
||||
: { message: await response.text() };
|
||||
const normalized = camelize<Record<string, unknown>>(payload);
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) notifyUnauthorized();
|
||||
const nested = normalized.error;
|
||||
const detail = nested && typeof nested === "object"
|
||||
? {
|
||||
@@ -145,26 +124,11 @@ async function requestAPI<T>(path: string, options: RequestOptions, retryCSRF: b
|
||||
requestId: (normalized.requestId as string | undefined) || (nested as ApiErrorBody).requestId,
|
||||
}
|
||||
: normalized as ApiErrorBody;
|
||||
if (
|
||||
retryCSRF &&
|
||||
isMutation(method) &&
|
||||
response.status === 403 &&
|
||||
detail.code === "invalid_csrf"
|
||||
) {
|
||||
if (await refreshCSRFToken()) return requestAPI<T>(path, options, false);
|
||||
notifyUnauthorized();
|
||||
} else if (response.status === 401) {
|
||||
notifyUnauthorized();
|
||||
}
|
||||
throw new ApiError(response.status, detail);
|
||||
}
|
||||
return (Object.prototype.hasOwnProperty.call(normalized, "data") ? normalized.data : normalized) as T;
|
||||
}
|
||||
|
||||
export async function api<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||
return requestAPI<T>(path, options, true);
|
||||
}
|
||||
|
||||
export async function login(username: string, password: string) {
|
||||
const result = await api<LoginResponse & { user?: { username?: string } }>("/auth/login", {
|
||||
method: "POST",
|
||||
|
||||
Reference in New Issue
Block a user