| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563 |
- package server
- import (
- "context"
- "encoding/json"
- "errors"
- "net/http"
- "strings"
- "time"
- "vocat/internal/i18n"
- localproxy "vocat/internal/proxy"
- "vocat/internal/store"
- )
- func (s *Server) routeProxyAPI(w http.ResponseWriter, r *http.Request, cleanPath string) bool {
- switch cleanPath {
- case "upstream-proxies":
- s.handleUpstreamProxies(w, r)
- case "upstream-proxy-probe":
- s.handleUpstreamProbeConfig(w, r)
- case "upstream-proxy-countries":
- if !requireMethod(w, r, http.MethodGet) {
- return true
- }
- 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)
- default:
- segments := splitAPIPath(cleanPath)
- switch {
- case len(segments) == 2 && segments[0] == "upstream-proxies":
- s.handleUpstreamProxy(w, r, segments[1])
- case len(segments) == 4 &&
- segments[0] == "upstream-proxies" &&
- segments[2] == "actions" &&
- segments[3] == "probe":
- 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
- }
- }
- return true
- }
- type upstreamProxyPayload struct {
- ID string `json:"id"`
- Name string `json:"name"`
- Addr string `json:"addr"`
- Username string `json:"username"`
- Password string `json:"password"`
- Enabled bool `json:"enabled"`
- }
- func (s *Server) handleUpstreamProxies(w http.ResponseWriter, r *http.Request) {
- switch r.Method {
- case http.MethodGet:
- values, err := s.store.ListUpstreamProxies(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, upstreamProxyResponse(value.Redacted()))
- }
- writeJSON(w, http.StatusOK, map[string]any{"data": result})
- case http.MethodPost:
- var payload upstreamProxyPayload
- if err := s.decodeJSON(w, r, &payload); err != nil {
- writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
- return
- }
- if !validObjectID(payload.ID) {
- writeError(w, http.StatusBadRequest, "invalid_proxy_id", "proxy ID must use 1-64 safe characters")
- return
- }
- s.saveAndProbeUpstream(w, r, payload)
- default:
- w.Header().Set("Allow", "GET, POST")
- writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
- }
- }
- func (s *Server) handleUpstreamProxy(w http.ResponseWriter, r *http.Request, id string) {
- switch r.Method {
- case http.MethodPut:
- var payload upstreamProxyPayload
- if err := s.decodeJSON(w, r, &payload); err != nil {
- writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
- return
- }
- if payload.ID != "" && payload.ID != id {
- writeError(w, http.StatusConflict, "immutable_proxy_id", "upstream proxy ID cannot be changed")
- return
- }
- payload.ID = id
- s.saveAndProbeUpstream(w, r, payload)
- case http.MethodDelete:
- bindings, listErr := s.store.ListDeviceProxyBindings(r.Context())
- if listErr != nil {
- s.writeStoreError(w, listErr)
- return
- }
- if err := s.store.DeleteUpstreamProxy(r.Context(), id); err != nil {
- s.writeStoreError(w, err)
- return
- }
- for _, binding := range bindings {
- if binding.UpstreamProxyID == id {
- s.requestProxyRouteReconnect(binding.DeviceID)
- }
- }
- writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"deleted": true}})
- default:
- w.Header().Set("Allow", "PUT, DELETE")
- writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
- }
- }
- func (s *Server) 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})
- }
- 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
- }
- switch r.Method {
- case http.MethodPut:
- var request struct {
- UpstreamProxyID string `json:"upstream_proxy_id"`
- }
- if err := s.decodeJSON(w, r, &request); err != nil {
- writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
- return
- }
- request.UpstreamProxyID = strings.TrimSpace(request.UpstreamProxyID)
- upstream, err := s.store.UpstreamProxy(r.Context(), request.UpstreamProxyID)
- if err != nil {
- s.writeStoreError(w, err)
- return
- }
- if !upstream.Enabled {
- writeError(w, http.StatusConflict, "upstream_proxy_disabled", "enable the upstream proxy before binding a device")
- 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)
- return
- }
- value := store.DeviceProxyBinding{DeviceID: deviceID, UpstreamProxyID: upstream.ID}
- if err := s.store.UpsertDeviceProxyBinding(r.Context(), value); err != nil {
- s.writeStoreError(w, err)
- return
- }
- reconnected, reconnectErr := s.requestProxyRouteReconnect(deviceID)
- response := deviceProxyBindingResponse(value)
- response["reconnect_requested"] = reconnected
- if reconnectErr != nil {
- response["reconnect_error"] = reconnectErr.Error()
- }
- 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)
- return
- }
- reconnected, reconnectErr := s.requestProxyRouteReconnect(deviceID)
- response := map[string]any{"deleted": true, "reconnect_requested": reconnected}
- if reconnectErr != nil {
- response["reconnect_error"] = reconnectErr.Error()
- }
- writeJSON(w, http.StatusOK, map[string]any{"data": response})
- default:
- w.Header().Set("Allow", "PUT, DELETE")
- writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
- }
- }
- // 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) {
- if s.vowifi == nil {
- return false, nil
- }
- config, err := s.store.Device(context.Background(), deviceID)
- if err != nil {
- return false, err
- }
- if !config.VoWiFiEnabled {
- 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
- }
- return true, nil
- }
- func (s *Server) saveAndProbeUpstream(
- w http.ResponseWriter,
- r *http.Request,
- payload upstreamProxyPayload,
- ) {
- value := store.UpstreamProxy{
- ID: payload.ID,
- Name: payload.Name,
- Addr: payload.Addr,
- Username: payload.Username,
- Password: payload.Password,
- Enabled: payload.Enabled,
- }
- if err := s.store.UpsertUpstreamProxy(r.Context(), value); err != nil {
- s.writeStoreError(w, err)
- return
- }
- saved, err := s.store.UpstreamProxy(r.Context(), value.ID)
- if err != nil {
- s.writeStoreError(w, err)
- return
- }
- bindings, err := s.store.ListDeviceProxyBindings(r.Context())
- if err != nil {
- s.writeStoreError(w, err)
- return
- }
- for _, binding := range bindings {
- if binding.UpstreamProxyID == saved.ID {
- s.requestProxyRouteReconnect(binding.DeviceID)
- }
- }
- probe, probeErr := localproxy.ProbeSOCKS5(
- r.Context(),
- saved.Addr,
- saved.Username,
- saved.Password,
- 8*time.Second,
- )
- probeResponse := probeMap(probe, probeErr)
- message := i18n.T("代理已保存;UDP ASSOCIATE 尚未通过。")
- if probeErr == nil && probe.UDPAssociateOK {
- message = i18n.T("代理已保存,SOCKS5 认证与 UDP ASSOCIATE 均通过。")
- }
- writeJSON(w, http.StatusOK, map[string]any{
- "data": map[string]any{
- "status": "saved",
- "proxy": upstreamProxyResponse(saved.Redacted()),
- "probe": probeResponse,
- "message": message,
- },
- })
- }
- func (s *Server) handleUpstreamProbe(w http.ResponseWriter, r *http.Request, id string) {
- if !requireMethod(w, r, http.MethodPost) {
- return
- }
- value, err := s.store.UpstreamProxy(r.Context(), id)
- if err != nil {
- s.writeStoreError(w, err)
- return
- }
- result, probeErr := localproxy.ProbeSOCKS5(
- r.Context(),
- value.Addr,
- value.Username,
- value.Password,
- 8*time.Second,
- )
- message := i18n.T("代理不能承载 VoWiFi 所需的 UDP。")
- if probeErr == nil && result.UDPAssociateOK {
- message = i18n.T("SOCKS5 认证与 UDP ASSOCIATE 探测通过。")
- }
- writeJSON(w, http.StatusOK, map[string]any{
- "data": map[string]any{
- "status": "probed",
- "probe": probeMap(result, probeErr),
- "message": message,
- },
- })
- }
- // handleUpstreamProbeConfig probes a front proxy straight from the editor's
- // form values, so connectivity (above all UDP ASSOCIATE, which VoWiFi depends
- // on) can be verified before the proxy is ever saved. When the form edits an
- // existing proxy and leaves the password blank (meaning "keep the stored
- // secret"), the stored record supplies the missing credentials.
- func (s *Server) handleUpstreamProbeConfig(w http.ResponseWriter, r *http.Request) {
- if !requireMethod(w, r, http.MethodPost) {
- return
- }
- var payload upstreamProxyPayload
- if err := s.decodeJSON(w, r, &payload); err != nil {
- writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
- return
- }
- addr := strings.TrimSpace(payload.Addr)
- username := strings.TrimSpace(payload.Username)
- password := payload.Password
- if id := strings.TrimSpace(payload.ID); id != "" {
- if stored, err := s.store.UpstreamProxy(r.Context(), id); err == nil {
- if addr == "" {
- addr = stored.Addr
- }
- if username == "" {
- username = stored.Username
- }
- if password == "" || password == store.SecretMask {
- password = stored.Password
- }
- }
- }
- if addr == "" {
- writeError(w, http.StatusBadRequest, "invalid_proxy_addr", "Socks5 address is required")
- return
- }
- result, probeErr := localproxy.ProbeSOCKS5(
- r.Context(),
- addr,
- username,
- password,
- 8*time.Second,
- )
- message := i18n.T("代理不能承载 VoWiFi 所需的 UDP。")
- if probeErr == nil && result.UDPAssociateOK {
- message = i18n.T("SOCKS5 认证与 UDP ASSOCIATE 探测通过。")
- }
- writeJSON(w, http.StatusOK, map[string]any{
- "data": map[string]any{
- "status": "probed",
- "probe": probeMap(result, probeErr),
- "message": message,
- },
- })
- }
- func (s *Server) handleCountryRules(w http.ResponseWriter, r *http.Request) {
- if !requireMethod(w, r, http.MethodGet) {
- return
- }
- values, err := s.store.ListCountryRules(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, countryRuleResponse(value))
- }
- writeJSON(w, http.StatusOK, map[string]any{"data": result})
- }
- func (s *Server) handleCountryRule(w http.ResponseWriter, r *http.Request, countryCode string) {
- countryCode = strings.ToUpper(strings.TrimSpace(countryCode))
- switch r.Method {
- case http.MethodPut:
- var request struct {
- UpstreamProxyID string `json:"upstream_proxy_id"`
- Enabled bool `json:"enabled"`
- }
- if err := s.decodeJSON(w, r, &request); err != nil {
- writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
- return
- }
- country := countryByCode(countryCode)
- if country == nil {
- writeError(w, http.StatusBadRequest, "invalid_country", "country code is not in the supported MCC table")
- return
- }
- if _, err := s.store.UpstreamProxy(r.Context(), request.UpstreamProxyID); err != nil {
- s.writeStoreError(w, err)
- return
- }
- value := store.CountryRule{
- CountryCode: countryCode,
- CountryName: country.Name,
- UpstreamProxyID: request.UpstreamProxyID,
- Enabled: request.Enabled,
- }
- if err := s.store.UpsertCountryRule(r.Context(), value); err != nil {
- s.writeStoreError(w, err)
- return
- }
- writeJSON(w, http.StatusOK, map[string]any{"data": countryRuleResponse(value)})
- case http.MethodDelete:
- if err := s.store.DeleteCountryRule(r.Context(), countryCode); err != nil {
- s.writeStoreError(w, err)
- return
- }
- writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"deleted": true}})
- default:
- w.Header().Set("Allow", "PUT, DELETE")
- writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
- }
- }
- func upstreamProxyResponse(value store.UpstreamProxy) map[string]any {
- return map[string]any{
- "id": value.ID,
- "name": value.Name,
- "addr": value.Addr,
- "username": value.Username,
- "password": value.Password,
- "enabled": value.Enabled,
- }
- }
- func countryRuleResponse(value store.CountryRule) map[string]any {
- return map[string]any{
- "country_code": value.CountryCode,
- "country_name": value.CountryName,
- "upstream_proxy_id": value.UpstreamProxyID,
- "enabled": value.Enabled,
- }
- }
- func deviceProxyBindingResponse(value store.DeviceProxyBinding) map[string]any {
- return map[string]any{
- "device_id": value.DeviceID,
- "upstream_proxy_id": value.UpstreamProxyID,
- }
- }
- func probeMap(result localproxy.ProbeResult, err error) map[string]any {
- encoded, _ := json.Marshal(result)
- var response map[string]any
- _ = json.Unmarshal(encoded, &response)
- if err != nil {
- response["error"] = err.Error()
- }
- return response
- }
- func validObjectID(value string) bool {
- return validDeviceID(value)
- }
- type proxyCountry struct {
- Code string
- Name string
- MCCs []string
- }
- func (country proxyCountry) MarshalJSON() ([]byte, error) {
- return json.Marshal(map[string]any{
- "country_code": country.Code,
- "country_name": i18n.T(country.Name),
- "mccs": country.MCCs,
- })
- }
- func countryByCode(code string) *proxyCountry {
- for index := range proxyCountries {
- if proxyCountries[index].Code == code {
- return &proxyCountries[index]
- }
- }
- return nil
- }
- // countryNameForMCC resolves a mobile country code to a display name using the
- // shared MCC table. It returns an empty string for an unknown or empty MCC.
- func countryNameForMCC(mcc string) string {
- if mcc == "" {
- return ""
- }
- for index := range proxyCountries {
- for _, candidate := range proxyCountries[index].MCCs {
- if candidate == mcc {
- return i18n.T(proxyCountries[index].Name)
- }
- }
- }
- return ""
- }
- var proxyCountries = []proxyCountry{
- {Code: "CN", Name: "中国", MCCs: []string{"460", "461"}},
- {Code: "HK", Name: "中国香港", MCCs: []string{"454"}},
- {Code: "MO", Name: "中国澳门", MCCs: []string{"455"}},
- {Code: "TW", Name: "中国台湾", MCCs: []string{"466"}},
- {Code: "US", Name: "美国", MCCs: []string{"310", "311", "312", "313", "314", "315", "316"}},
- {Code: "CA", Name: "加拿大", MCCs: []string{"302"}},
- {Code: "GB", Name: "英国", MCCs: []string{"234", "235"}},
- {Code: "DE", Name: "德国", MCCs: []string{"262"}},
- {Code: "FR", Name: "法国", MCCs: []string{"208"}},
- {Code: "IT", Name: "意大利", MCCs: []string{"222"}},
- {Code: "ES", Name: "西班牙", MCCs: []string{"214"}},
- {Code: "PT", Name: "葡萄牙", MCCs: []string{"268"}},
- {Code: "NL", Name: "荷兰", MCCs: []string{"204"}},
- {Code: "BE", Name: "比利时", MCCs: []string{"206"}},
- {Code: "CH", Name: "瑞士", MCCs: []string{"228"}},
- {Code: "AT", Name: "奥地利", MCCs: []string{"232"}},
- {Code: "IE", Name: "爱尔兰", MCCs: []string{"272"}},
- {Code: "DK", Name: "丹麦", MCCs: []string{"238"}},
- {Code: "SE", Name: "瑞典", MCCs: []string{"240"}},
- {Code: "NO", Name: "挪威", MCCs: []string{"242"}},
- {Code: "FI", Name: "芬兰", MCCs: []string{"244"}},
- {Code: "PL", Name: "波兰", MCCs: []string{"260"}},
- {Code: "CZ", Name: "捷克", MCCs: []string{"230"}},
- {Code: "GR", Name: "希腊", MCCs: []string{"202"}},
- {Code: "RO", Name: "罗马尼亚", MCCs: []string{"226"}},
- {Code: "HU", Name: "匈牙利", MCCs: []string{"216"}},
- {Code: "UA", Name: "乌克兰", MCCs: []string{"255"}},
- {Code: "RU", Name: "俄罗斯", MCCs: []string{"250"}},
- {Code: "TR", Name: "土耳其", MCCs: []string{"286"}},
- {Code: "JP", Name: "日本", MCCs: []string{"440", "441"}},
- {Code: "KR", Name: "韩国", MCCs: []string{"450"}},
- {Code: "SG", Name: "新加坡", MCCs: []string{"525"}},
- {Code: "MY", Name: "马来西亚", MCCs: []string{"502"}},
- {Code: "TH", Name: "泰国", MCCs: []string{"520"}},
- {Code: "VN", Name: "越南", MCCs: []string{"452"}},
- {Code: "PH", Name: "菲律宾", MCCs: []string{"515"}},
- {Code: "ID", Name: "印度尼西亚", MCCs: []string{"510"}},
- {Code: "IN", Name: "印度", MCCs: []string{"404", "405", "406"}},
- {Code: "PK", Name: "巴基斯坦", MCCs: []string{"410"}},
- {Code: "AE", Name: "阿联酋", MCCs: []string{"424", "430", "431"}},
- {Code: "SA", Name: "沙特阿拉伯", MCCs: []string{"420"}},
- {Code: "IL", Name: "以色列", MCCs: []string{"425"}},
- {Code: "AU", Name: "澳大利亚", MCCs: []string{"505"}},
- {Code: "NZ", Name: "新西兰", MCCs: []string{"530"}},
- {Code: "BR", Name: "巴西", MCCs: []string{"724"}},
- {Code: "MX", Name: "墨西哥", MCCs: []string{"334"}},
- {Code: "AR", Name: "阿根廷", MCCs: []string{"722"}},
- {Code: "CL", Name: "智利", MCCs: []string{"730"}},
- {Code: "CO", Name: "哥伦比亚", MCCs: []string{"732"}},
- {Code: "ZA", Name: "南非", MCCs: []string{"655"}},
- {Code: "EG", Name: "埃及", MCCs: []string{"602"}},
- {Code: "NG", Name: "尼日利亚", MCCs: []string{"621"}},
- {Code: "KE", Name: "肯尼亚", MCCs: []string{"639"}},
- }
|