feat: update schema version and refactor proxy binding logic

- Increment schema version from 11 to 12.
- Modify ProxyResolver to use ICCID for device proxy binding resolution.
- Update tests to reflect changes in proxy binding logic using ICCID.
- Enhance DeviceBindingsDialog to manage eSIM profile bindings instead of device bindings.
- Update UI components and translations to reflect the new profile binding terminology.
- Implement pagination for automatic task runs in AutomaticTasksPage.
- Create a new Pagination component for better navigation in lists.
This commit is contained in:
MengMengCode
2026-08-11 01:23:04 +08:00
parent 928ba7746e
commit a09f9af646
23 changed files with 845 additions and 282 deletions
+20 -6
View File
@@ -456,6 +456,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 +485,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 +530,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
+141 -58
View File
@@ -26,8 +26,8 @@ func (s *Server) routeProxyAPI(w http.ResponseWriter, r *http.Request, cleanPath
writeJSON(w, http.StatusOK, map[string]any{"data": proxyCountries})
case "upstream-proxy-country-rules":
s.handleCountryRules(w, r)
case "upstream-proxy-device-bindings":
s.handleDeviceProxyBindings(w, r)
case "upstream-proxy-profile-bindings":
s.handleProfileProxyBindings(w, r)
default:
segments := splitAPIPath(cleanPath)
switch {
@@ -40,8 +40,6 @@ func (s *Server) routeProxyAPI(w http.ResponseWriter, r *http.Request, cleanPath
s.handleUpstreamProbe(w, r, segments[1])
case len(segments) == 2 && segments[0] == "upstream-proxy-country-rules":
s.handleCountryRule(w, r, segments[1])
case len(segments) == 2 && segments[0] == "upstream-proxy-device-bindings":
s.handleDeviceProxyBinding(w, r, segments[1])
default:
return false
}
@@ -114,7 +112,7 @@ func (s *Server) handleUpstreamProxy(w http.ResponseWriter, r *http.Request, id
}
for _, binding := range bindings {
if binding.UpstreamProxyID == id {
s.requestProxyRouteReconnect(binding.DeviceID)
s.requestProfileProxyRouteReconnect(binding.DeviceID, binding.ICCID)
}
}
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"deleted": true}})
@@ -124,36 +122,32 @@ func (s *Server) handleUpstreamProxy(w http.ResponseWriter, r *http.Request, id
}
}
func (s *Server) handleDeviceProxyBindings(w http.ResponseWriter, r *http.Request) {
if !requireMethod(w, r, http.MethodGet) {
return
}
values, err := s.store.ListDeviceProxyBindings(r.Context())
if err != nil {
s.writeStoreError(w, err)
return
}
result := make([]map[string]any, 0, len(values))
for _, value := range values {
result = append(result, deviceProxyBindingResponse(value))
}
writeJSON(w, http.StatusOK, map[string]any{"data": result})
type profileProxyBindingPayload struct {
DeviceID string `json:"device_id"`
ICCID string `json:"iccid"`
ProfileName string `json:"profile_name"`
// Accepted for compatibility with the first profile-picker bundle, which
// sent the read-only display state together with the writable identity.
StateText string `json:"state_text,omitempty"`
}
func (s *Server) handleDeviceProxyBinding(w http.ResponseWriter, r *http.Request, deviceID string) {
deviceID = strings.TrimSpace(deviceID)
if !validDeviceID(deviceID) {
writeError(w, http.StatusBadRequest, "invalid_device_id", "device ID must use 1-64 safe characters")
return
}
if _, err := s.store.Device(r.Context(), deviceID); err != nil {
s.writeStoreError(w, err)
return
}
func (s *Server) handleProfileProxyBindings(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodPut:
case http.MethodGet:
values, err := s.store.ListDeviceProxyBindings(r.Context())
if err != nil {
s.writeStoreError(w, err)
return
}
result := make([]map[string]any, 0, len(values))
for _, value := range values {
result = append(result, deviceProxyBindingResponse(value))
}
writeJSON(w, http.StatusOK, map[string]any{"data": result})
case http.MethodPost:
var request struct {
UpstreamProxyID string `json:"upstream_proxy_id"`
UpstreamProxyID string `json:"upstream_proxy_id"`
Bindings []profileProxyBindingPayload `json:"bindings"`
}
if err := s.decodeJSON(w, r, &request); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
@@ -166,44 +160,114 @@ func (s *Server) handleDeviceProxyBinding(w http.ResponseWriter, r *http.Request
return
}
if !upstream.Enabled {
writeError(w, http.StatusConflict, "upstream_proxy_disabled", "enable the upstream proxy before binding a device")
writeError(w, http.StatusConflict, "upstream_proxy_disabled", "enable the upstream proxy before binding a profile")
return
}
// Once bound, a device may not be silently rebinded to a different
// upstream proxy. Force the caller to DELETE first so the change is
// intentional. Re-binding the same upstream stays idempotent.
if existing, err := s.store.DeviceProxyBinding(r.Context(), deviceID); err == nil && existing.UpstreamProxyID != upstream.ID {
writeError(w, http.StatusConflict, "device_already_bound", "device is already bound to another upstream proxy; delete the binding first")
return
} else if err != nil && !errors.Is(err, store.ErrNotFound) {
s.writeStoreError(w, err)
if len(request.Bindings) == 0 || len(request.Bindings) > 200 {
writeError(w, http.StatusBadRequest, "invalid_bindings", "select between 1 and 200 profiles")
return
}
value := store.DeviceProxyBinding{DeviceID: deviceID, UpstreamProxyID: upstream.ID}
if err := s.store.UpsertDeviceProxyBinding(r.Context(), value); err != nil {
s.writeStoreError(w, err)
return
values := make([]store.DeviceProxyBinding, 0, len(request.Bindings))
seen := make(map[string]struct{}, len(request.Bindings))
for _, item := range request.Bindings {
deviceID := strings.TrimSpace(item.DeviceID)
iccid := strings.TrimSpace(item.ICCID)
if !validDeviceID(deviceID) {
writeError(w, http.StatusBadRequest, "invalid_device_id", "device ID must use 1-64 safe characters")
return
}
if !validProfileICCID(iccid) {
writeError(w, http.StatusBadRequest, "invalid_iccid", "profile ICCID must contain 18 to 22 digits")
return
}
if _, duplicate := seen[iccid]; duplicate {
writeError(w, http.StatusBadRequest, "duplicate_iccid", "the same ICCID was selected more than once")
return
}
seen[iccid] = struct{}{}
if _, err := s.store.Device(r.Context(), deviceID); err != nil {
s.writeStoreError(w, err)
return
}
if existing, err := s.store.DeviceProxyBinding(r.Context(), iccid); err == nil && existing.UpstreamProxyID != upstream.ID {
writeError(w, http.StatusConflict, "profile_already_bound", "this ICCID is already bound to another upstream proxy; delete that binding first")
return
} else if err != nil && !errors.Is(err, store.ErrNotFound) {
s.writeStoreError(w, err)
return
}
name := strings.TrimSpace(item.ProfileName)
if name == "" {
name = iccid
}
values = append(values, store.DeviceProxyBinding{DeviceID: deviceID, ICCID: iccid, ProfileName: name, UpstreamProxyID: upstream.ID})
}
reconnected, reconnectErr := s.requestProxyRouteReconnect(deviceID)
response := deviceProxyBindingResponse(value)
response["reconnect_requested"] = reconnected
if reconnectErr != nil {
response["reconnect_error"] = reconnectErr.Error()
requested := false
var reconnectErrors []string
for _, value := range values {
if err := s.store.UpsertDeviceProxyBinding(r.Context(), value); err != nil {
s.writeStoreError(w, err)
return
}
reconnected, reconnectErr := s.requestProfileProxyRouteReconnect(value.DeviceID, value.ICCID)
requested = requested || reconnected
if reconnectErr != nil {
reconnectErrors = append(reconnectErrors, reconnectErr.Error())
}
}
response := map[string]any{"created": len(values), "reconnect_requested": requested}
if len(reconnectErrors) > 0 {
response["reconnect_error"] = strings.Join(reconnectErrors, "; ")
}
writeJSON(w, http.StatusOK, map[string]any{"data": response})
case http.MethodDelete:
if err := s.store.DeleteDeviceProxyBinding(r.Context(), deviceID); err != nil {
s.writeStoreError(w, err)
var request struct {
UpstreamProxyID string `json:"upstream_proxy_id"`
ICCIDs []string `json:"iccids"`
}
if err := s.decodeJSON(w, r, &request); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
return
}
reconnected, reconnectErr := s.requestProxyRouteReconnect(deviceID)
response := map[string]any{"deleted": true, "reconnect_requested": reconnected}
if reconnectErr != nil {
response["reconnect_error"] = reconnectErr.Error()
if len(request.ICCIDs) == 0 || len(request.ICCIDs) > 200 {
writeError(w, http.StatusBadRequest, "invalid_bindings", "select between 1 and 200 profiles")
return
}
requested := false
deleted := 0
var reconnectErrors []string
for _, rawICCID := range request.ICCIDs {
iccid := strings.TrimSpace(rawICCID)
binding, err := s.store.DeviceProxyBinding(r.Context(), iccid)
if errors.Is(err, store.ErrNotFound) {
continue
}
if err != nil {
s.writeStoreError(w, err)
return
}
if strings.TrimSpace(request.UpstreamProxyID) != "" && binding.UpstreamProxyID != strings.TrimSpace(request.UpstreamProxyID) {
writeError(w, http.StatusConflict, "binding_proxy_mismatch", "selected ICCID is not bound to this upstream proxy")
return
}
if err := s.store.DeleteDeviceProxyBinding(r.Context(), iccid); err != nil {
s.writeStoreError(w, err)
return
}
deleted++
reconnected, reconnectErr := s.requestProfileProxyRouteReconnect(binding.DeviceID, binding.ICCID)
requested = requested || reconnected
if reconnectErr != nil {
reconnectErrors = append(reconnectErrors, reconnectErr.Error())
}
}
response := map[string]any{"deleted": deleted, "reconnect_requested": requested}
if len(reconnectErrors) > 0 {
response["reconnect_error"] = strings.Join(reconnectErrors, "; ")
}
writeJSON(w, http.StatusOK, map[string]any{"data": response})
default:
w.Header().Set("Allow", "PUT, DELETE")
w.Header().Set("Allow", "GET, POST, DELETE")
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
}
}
@@ -211,7 +275,7 @@ func (s *Server) handleDeviceProxyBinding(w http.ResponseWriter, r *http.Request
// A binding is already durable before this is called. Reconnect failures are
// returned as advisory information: the chosen route will still be used on
// the next VoWiFi start/reconnect.
func (s *Server) requestProxyRouteReconnect(deviceID string) (bool, error) {
func (s *Server) requestProfileProxyRouteReconnect(deviceID, iccid string) (bool, error) {
if s.vowifi == nil {
return false, nil
}
@@ -222,6 +286,10 @@ func (s *Server) requestProxyRouteReconnect(deviceID string) (bool, error) {
if !config.VoWiFiEnabled {
return false, nil
}
state, stateErr := s.vowifi.State(deviceID)
if stateErr != nil || strings.TrimSpace(state.ICCID) == "" || strings.TrimSpace(state.ICCID) != strings.TrimSpace(iccid) {
return false, nil
}
if _, err := s.vowifi.RequestReconnect(deviceID); err != nil {
s.logger.Warn("VoWiFi proxy route saved but immediate reconnect was not started", "device_id", deviceID, "error", err)
return false, err
@@ -229,6 +297,19 @@ func (s *Server) requestProxyRouteReconnect(deviceID string) (bool, error) {
return true, nil
}
func validProfileICCID(value string) bool {
value = strings.TrimSpace(value)
if len(value) < 18 || len(value) > 22 {
return false
}
for _, digit := range value {
if digit < '0' || digit > '9' {
return false
}
}
return true
}
func (s *Server) saveAndProbeUpstream(
w http.ResponseWriter,
r *http.Request,
@@ -258,7 +339,7 @@ func (s *Server) saveAndProbeUpstream(
}
for _, binding := range bindings {
if binding.UpstreamProxyID == saved.ID {
s.requestProxyRouteReconnect(binding.DeviceID)
s.requestProfileProxyRouteReconnect(binding.DeviceID, binding.ICCID)
}
}
probe, probeErr := localproxy.ProbeSOCKS5(
@@ -449,6 +530,8 @@ func countryRuleResponse(value store.CountryRule) map[string]any {
func deviceProxyBindingResponse(value store.DeviceProxyBinding) map[string]any {
return map[string]any{
"device_id": value.DeviceID,
"iccid": value.ICCID,
"profile_name": value.ProfileName,
"upstream_proxy_id": value.UpstreamProxyID,
}
}
+61 -95
View File
@@ -10,120 +10,86 @@ import (
"testing"
"vocat/internal/store"
"vocat/internal/vowifi"
)
func TestDeviceProxyBindingPersistsAndReconnectsEnabledVoWiFi(t *testing.T) {
const testProfileICCID = "89441000400128014257"
func newProfileBindingTestServer(t *testing.T) (*Server, *store.Store, *fakeVoWiFiController) {
t.Helper()
database, err := store.Open(context.Background(), ":memory:")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = database.Close() })
if err := database.UpsertDevice(context.Background(), store.Device{
ID: "ec20", Name: "EC20", VoWiFiEnabled: true,
}); err != nil {
if err := database.UpsertDevice(context.Background(), store.Device{ID: "ec20", Name: "EC20", VoWiFiEnabled: true}); err != nil {
t.Fatal(err)
}
if err := database.UpsertUpstreamProxy(context.Background(), store.UpstreamProxy{
ID: "route-1", Name: "Route 1", Addr: "127.0.0.1:1080", Enabled: true,
}); err != nil {
t.Fatal(err)
}
controller := &fakeVoWiFiController{}
server := &Server{
store: database,
vowifi: controller,
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
maxRequestBodyBytes: 4096,
}
request := httptest.NewRequest(
http.MethodPut,
"/api/upstream-proxy-device-bindings/ec20",
bytes.NewBufferString(`{"upstream_proxy_id":"route-1"}`),
)
request.Header.Set("Content-Type", "application/json")
response := httptest.NewRecorder()
server.handleDeviceProxyBinding(response, request, "ec20")
if response.Code != http.StatusOK {
t.Fatalf("PUT status = %d, body = %s", response.Code, response.Body.String())
}
binding, err := database.DeviceProxyBinding(context.Background(), "ec20")
if err != nil || binding.UpstreamProxyID != "route-1" {
t.Fatalf("binding = %+v, %v", binding, err)
}
if controller.reconnects != 1 {
t.Fatalf("reconnects = %d, want 1", controller.reconnects)
}
request = httptest.NewRequest(http.MethodDelete, "/api/upstream-proxy-device-bindings/ec20", nil)
response = httptest.NewRecorder()
server.handleDeviceProxyBinding(response, request, "ec20")
if response.Code != http.StatusOK {
t.Fatalf("DELETE status = %d, body = %s", response.Code, response.Body.String())
}
if _, err := database.DeviceProxyBinding(context.Background(), "ec20"); err != store.ErrNotFound {
t.Fatalf("binding after delete error = %v, want ErrNotFound", err)
}
if controller.reconnects != 2 {
t.Fatalf("reconnects = %d, want 2", controller.reconnects)
}
}
func TestDeviceProxyBindingRejectsRebindToDifferentUpstream(t *testing.T) {
database, err := store.Open(context.Background(), ":memory:")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = database.Close() })
if err := database.UpsertDevice(context.Background(), store.Device{
ID: "ec20", Name: "EC20", VoWiFiEnabled: true,
}); err != nil {
t.Fatal(err)
}
for _, up := range []store.UpstreamProxy{
for _, upstream := range []store.UpstreamProxy{
{ID: "route-1", Name: "Route 1", Addr: "127.0.0.1:1080", Enabled: true},
{ID: "route-2", Name: "Route 2", Addr: "127.0.0.1:1081", Enabled: true},
} {
if err := database.UpsertUpstreamProxy(context.Background(), up); err != nil {
if err := database.UpsertUpstreamProxy(context.Background(), upstream); err != nil {
t.Fatal(err)
}
}
server := &Server{
store: database,
vowifi: &fakeVoWiFiController{},
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
maxRequestBodyBytes: 4096,
controller := &fakeVoWiFiController{state: vowifi.State{DeviceID: "ec20", ICCID: testProfileICCID, Enabled: true}}
return &Server{store: database, vowifi: controller, logger: slog.New(slog.NewTextHandler(io.Discard, nil)), maxRequestBodyBytes: 16 << 10}, database, controller
}
func profileBindingRequest(t *testing.T, server *Server, method, body string) *httptest.ResponseRecorder {
t.Helper()
request := httptest.NewRequest(method, "/api/upstream-proxy-profile-bindings", bytes.NewBufferString(body))
request.Header.Set("Content-Type", "application/json")
response := httptest.NewRecorder()
server.handleProfileProxyBindings(response, request)
return response
}
func TestProfileProxyBindingPersistsAndReconnectsOnlyCurrentICCID(t *testing.T) {
server, database, controller := newProfileBindingTestServer(t)
response := profileBindingRequest(t, server, http.MethodPost, `{
"upstream_proxy_id":"route-1",
"bindings":[
{"device_id":"ec20","iccid":"89441000400128014257","profile_name":"Vodafone UK","state_text":"Enabled"},
{"device_id":"ec20","iccid":"89104100000028106378","profile_name":"TIM"}
]
}`)
if response.Code != http.StatusOK {
t.Fatalf("POST status = %d, body = %s", response.Code, response.Body.String())
}
binding, err := database.DeviceProxyBinding(context.Background(), testProfileICCID)
if err != nil || binding.UpstreamProxyID != "route-1" || binding.ProfileName != "Vodafone UK" {
t.Fatalf("binding = %+v, %v", binding, err)
}
if controller.reconnects != 1 {
t.Fatalf("reconnects = %d, want only the current ICCID to reconnect", controller.reconnects)
}
// First bind to route-1 succeeds.
put := func(proxyID string) *httptest.ResponseRecorder {
req := httptest.NewRequest(
http.MethodPut,
"/api/upstream-proxy-device-bindings/ec20",
bytes.NewBufferString(`{"upstream_proxy_id":"`+proxyID+`"}`),
)
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
server.handleDeviceProxyBinding(rec, req, "ec20")
return rec
response = profileBindingRequest(t, server, http.MethodDelete, `{"upstream_proxy_id":"route-1","iccids":["89441000400128014257","89104100000028106378"]}`)
if response.Code != http.StatusOK {
t.Fatalf("DELETE status = %d, body = %s", response.Code, response.Body.String())
}
if rec := put("route-1"); rec.Code != http.StatusOK {
t.Fatalf("initial bind status = %d, body = %s", rec.Code, rec.Body.String())
if _, err := database.DeviceProxyBinding(context.Background(), testProfileICCID); err != store.ErrNotFound {
t.Fatalf("binding after delete error = %v, want ErrNotFound", err)
}
// Rebind to a different upstream must be rejected with 409.
rec := put("route-2")
if rec.Code != http.StatusConflict {
t.Fatalf("rebind status = %d, want 409, body = %s", rec.Code, rec.Body.String())
}
binding, err := database.DeviceProxyBinding(context.Background(), "ec20")
if err != nil || binding.UpstreamProxyID != "route-1" {
t.Fatalf("binding after rejected rebind = %+v, %v (want route-1 unchanged)", binding, err)
}
// Re-binding the SAME upstream stays idempotent (no 409).
if rec := put("route-1"); rec.Code != http.StatusOK {
t.Fatalf("idempotent rebind status = %d, want 200, body = %s", rec.Code, rec.Body.String())
if controller.reconnects != 2 {
t.Fatalf("reconnects after delete = %d, want 2", controller.reconnects)
}
}
func TestProfileProxyBindingRejectsSameICCIDOnDifferentProxy(t *testing.T) {
server, database, _ := newProfileBindingTestServer(t)
first := profileBindingRequest(t, server, http.MethodPost, `{"upstream_proxy_id":"route-1","bindings":[{"device_id":"ec20","iccid":"89441000400128014257","profile_name":"Profile"}]}`)
if first.Code != http.StatusOK {
t.Fatalf("initial bind status = %d, body = %s", first.Code, first.Body.String())
}
second := profileBindingRequest(t, server, http.MethodPost, `{"upstream_proxy_id":"route-2","bindings":[{"device_id":"ec20","iccid":"89441000400128014257","profile_name":"Profile"}]}`)
if second.Code != http.StatusConflict {
t.Fatalf("rebind status = %d, want 409, body = %s", second.Code, second.Body.String())
}
binding, err := database.DeviceProxyBinding(context.Background(), testProfileICCID)
if err != nil || binding.UpstreamProxyID != "route-1" {
t.Fatalf("binding after rejected rebind = %+v, %v", binding, err)
}
}
+38 -3
View File
@@ -179,16 +179,51 @@ func (s *Store) UpdateAutomaticTaskRun(ctx context.Context, run AutomaticTaskRun
return err
}
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() {
+48
View File
@@ -70,3 +70,51 @@ 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)
}
}
+46 -4
View File
@@ -106,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")
@@ -604,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 {
@@ -618,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)
}
}
+31
View File
@@ -196,6 +196,37 @@ func migrationStatements(version int) []string {
`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)`,
}
default:
return nil
}
+4 -3
View File
@@ -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
+21 -17
View File
@@ -358,9 +358,11 @@ func upstreamProxy(row rowScanner) (UpstreamProxy, error) {
func (s *Store) UpsertDeviceProxyBinding(ctx context.Context, value DeviceProxyBinding) error {
value.DeviceID = strings.TrimSpace(value.DeviceID)
value.ICCID = strings.TrimSpace(value.ICCID)
value.ProfileName = strings.TrimSpace(value.ProfileName)
value.UpstreamProxyID = strings.TrimSpace(value.UpstreamProxyID)
if value.DeviceID == "" || value.UpstreamProxyID == "" {
return errors.New("device proxy binding requires device and upstream proxy IDs")
if value.DeviceID == "" || value.ICCID == "" || value.UpstreamProxyID == "" {
return errors.New("profile proxy binding requires device ID, ICCID, and upstream proxy ID")
}
now := time.Now().UTC()
createdAt := value.CreatedAt
@@ -373,28 +375,30 @@ func (s *Store) UpsertDeviceProxyBinding(ctx context.Context, value DeviceProxyB
}
_, err := s.db.ExecContext(ctx, `
INSERT INTO device_proxy_bindings (
device_id, upstream_proxy_id, created_at, updated_at
) VALUES (?, ?, ?, ?)
ON CONFLICT(device_id) DO UPDATE SET
iccid, device_id, profile_name, upstream_proxy_id, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(iccid) DO UPDATE SET
device_id = excluded.device_id,
profile_name = excluded.profile_name,
upstream_proxy_id = excluded.upstream_proxy_id,
updated_at = excluded.updated_at
`, value.DeviceID, value.UpstreamProxyID, createdAt.Unix(), updatedAt.Unix())
`, value.ICCID, value.DeviceID, value.ProfileName, value.UpstreamProxyID, createdAt.Unix(), updatedAt.Unix())
if err != nil {
return fmt.Errorf("upsert proxy binding for device %q: %w", value.DeviceID, err)
return fmt.Errorf("upsert proxy binding for ICCID %q: %w", value.ICCID, err)
}
return nil
}
func (s *Store) DeviceProxyBinding(ctx context.Context, deviceID string) (DeviceProxyBinding, error) {
func (s *Store) DeviceProxyBinding(ctx context.Context, iccid string) (DeviceProxyBinding, error) {
return deviceProxyBinding(s.db.QueryRowContext(
ctx,
deviceProxyBindingSelect+` WHERE device_id = ?`,
strings.TrimSpace(deviceID),
deviceProxyBindingSelect+` WHERE iccid = ?`,
strings.TrimSpace(iccid),
))
}
func (s *Store) ListDeviceProxyBindings(ctx context.Context) ([]DeviceProxyBinding, error) {
rows, err := s.db.QueryContext(ctx, deviceProxyBindingSelect+` ORDER BY device_id`)
rows, err := s.db.QueryContext(ctx, deviceProxyBindingSelect+` ORDER BY device_id, profile_name COLLATE NOCASE, iccid`)
if err != nil {
return nil, fmt.Errorf("list device proxy bindings: %w", err)
}
@@ -413,26 +417,26 @@ func (s *Store) ListDeviceProxyBindings(ctx context.Context) ([]DeviceProxyBindi
return values, nil
}
func (s *Store) DeleteDeviceProxyBinding(ctx context.Context, deviceID string) error {
func (s *Store) DeleteDeviceProxyBinding(ctx context.Context, iccid string) error {
result, err := s.db.ExecContext(
ctx,
`DELETE FROM device_proxy_bindings WHERE device_id = ?`,
strings.TrimSpace(deviceID),
`DELETE FROM device_proxy_bindings WHERE iccid = ?`,
strings.TrimSpace(iccid),
)
if err != nil {
return fmt.Errorf("delete proxy binding for device %q: %w", deviceID, err)
return fmt.Errorf("delete proxy binding for ICCID %q: %w", iccid, err)
}
return requireAffected(result)
}
const deviceProxyBindingSelect = `
SELECT device_id, upstream_proxy_id, created_at, updated_at
SELECT device_id, iccid, profile_name, upstream_proxy_id, created_at, updated_at
FROM device_proxy_bindings`
func deviceProxyBinding(row rowScanner) (DeviceProxyBinding, error) {
var value DeviceProxyBinding
var createdAt, updatedAt int64
err := row.Scan(&value.DeviceID, &value.UpstreamProxyID, &createdAt, &updatedAt)
err := row.Scan(&value.DeviceID, &value.ICCID, &value.ProfileName, &value.UpstreamProxyID, &createdAt, &updatedAt)
if errors.Is(err, sql.ErrNoRows) {
return DeviceProxyBinding{}, ErrNotFound
}
+1 -1
View File
@@ -13,7 +13,7 @@ import (
_ "modernc.org/sqlite"
)
const schemaVersion = 11
const schemaVersion = 12
var ErrNotFound = errors.New("store: not found")
+4 -3
View File
@@ -27,15 +27,16 @@ func (resolver ProxyResolver) Resolve(
return vowifi.ProxyRoute{}, errors.New("vowifi proxy resolver: store is nil")
}
deviceID := strings.TrimSpace(request.DeviceID)
if deviceID == "" {
iccid := strings.TrimSpace(request.ICCID)
if deviceID == "" || iccid == "" {
return vowifi.ProxyRoute{Mode: vowifi.ProxyModeDirect}, nil
}
binding, err := resolver.Store.DeviceProxyBinding(ctx, deviceID)
binding, err := resolver.Store.DeviceProxyBinding(ctx, iccid)
if errors.Is(err, store.ErrNotFound) {
return vowifi.ProxyRoute{Mode: vowifi.ProxyModeDirect}, nil
}
if err != nil {
return vowifi.ProxyRoute{}, fmt.Errorf("resolve proxy binding for device %s: %w", deviceID, err)
return vowifi.ProxyRoute{}, fmt.Errorf("resolve proxy binding for ICCID %s: %w", iccid, err)
}
upstream, err := resolver.Store.UpstreamProxy(ctx, binding.UpstreamProxyID)
if err != nil {
+24 -2
View File
@@ -21,7 +21,7 @@ func testStore(t *testing.T) *store.Store {
return database
}
func TestProxyResolverUsesDeviceBinding(t *testing.T) {
func TestProxyResolverUsesICCIDProfileBinding(t *testing.T) {
database := testStore(t)
if err := database.UpsertDevice(context.Background(), store.Device{ID: "ec20", Name: "EC20"}); err != nil {
t.Fatal(err)
@@ -38,13 +38,15 @@ func TestProxyResolverUsesDeviceBinding(t *testing.T) {
}
if err := database.UpsertDeviceProxyBinding(context.Background(), store.DeviceProxyBinding{
DeviceID: "ec20",
ICCID: "89441000400128014257",
ProfileName: "Vodafone UK",
UpstreamProxyID: "clash",
}); err != nil {
t.Fatal(err)
}
route, err := (ProxyResolver{Store: database}).Resolve(
context.Background(),
vowifi.ProxyRequest{DeviceID: "ec20", HomeMCC: "234", HomeMNC: "15"},
vowifi.ProxyRequest{DeviceID: "ec20", ICCID: "89441000400128014257", HomeMCC: "234", HomeMNC: "15"},
)
if err != nil {
t.Fatal(err)
@@ -57,6 +59,26 @@ func TestProxyResolverUsesDeviceBinding(t *testing.T) {
}
}
func TestProxyResolverDoesNotLeakBindingToAnotherProfileOnSameDevice(t *testing.T) {
database := testStore(t)
if err := database.UpsertDevice(context.Background(), store.Device{ID: "ec20", Name: "EC20"}); err != nil {
t.Fatal(err)
}
if err := database.UpsertUpstreamProxy(context.Background(), store.UpstreamProxy{ID: "proxy", Name: "Proxy", Addr: "127.0.0.1:1080", Enabled: true}); err != nil {
t.Fatal(err)
}
if err := database.UpsertDeviceProxyBinding(context.Background(), store.DeviceProxyBinding{DeviceID: "ec20", ICCID: "89441000400128014257", ProfileName: "A", UpstreamProxyID: "proxy"}); err != nil {
t.Fatal(err)
}
route, err := (ProxyResolver{Store: database}).Resolve(context.Background(), vowifi.ProxyRequest{DeviceID: "ec20", ICCID: "89104100000028106378"})
if err != nil {
t.Fatal(err)
}
if route.Mode != vowifi.ProxyModeDirect {
t.Fatalf("route = %#v, want direct for unbound ICCID", route)
}
}
func TestProxyResolverDoesNotUseCountryRuleWithoutDeviceBinding(t *testing.T) {
database := testStore(t)
if err := database.UpsertUpstreamProxy(context.Background(), store.UpstreamProxy{
+1
View File
@@ -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)),
+1
View File
@@ -203,6 +203,7 @@ type ProxyRoute struct {
type ProxyRequest struct {
DeviceID string
ICCID string
HomeMCC string
HomeMNC string
CountryCode string