diff --git a/server/android/android.go b/server/android/android.go index c87116c4d95f..22f29b32ca91 100644 --- a/server/android/android.go +++ b/server/android/android.go @@ -18,3 +18,9 @@ func (e Enterprise) Name() string { type EnrollmentToken struct { Value string `json:"value"` } + +type Host struct { + HostID uint `db:"host_id"` + FleetEnterpriseID uint `db:"enterprise_id"` + DeviceID string `db:"device_id"` +} diff --git a/server/android/datastore.go b/server/android/datastore.go index bc6a6b230a05..61475d13a2cd 100644 --- a/server/android/datastore.go +++ b/server/android/datastore.go @@ -14,6 +14,9 @@ type Datastore interface { GetEnterpriseByID(ctx context.Context, ID uint) (*Enterprise, error) UpdateEnterprise(ctx context.Context, enterprise *Enterprise) error ListEnterprises(ctx context.Context) ([]*Enterprise, error) + + AddHost(ctx context.Context, host *Host) error + GetHost(ctx context.Context, fleetEnterpriseID uint, deviceID string) (*Host, error) } type MigrationStatus struct { diff --git a/server/android/job/job.go b/server/android/job/job.go index e1915e9554e6..3696aa0684b5 100644 --- a/server/android/job/job.go +++ b/server/android/job/job.go @@ -3,12 +3,16 @@ package job import ( "context" "errors" + "fmt" "os" + "strings" + "time" "github.com/fleetdm/fleet/v4/server/android" "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" "github.com/fleetdm/fleet/v4/server/fleet" kitlog "github.com/go-kit/log" + "github.com/go-kit/log/level" "google.golang.org/api/androidmanagement/v1" "google.golang.org/api/option" ) @@ -36,16 +40,80 @@ func ReconcileDevices(ctx context.Context, ds fleet.Datastore, androidDS android for _, enterprise := range enterprises { // Note: we can optimize this by using Fields to retrieve partial data https://developers.google.com/gdata/docs/2.0/basics#PartialResponse + // But actually this is not scalable for 100,000s devices, so we need to use PubSub. devices, err := mgmt.Enterprises.Devices.List(enterprise.Name()).Do() if err != nil { return ctxerr.Wrap(ctx, err, "listing devices with Google API") } for _, device := range devices.Devices { - logger.Log("msg", "device", "device", device) + // Get the deviceId from the name: enterprises/{enterpriseId}/devices/{deviceId} + nameParts := strings.Split(device.Name, "/") + if len(nameParts) != 4 { + return ctxerr.Errorf(ctx, "invalid Android device name: %s", device.Name) + } + deviceID := nameParts[3] + + host, err := androidDS.GetHost(ctx, enterprise.ID, deviceID) + if err != nil { + return ctxerr.Wrap(ctx, err, "getting host") + } + if host != nil { + // TODO: Update host if needed + continue + } + + // TODO: Do EnrollHost and androidDS.AddHost inside a transaction so we don't add duplicate hosts + fleetHost, err := ds.EnrollHost(ctx, true, device.HardwareInfo.SerialNumber, device.HardwareInfo.SerialNumber, + device.HardwareInfo.SerialNumber, "", nil, 0) + if err != nil { + return ctxerr.Wrap(ctx, err, "enrolling host") + } + err = androidDS.AddHost(ctx, &android.Host{ + FleetEnterpriseID: enterprise.ID, + DeviceID: deviceID, + HostID: fleetHost.ID, + }) + if err != nil { + return ctxerr.Wrap(ctx, err, "adding Android host") + } + + fleetHost.DiskEncryptionEnabled = &device.DeviceSettings.IsEncrypted + fleetHost.Platform = "ubuntu" + fleetHost.HardwareVendor = device.HardwareInfo.Manufacturer + fleetHost.HardwareModel = device.HardwareInfo.Model + fleetHost.OSVersion = "Android " + device.SoftwareInfo.AndroidVersion + lastEnrolledAt, err := time.Parse(time.RFC3339, device.EnrollmentTime) + switch { + case err != nil: + level.Error(logger).Log("msg", "parsing Android device last enrolled at", "err", err, "deviceId", deviceID) + default: + fleetHost.LastEnrolledAt = lastEnrolledAt + } + detailUpdatedAt, err := time.Parse(time.RFC3339, device.LastStatusReportTime) + switch { + case err != nil: + level.Error(logger).Log("msg", "parsing Android device detail updated at", "err", err, "deviceId", deviceID) + default: + fleetHost.DetailUpdatedAt = detailUpdatedAt + } + err = ds.UpdateHost(ctx, fleetHost) + if err != nil { + return ctxerr.Wrap(ctx, err, fmt.Sprintf("updating host with deviceId %s", deviceID)) + } + + err = ds.UpdateHostOperatingSystem(ctx, fleetHost.ID, fleet.OperatingSystem{ + Name: "Android", + Version: device.SoftwareInfo.AndroidVersion, + Platform: "android", + KernelVersion: device.SoftwareInfo.DeviceKernelVersion, + }) + if err != nil { + return ctxerr.Wrap(ctx, err, fmt.Sprintf("updating host operating system with deviceId %s", deviceID)) + } + } - // For each device, check whether it is in Fleet. If not, add it } return nil diff --git a/server/android/mysql/enterprise.go b/server/android/mysql/enterprises.go similarity index 100% rename from server/android/mysql/enterprise.go rename to server/android/mysql/enterprises.go diff --git a/server/android/mysql/enterprise_test.go b/server/android/mysql/enterprises_test.go similarity index 100% rename from server/android/mysql/enterprise_test.go rename to server/android/mysql/enterprises_test.go diff --git a/server/android/mysql/hosts.go b/server/android/mysql/hosts.go new file mode 100644 index 000000000000..504e16c40c76 --- /dev/null +++ b/server/android/mysql/hosts.go @@ -0,0 +1,30 @@ +package mysql + +import ( + "context" + "database/sql" + "errors" + + "github.com/fleetdm/fleet/v4/server/android" + "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" + "github.com/jmoiron/sqlx" +) + +func (ds *Datastore) GetHost(ctx context.Context, fleetEnterpriseID uint, deviceID string) (*android.Host, error) { + stmt := `SELECT enterprise_id, device_id, host_id FROM android_hosts WHERE enterprise_id = ? AND device_id = ?` + var host android.Host + err := sqlx.GetContext(ctx, ds.reader(ctx), &host, stmt, fleetEnterpriseID, deviceID) + switch { + case errors.Is(err, sql.ErrNoRows): + return nil, nil + case err != nil: + return nil, ctxerr.Wrap(ctx, err, "getting host") + } + return &host, nil +} + +func (ds *Datastore) AddHost(ctx context.Context, host *android.Host) error { + stmt := `INSERT INTO android_hosts (enterprise_id, device_id, host_id) VALUES (?, ?, ?)` + _, err := ds.writer(ctx).ExecContext(ctx, stmt, host.FleetEnterpriseID, host.DeviceID, host.HostID) + return ctxerr.Wrap(ctx, err, "adding host") +} diff --git a/server/android/mysql/migrations/20250101000000_CreateAndroidTables.go b/server/android/mysql/migrations/20250101000000_CreateAndroidTables.go index 3d7f6a1f870a..9a0783b8a4b8 100644 --- a/server/android/mysql/migrations/20250101000000_CreateAndroidTables.go +++ b/server/android/mysql/migrations/20250101000000_CreateAndroidTables.go @@ -10,15 +10,29 @@ func init() { } func Up_20250101000000(tx *sql.Tx) error { - _, err := tx.Exec(`CREATE TABLE android_enterprises ( + _, err := tx.Exec(`CREATE TABLE IF NOT EXISTS android_enterprises ( id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, - signup_name VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', - enterprise_id VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', + signup_name VARCHAR(63) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', + enterprise_id VARCHAR(63) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', created_at DATETIME(6) NULL DEFAULT NOW(6), updated_at DATETIME(6) NULL DEFAULT NOW(6) ON UPDATE NOW(6))`) if err != nil { return fmt.Errorf("failed to create android_enterprise table: %w", err) } + + _, err = tx.Exec(`CREATE TABLE IF NOT EXISTS android_hosts ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + host_id INT UNSIGNED NULL, + enterprise_id INT UNSIGNED NOT NULL, + device_id VARCHAR(63) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + created_at DATETIME(6) NULL DEFAULT NOW(6), + updated_at DATETIME(6) NULL DEFAULT NOW(6) ON UPDATE NOW(6), + UNIQUE KEY idx_android_hosts_enterprise_device (enterprise_id, device_id) -- consider making this the primary key + )`) + if err != nil { + return fmt.Errorf("failed to create android_enterprise table: %w", err) + } + logger.Info.Println("Done with initial migration.") return nil } diff --git a/server/datastore/mysql/operating_systems.go b/server/datastore/mysql/operating_systems.go index 73160b28e468..e5dbf94d03f8 100644 --- a/server/datastore/mysql/operating_systems.go +++ b/server/datastore/mysql/operating_systems.go @@ -46,6 +46,11 @@ func (ds *Datastore) UpdateHostOperatingSystem(ctx context.Context, hostID uint, if !updateNeeded { return nil } + + const maxDisplayVersionLength = 10 // per DB schema + if len(hostOS.DisplayVersion) > maxDisplayVersionLength { + return ctxerr.Errorf(ctx, "host OS display version too long: %s", hostOS.DisplayVersion) + } return ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { os, err := getOrGenerateOperatingSystemDB(ctx, tx, hostOS) if err != nil { diff --git a/tools/android/android.go b/tools/android/android.go index 8681b735d547..17327d670603 100644 --- a/tools/android/android.go +++ b/tools/android/android.go @@ -24,6 +24,7 @@ func main() { command := flag.String("command", "", "") enterpriseID := flag.String("enterprise_id", "", "") + deviceID := flag.String("device_id", "", "") flag.Parse() ctx := context.Background() @@ -41,6 +42,8 @@ func main() { policiesList(mgmt, *enterpriseID) case "devices.list": devicesList(mgmt, *enterpriseID) + case "devices.issueCommand.RELINQUISH_OWNERSHIP": + devicesRelinquishOwnership(mgmt, *enterpriseID, *deviceID) default: log.Fatalf("Unknown command: %s", *command) } @@ -108,3 +111,20 @@ func devicesList(mgmt *androidmanagement.Service, enterpriseID string) { log.Println(string(data)) } } + +func devicesRelinquishOwnership(mgmt *androidmanagement.Service, enterpriseID, deviceID string) { + if enterpriseID == "" || deviceID == "" { + log.Fatalf("enterprise_id and device_id must be set") + } + operation, err := mgmt.Enterprises.Devices.IssueCommand("enterprises/"+enterpriseID+"/devices/"+deviceID, &androidmanagement.Command{ + Type: "RELINQUISH_OWNERSHIP", + }).Do() + if err != nil { + log.Fatalf("Error issuing command: %v", err) + } + data, err := json.MarshalIndent(operation, "", " ") + if err != nil { + log.Fatalf("Error marshalling operation: %v", err) + } + log.Println(string(data)) +}