diff --git a/internal/xiaomi/xiaomi.go b/internal/xiaomi/xiaomi.go new file mode 100644 index 0000000..a8a1dc9 --- /dev/null +++ b/internal/xiaomi/xiaomi.go @@ -0,0 +1,375 @@ +package xiaomi + +import ( + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "strings" + "sync" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/xiaomi" + "github.com/AlexxIT/go2rtc/pkg/xiaomi/crypto" + "github.com/eduard256/strix/internal/api" + "github.com/eduard256/strix/internal/app" + "github.com/eduard256/strix/pkg/tester" + "github.com/rs/zerolog" +) + +func Init() { + log = app.GetLogger("xiaomi") + + tester.RegisterSource("xiaomi", func(rawURL string) (core.Producer, error) { + u, err := url.Parse(rawURL) + if err != nil { + return nil, err + } + + // stateless: token comes from URL query, not from persistent config + if token := u.Query().Get("token"); token != "" && u.User != nil { + userID := u.User.Username() + cloudsMu.Lock() + if tokens == nil { + tokens = map[string]string{userID: token} + } else { + tokens[userID] = token + } + cloudsMu.Unlock() + } + + if u.User != nil { + rawURL, err = getCameraURL(u) + if err != nil { + return nil, err + } + } + + log.Debug().Msgf("xiaomi: dial %s", rawURL) + + return xiaomi.Dial(rawURL) + }) + + api.HandleFunc("api/xiaomi", apiXiaomi) +} + +var log zerolog.Logger + +var tokens map[string]string +var clouds map[string]*xiaomi.Cloud +var cloudsMu sync.Mutex + +func getCloud(userID string) (*xiaomi.Cloud, error) { + cloudsMu.Lock() + defer cloudsMu.Unlock() + + if cloud := clouds[userID]; cloud != nil { + return cloud, nil + } + + cloud := xiaomi.NewCloud(AppXiaomiHome) + if err := cloud.LoginWithToken(userID, tokens[userID]); err != nil { + return nil, err + } + if clouds == nil { + clouds = map[string]*xiaomi.Cloud{userID: cloud} + } else { + clouds[userID] = cloud + } + return cloud, nil +} + +func cloudRequest(userID, region, apiURL, params string) ([]byte, error) { + cloud, err := getCloud(userID) + if err != nil { + return nil, err + } + return cloud.Request(GetBaseURL(region), apiURL, params, nil) +} + +func cloudUserRequest(user *url.Userinfo, apiURL, params string) ([]byte, error) { + userID := user.Username() + region, _ := user.Password() + return cloudRequest(userID, region, apiURL, params) +} + +func getCameraURL(url *url.URL) (string, error) { + model := url.Query().Get("model") + + // It is not known which models need to be awakened. + // Probably all the doorbells and all the battery cameras. + if strings.Contains(model, ".cateye.") { + _ = wakeUpCamera(url) + } + + // The getMissURL request has a fallback to getP2PURL. + // But for known models we can save one request to the cloud. + if xiaomi.IsLegacy(model) { + return getLegacyURL(url) + } + return getMissURL(url) +} + +func getLegacyURL(url *url.URL) (string, error) { + query := url.Query() + + clientPublic, clientPrivate, err := crypto.GenerateKey() + if err != nil { + return "", err + } + + params := fmt.Sprintf(`{"did":"%s","toSignAppData":"%x"}`, query.Get("did"), clientPublic) + + userID := url.User.Username() + region, _ := url.User.Password() + res, err := cloudRequest(userID, region, "/device/devicepass", params) + if err != nil { + return "", err + } + + var v struct { + UID string `json:"p2p_id"` + Password string `json:"password"` + PublicKey string `json:"p2p_dev_public_key"` + Sign string `json:"signForAppData"` + } + if err = json.Unmarshal(res, &v); err != nil { + return "", err + } + + query.Set("uid", v.UID) + + if v.Sign != "" { + query.Set("client_public", hex.EncodeToString(clientPublic)) + query.Set("client_private", hex.EncodeToString(clientPrivate)) + query.Set("device_public", v.PublicKey) + query.Set("sign", v.Sign) + } else { + query.Set("password", v.Password) + } + + url.RawQuery = query.Encode() + return url.String(), nil +} + +func getMissURL(url *url.URL) (string, error) { + clientPublic, clientPrivate, err := crypto.GenerateKey() + if err != nil { + return "", err + } + + query := url.Query() + params := fmt.Sprintf( + `{"app_pubkey":"%x","did":"%s","support_vendors":"TUTK_CS2_MTP"}`, + clientPublic, query.Get("did"), + ) + + res, err := cloudUserRequest(url.User, "/v2/device/miss_get_vendor", params) + if err != nil { + if strings.Contains(err.Error(), "no available vendor support") { + return getLegacyURL(url) + } + return "", err + } + + var v struct { + Vendor struct { + ID byte `json:"vendor"` + Params struct { + UID string `json:"p2p_id"` + } `json:"vendor_params"` + } `json:"vendor"` + PublicKey string `json:"public_key"` + Sign string `json:"sign"` + } + if err = json.Unmarshal(res, &v); err != nil { + return "", err + } + + query.Set("client_public", hex.EncodeToString(clientPublic)) + query.Set("client_private", hex.EncodeToString(clientPrivate)) + query.Set("device_public", v.PublicKey) + query.Set("sign", v.Sign) + query.Set("vendor", getVendorName(v.Vendor.ID)) + + if v.Vendor.ID == 1 { + query.Set("uid", v.Vendor.Params.UID) + } + + url.RawQuery = query.Encode() + return url.String(), nil +} + +func getVendorName(i byte) string { + switch i { + case 1: + return "tutk" + case 3: + return "agora" + case 4: + return "cs2" + case 6: + return "mtp" + } + return fmt.Sprintf("%d", i) +} + +func wakeUpCamera(url *url.URL) error { + const params = `{"id":1,"method":"wakeup","params":{"video":"1"}}` + did := url.Query().Get("did") + _, err := cloudUserRequest(url.User, "/home/rpc/"+did, params) + return err +} + +func apiXiaomi(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case "GET": + apiDeviceList(w, r) + case "POST": + apiAuth(w, r) + } +} + +func apiDeviceList(w http.ResponseWriter, r *http.Request) { + query := r.URL.Query() + + user := query.Get("id") + if user == "" { + cloudsMu.Lock() + users := make([]string, 0, len(tokens)) + for s := range tokens { + users = append(users, s) + } + cloudsMu.Unlock() + + api.ResponseJSON(w, users) + return + } + + err := func() error { + region := query.Get("region") + res, err := cloudRequest(user, region, "/v2/home/device_list_page", "{}") + if err != nil { + return err + } + var v struct { + List []*Device `json:"list"` + } + + log.Trace().Str("user", user).Msgf("[xiaomi] devices list: %s", res) + + if err = json.Unmarshal(res, &v); err != nil { + return err + } + + cloudsMu.Lock() + token := tokens[user] + cloudsMu.Unlock() + + type source struct { + Name string `json:"name"` + Info string `json:"info"` + URL string `json:"url"` + } + var items []*source + + for _, device := range v.List { + if !device.HasCamera() { + continue + } + items = append(items, &source{ + Name: device.Name, + Info: fmt.Sprintf("ip: %s, mac: %s", device.IP, device.MAC), + URL: fmt.Sprintf("xiaomi://%s:%s@%s?did=%s&model=%s&token=%s", + user, region, device.IP, device.Did, device.Model, url.QueryEscape(token)), + }) + } + + api.ResponseJSON(w, items) + return nil + }() + + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } +} + +type Device struct { + Did string `json:"did"` + Name string `json:"name"` + Model string `json:"model"` + MAC string `json:"mac"` + IP string `json:"localip"` +} + +func (d *Device) HasCamera() bool { + return strings.Contains(d.Model, ".camera.") || + strings.Contains(d.Model, ".cateye.") || + strings.Contains(d.Model, ".feeder.") +} + +var auth *xiaomi.Cloud + +func apiAuth(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + username := r.Form.Get("username") + password := r.Form.Get("password") + captcha := r.Form.Get("captcha") + verify := r.Form.Get("verify") + + var err error + + switch { + case username != "" || password != "": + auth = xiaomi.NewCloud(AppXiaomiHome) + err = auth.Login(username, password) + case captcha != "": + err = auth.LoginWithCaptcha(captcha) + case verify != "": + err = auth.LoginWithVerify(verify) + default: + http.Error(w, "wrong request", http.StatusBadRequest) + return + } + + if err == nil { + userID, token := auth.UserToken() + auth = nil + + cloudsMu.Lock() + if tokens == nil { + tokens = map[string]string{userID: token} + } else { + tokens[userID] = token + } + cloudsMu.Unlock() + } + + if err != nil { + var login *xiaomi.LoginError + if errors.As(err, &login) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + _ = json.NewEncoder(w).Encode(err) + return + } + + http.Error(w, err.Error(), http.StatusInternalServerError) + } +} + +const AppXiaomiHome = "xiaomiio" + +func GetBaseURL(region string) string { + switch region { + case "de", "i2", "ru", "sg", "us": + return "https://" + region + ".api.io.mi.com/app" + } + return "https://api.io.mi.com/app" +} diff --git a/main.go b/main.go index 859c427..3c70937 100644 --- a/main.go +++ b/main.go @@ -10,6 +10,7 @@ import ( "github.com/eduard256/strix/internal/probe" "github.com/eduard256/strix/internal/search" "github.com/eduard256/strix/internal/test" + "github.com/eduard256/strix/internal/xiaomi" ) // version is set at build time via ldflags: @@ -35,6 +36,7 @@ func main() { {"frigate", frigate.Init}, {"go2rtc", go2rtc.Init}, {"homekit", homekit.Init}, + {"xiaomi", xiaomi.Init}, } for _, m := range modules {