Added activity item for fleetd enrollment with host serial and display name. (#23790)
#22810 # Demo [](http://www.youtube.com/watch?v=le71QQ92suc) # Checklist for submitter - [x] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/Committing-Changes.md#changes-files) for more information. - [x] Added support on fleet's osquery simulator `cmd/osquery-perf` for new osquery data ingestion features. - [x] Added/updated tests - [x] Manual QA for all new/changed functionality - For Orbit and Fleet Desktop changes: - [x] Orbit runs on macOS, Linux and Windows. Check if the orbit feature/bugfix should only apply to one platform (`runtime.GOOS`). - [x] Manual QA must be performed in the three main OSs, macOS, Windows and Linux. - [x] Auto-update manual QA, from released version of component to new version (see [tools/tuf/test](../tools/tuf/test/README.md)).
This commit is contained in:
@@ -0,0 +1 @@
|
||||
Added activity item for fleetd enrollment with host serial and display name.
|
||||
@@ -1413,6 +1413,7 @@ func (a *agent) orbitEnroll() error {
|
||||
EnrollSecret: a.EnrollSecret,
|
||||
HardwareUUID: a.UUID,
|
||||
HardwareSerial: a.SerialNumber,
|
||||
Hostname: a.CachedString("hostname"),
|
||||
}
|
||||
jsonBytes, err := json.Marshal(params)
|
||||
if err != nil {
|
||||
|
||||
@@ -521,6 +521,23 @@ This activity contains the following fields:
|
||||
}
|
||||
```
|
||||
|
||||
## fleet_enrolled
|
||||
|
||||
Generated when a host is enrolled to Fleet (Fleet's agent fleetd is installed).
|
||||
|
||||
This activity contains the following fields:
|
||||
- "host_serial": Serial number of the host.
|
||||
- "host_display_name": Display name of the host.
|
||||
|
||||
#### Example
|
||||
|
||||
```json
|
||||
{
|
||||
"host_serial": "B04FL3ALPT21",
|
||||
"host_display_name": "WIN-DESKTOP-JGS78KJ7C"
|
||||
}
|
||||
```
|
||||
|
||||
## mdm_enrolled
|
||||
|
||||
Generated when a host is enrolled in Fleet's MDM.
|
||||
|
||||
@@ -33,6 +33,7 @@ export enum ActivityType {
|
||||
UserDeletedGlobalRole = "deleted_user_global_role",
|
||||
UserChangedTeamRole = "changed_user_team_role",
|
||||
UserDeletedTeamRole = "deleted_user_team_role",
|
||||
FleetEnrolled = "fleet_enrolled",
|
||||
MdmEnrolled = "mdm_enrolled",
|
||||
MdmUnenrolled = "mdm_unenrolled",
|
||||
EditedMacosMinVersion = "edited_macos_min_version",
|
||||
|
||||
@@ -279,6 +279,14 @@ const TAGGED_TEMPLATES = {
|
||||
</>
|
||||
);
|
||||
},
|
||||
fleetEnrolled: (activity: IActivity) => {
|
||||
const hostDisplayName = activity.details?.host_display_name ? (
|
||||
<b>{activity.details.host_display_name}</b>
|
||||
) : (
|
||||
"A host"
|
||||
);
|
||||
return <>{hostDisplayName} enrolled in Fleet.</>;
|
||||
},
|
||||
mdmEnrolled: (activity: IActivity) => {
|
||||
if (activity.details?.mdm_platform === "microsoft") {
|
||||
return (
|
||||
@@ -1167,6 +1175,9 @@ const getDetail = (
|
||||
case ActivityType.UserDeletedTeamRole: {
|
||||
return TAGGED_TEMPLATES.userDeletedTeamRole(activity);
|
||||
}
|
||||
case ActivityType.FleetEnrolled: {
|
||||
return TAGGED_TEMPLATES.fleetEnrolled(activity);
|
||||
}
|
||||
case ActivityType.MdmEnrolled: {
|
||||
return TAGGED_TEMPLATES.mdmEnrolled(activity);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
Added computer_name and hardware_model for fleetd enrollment.
|
||||
Added serial number for fleetd enrollment for Windows hosts (already present for macOS and Linux).
|
||||
@@ -694,6 +694,8 @@ func main() {
|
||||
HardwareUUID: osqueryHostInfo.HardwareUUID,
|
||||
Hostname: osqueryHostInfo.Hostname,
|
||||
Platform: osqueryHostInfo.Platform,
|
||||
ComputerName: osqueryHostInfo.ComputerName,
|
||||
HardwareModel: osqueryHostInfo.HardwareModel,
|
||||
}
|
||||
|
||||
if runtime.GOOS == "darwin" {
|
||||
@@ -737,13 +739,6 @@ func main() {
|
||||
orbitHostInfo.OsqueryIdentifier = osqueryHostInfo.InstanceID
|
||||
}
|
||||
|
||||
// The hardware serial was not sent when Windows MDM was implemented,
|
||||
// thus we clear its value here to not break any existing enroll functionality
|
||||
// on the server.
|
||||
if runtime.GOOS == "windows" {
|
||||
orbitHostInfo.HardwareSerial = ""
|
||||
}
|
||||
|
||||
var (
|
||||
options []osquery.Option
|
||||
// optionsAfterFlagfile is populated with options that will be set after the '--flagfile' argument
|
||||
@@ -1697,6 +1692,10 @@ type osqueryHostInfo struct {
|
||||
HardwareSerial string `json:"hardware_serial"`
|
||||
// Hostname is the device's hostname (extracted from `system_info` osquery table).
|
||||
Hostname string `json:"hostname"`
|
||||
// ComputerName is the friendly computer name (optional) (extracted from `system_info` osquery table).
|
||||
ComputerName string `json:"computer_name"`
|
||||
// HardwareModel is the device's hardware model (extracted from `system_info` osquery table).
|
||||
HardwareModel string `json:"hardware_model"`
|
||||
// Platform is the device's platform as defined by osquery (extracted from `os_version` osquery table).
|
||||
Platform string `json:"platform"`
|
||||
// InstanceID is the osquery's randomly generated instance ID
|
||||
@@ -1714,7 +1713,18 @@ func getHostInfo(osqueryPath string, osqueryDBPath string) (*osqueryHostInfo, er
|
||||
if err := os.MkdirAll(filepath.Dir(osqueryDBPath), constant.DefaultDirMode); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
const systemQuery = "SELECT si.uuid, si.hardware_serial, si.hostname, os.platform, os.version as os_version, oi.instance_id, oi.version as osquery_version FROM system_info si, os_version os, osquery_info oi"
|
||||
const systemQuery = `
|
||||
SELECT
|
||||
si.uuid,
|
||||
si.hardware_serial,
|
||||
si.hostname,
|
||||
si.computer_name,
|
||||
si.hardware_model,
|
||||
os.platform,
|
||||
os.version as os_version,
|
||||
oi.instance_id,
|
||||
oi.version as osquery_version
|
||||
FROM system_info si, os_version os, osquery_info oi`
|
||||
args := []string{
|
||||
"-S",
|
||||
"--database_path", osqueryDBPath,
|
||||
|
||||
@@ -1904,9 +1904,20 @@ func (ds *Datastore) EnrollOrbit(ctx context.Context, isMDMEnabled bool, hostInf
|
||||
}
|
||||
// NOTE: allow an empty serial, currently it is empty for Windows.
|
||||
|
||||
var host fleet.Host
|
||||
host := fleet.Host{
|
||||
ComputerName: hostInfo.ComputerName,
|
||||
Hostname: hostInfo.Hostname,
|
||||
HardwareModel: hostInfo.HardwareModel,
|
||||
HardwareSerial: hostInfo.HardwareSerial,
|
||||
}
|
||||
err := ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error {
|
||||
enrolledHostInfo, err := matchHostDuringEnrollment(ctx, tx, orbitEnroll, isMDMEnabled, hostInfo.OsqueryIdentifier, hostInfo.HardwareUUID, hostInfo.HardwareSerial)
|
||||
serialToMatch := hostInfo.HardwareSerial
|
||||
if hostInfo.Platform == "windows" {
|
||||
// For Windows, don't match by serial number to retain legacy functionality.
|
||||
serialToMatch = ""
|
||||
}
|
||||
enrolledHostInfo, err := matchHostDuringEnrollment(ctx, tx, orbitEnroll, isMDMEnabled, hostInfo.OsqueryIdentifier,
|
||||
hostInfo.HardwareUUID, serialToMatch)
|
||||
|
||||
// If the osquery identifier that osqueryd will use was not sent by Orbit, then use the hardware UUID as identifier
|
||||
// (using the hardware UUID is Orbit's default behavior).
|
||||
@@ -1936,6 +1947,8 @@ func (ds *Datastore) EnrollOrbit(ctx context.Context, isMDMEnabled bool, hostInf
|
||||
uuid = COALESCE(NULLIF(uuid, ''), ?),
|
||||
osquery_host_id = COALESCE(NULLIF(osquery_host_id, ''), ?),
|
||||
hardware_serial = COALESCE(NULLIF(hardware_serial, ''), ?),
|
||||
computer_name = COALESCE(NULLIF(computer_name, ''), ?),
|
||||
hardware_model = COALESCE(NULLIF(hardware_model, ''), ?),
|
||||
team_id = ?
|
||||
WHERE id = ?`
|
||||
_, err := tx.ExecContext(ctx, sqlUpdate,
|
||||
@@ -1943,6 +1956,8 @@ func (ds *Datastore) EnrollOrbit(ctx context.Context, isMDMEnabled bool, hostInf
|
||||
hostInfo.HardwareUUID,
|
||||
osqueryIdentifier,
|
||||
hostInfo.HardwareSerial,
|
||||
hostInfo.ComputerName,
|
||||
hostInfo.HardwareModel,
|
||||
teamID,
|
||||
enrolledHostInfo.ID,
|
||||
)
|
||||
@@ -1977,8 +1992,10 @@ func (ds *Datastore) EnrollOrbit(ctx context.Context, isMDMEnabled bool, hostInf
|
||||
orbit_node_key,
|
||||
hardware_serial,
|
||||
hostname,
|
||||
computer_name,
|
||||
hardware_model,
|
||||
platform
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?)
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?)
|
||||
`
|
||||
result, err := tx.ExecContext(ctx, sqlInsert,
|
||||
zeroTime,
|
||||
@@ -1992,6 +2009,8 @@ func (ds *Datastore) EnrollOrbit(ctx context.Context, isMDMEnabled bool, hostInf
|
||||
orbitNodeKey,
|
||||
hostInfo.HardwareSerial,
|
||||
hostInfo.Hostname,
|
||||
hostInfo.ComputerName,
|
||||
hostInfo.HardwareModel,
|
||||
hostInfo.Platform,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -1999,9 +2018,9 @@ func (ds *Datastore) EnrollOrbit(ctx context.Context, isMDMEnabled bool, hostInf
|
||||
}
|
||||
hostID, _ := result.LastInsertId()
|
||||
const sqlHostDisplayName = `
|
||||
INSERT INTO host_display_names (host_id, display_name) VALUES (?, '')
|
||||
INSERT INTO host_display_names (host_id, display_name) VALUES (?, ?)
|
||||
`
|
||||
_, err = tx.ExecContext(ctx, sqlHostDisplayName, hostID)
|
||||
_, err = tx.ExecContext(ctx, sqlHostDisplayName, hostID, host.DisplayName())
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "insert host_display_names")
|
||||
}
|
||||
|
||||
@@ -8061,6 +8061,11 @@ func testHostsGetUnverifiedDiskEncryptionKeys(t *testing.T, ds *Datastore) {
|
||||
func testHostsEnrollOrbit(t *testing.T, ds *Datastore) {
|
||||
ctx := context.Background()
|
||||
|
||||
const (
|
||||
computerName = "My computer"
|
||||
hardwareModel = "CMP-1000"
|
||||
)
|
||||
|
||||
createHost := func(osqueryID, serial string) *fleet.Host {
|
||||
dbZeroTime := time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
var osqueryIDPtr *string
|
||||
@@ -8075,6 +8080,8 @@ func testHostsEnrollOrbit(t *testing.T, ds *Datastore) {
|
||||
DetailUpdatedAt: dbZeroTime,
|
||||
OsqueryHostID: osqueryIDPtr,
|
||||
RefetchRequested: true,
|
||||
ComputerName: computerName,
|
||||
HardwareModel: hardwareModel,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
return h
|
||||
@@ -8112,10 +8119,19 @@ func testHostsEnrollOrbit(t *testing.T, ds *Datastore) {
|
||||
h, err = ds.EnrollOrbit(ctx, true, fleet.OrbitHostInfo{
|
||||
HardwareUUID: *hBoth.OsqueryHostID,
|
||||
HardwareSerial: hBoth.HardwareSerial,
|
||||
ComputerName: hBoth.ComputerName,
|
||||
HardwareModel: hBoth.HardwareModel,
|
||||
}, uuid.New().String(), nil)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, hBoth.ID, h.ID)
|
||||
require.Empty(t, h.HardwareSerial) // this is just to prove that it was loaded based on osquery_host_id, the serial was not set in the lookup
|
||||
assert.Equal(t, hBoth.HardwareSerial, h.HardwareSerial)
|
||||
assert.Equal(t, hBoth.ComputerName, h.ComputerName)
|
||||
assert.Equal(t, hBoth.HardwareModel, h.HardwareModel)
|
||||
h, err = ds.Host(ctx, h.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, hBoth.HardwareSerial, h.HardwareSerial)
|
||||
assert.Equal(t, hBoth.ComputerName, h.ComputerName)
|
||||
assert.Equal(t, hBoth.HardwareModel, h.HardwareModel)
|
||||
|
||||
// enroll with osquery id from hBoth and serial from hSerialNoOsquery (should
|
||||
// use the osquery match)
|
||||
@@ -8125,14 +8141,17 @@ func testHostsEnrollOrbit(t *testing.T, ds *Datastore) {
|
||||
}, uuid.New().String(), nil)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, hBoth.ID, h.ID)
|
||||
require.Empty(t, h.HardwareSerial)
|
||||
assert.Equal(t, hSerialNoOsquery.HardwareSerial, h.HardwareSerial)
|
||||
|
||||
// enroll with no match, will create a new one
|
||||
newSerial := uuid.NewString()
|
||||
h, err = ds.EnrollOrbit(ctx, true, fleet.OrbitHostInfo{
|
||||
HardwareUUID: uuid.New().String(),
|
||||
HardwareSerial: uuid.New().String(),
|
||||
HardwareSerial: newSerial,
|
||||
Hostname: "foo2",
|
||||
Platform: "darwin",
|
||||
ComputerName: "New computer",
|
||||
HardwareModel: "ABC-3000",
|
||||
}, uuid.New().String(), nil)
|
||||
require.NoError(t, err)
|
||||
require.Greater(t, h.ID, hBoth.ID)
|
||||
@@ -8141,6 +8160,9 @@ func testHostsEnrollOrbit(t *testing.T, ds *Datastore) {
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "foo2", h.Hostname)
|
||||
require.Equal(t, "darwin", h.Platform)
|
||||
assert.Equal(t, "New computer", h.ComputerName)
|
||||
assert.Equal(t, "ABC-3000", h.HardwareModel)
|
||||
assert.Equal(t, newSerial, h.HardwareSerial)
|
||||
|
||||
// simulate a "corrupt database" where two hosts have the same serial and
|
||||
// enroll by serial should always use the same (the smaller ID)
|
||||
|
||||
@@ -50,6 +50,7 @@ var ActivityDetailsList = []ActivityDetails{
|
||||
ActivityTypeChangedUserTeamRole{},
|
||||
ActivityTypeDeletedUserTeamRole{},
|
||||
|
||||
ActivityTypeFleetEnrolled{},
|
||||
ActivityTypeMDMEnrolled{},
|
||||
ActivityTypeMDMUnenrolled{},
|
||||
|
||||
@@ -795,6 +796,25 @@ func (a ActivityTypeDeletedUserTeamRole) Documentation() (activity string, detai
|
||||
}`
|
||||
}
|
||||
|
||||
type ActivityTypeFleetEnrolled struct {
|
||||
HostSerial string `json:"host_serial"`
|
||||
HostDisplayName string `json:"host_display_name"`
|
||||
}
|
||||
|
||||
func (a ActivityTypeFleetEnrolled) ActivityName() string {
|
||||
return "fleet_enrolled"
|
||||
}
|
||||
|
||||
func (a ActivityTypeFleetEnrolled) Documentation() (activity string, details string, detailsExample string) {
|
||||
return `Generated when a host is enrolled to Fleet (Fleet's agent fleetd is installed).`,
|
||||
`This activity contains the following fields:
|
||||
- "host_serial": Serial number of the host.
|
||||
- "host_display_name": Display name of the host.`, `{
|
||||
"host_serial": "B04FL3ALPT21",
|
||||
"host_display_name": "WIN-DESKTOP-JGS78KJ7C"
|
||||
}`
|
||||
}
|
||||
|
||||
type ActivityTypeMDMEnrolled struct {
|
||||
HostSerial string `json:"host_serial"`
|
||||
HostDisplayName string `json:"host_display_name"`
|
||||
|
||||
@@ -91,6 +91,10 @@ type OrbitHostInfo struct {
|
||||
//
|
||||
// If not set, then the HardwareUUID is used/set as the osquery identifier.
|
||||
OsqueryIdentifier string
|
||||
// ComputerName is the device's friendly name (optional).
|
||||
ComputerName string
|
||||
// HardwareModel is the device's hardware model. For example: Standard PC (Q35 + ICH9, 2009)
|
||||
HardwareModel string
|
||||
}
|
||||
|
||||
// ExtensionInfo holds the data of a osquery extension to apply to an Orbit client.
|
||||
|
||||
@@ -2661,10 +2661,14 @@ func (s *integrationMDMTestSuite) TestEnrollOrbitAfterDEPSync() {
|
||||
// enroll the host from orbit, it should match the host above via the serial
|
||||
var resp EnrollOrbitResponse
|
||||
hostUUID := uuid.New().String()
|
||||
h.ComputerName = "My Mac"
|
||||
h.HardwareModel = "MacBook Pro"
|
||||
s.DoJSON("POST", "/api/fleet/orbit/enroll", EnrollOrbitRequest{
|
||||
EnrollSecret: secret,
|
||||
HardwareUUID: hostUUID, // will not match any existing host
|
||||
HardwareSerial: h.HardwareSerial,
|
||||
ComputerName: h.ComputerName,
|
||||
HardwareModel: h.HardwareModel,
|
||||
}, http.StatusOK, &resp)
|
||||
require.NotEmpty(t, resp.OrbitNodeKey)
|
||||
|
||||
@@ -2674,11 +2678,21 @@ func (s *integrationMDMTestSuite) TestEnrollOrbitAfterDEPSync() {
|
||||
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", h.ID), nil, http.StatusOK, &hostResp)
|
||||
require.Equal(t, h.ID, hostResp.Host.ID)
|
||||
require.NotEqual(t, dbZeroTime, hostResp.Host.LastEnrolledAt)
|
||||
assert.Equal(t, h.ComputerName, hostResp.Host.ComputerName)
|
||||
assert.Equal(t, h.HardwareModel, hostResp.Host.HardwareModel)
|
||||
assert.Equal(t, h.HardwareSerial, hostResp.Host.HardwareSerial)
|
||||
assert.Equal(t, h.DisplayName(), hostResp.Host.DisplayName)
|
||||
|
||||
got, err := s.ds.LoadHostByOrbitNodeKey(ctx, resp.OrbitNodeKey)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, h.ID, got.ID)
|
||||
|
||||
s.lastActivityMatches(
|
||||
"fleet_enrolled",
|
||||
fmt.Sprintf(`{"host_display_name": "%s", "host_serial": "%s"}`, h.DisplayName(), h.HardwareSerial),
|
||||
0,
|
||||
)
|
||||
|
||||
// enroll the host from osquery, it should match the same host
|
||||
var osqueryResp enrollAgentResponse
|
||||
osqueryID := uuid.New().String()
|
||||
|
||||
+20
-1
@@ -41,6 +41,10 @@ type EnrollOrbitRequest struct {
|
||||
// OsqueryIdentifier holds the identifier used by osquery.
|
||||
// If not set, then the hardware UUID is used to match orbit and osquery.
|
||||
OsqueryIdentifier string `json:"osquery_identifier"`
|
||||
// ComputerName is the device's friendly name (optional).
|
||||
ComputerName string `json:"computer_name"`
|
||||
// HardwareModel is the device's hardware model.
|
||||
HardwareModel string `json:"hardware_model"`
|
||||
}
|
||||
|
||||
type EnrollOrbitResponse struct {
|
||||
@@ -90,6 +94,8 @@ func enrollOrbitEndpoint(ctx context.Context, request interface{}, svc fleet.Ser
|
||||
Hostname: req.Hostname,
|
||||
Platform: req.Platform,
|
||||
OsqueryIdentifier: req.OsqueryIdentifier,
|
||||
ComputerName: req.ComputerName,
|
||||
HardwareModel: req.HardwareModel,
|
||||
}, req.EnrollSecret)
|
||||
if err != nil {
|
||||
return EnrollOrbitResponse{Err: err}, nil
|
||||
@@ -129,6 +135,8 @@ func (svc *Service) EnrollOrbit(ctx context.Context, hostInfo fleet.OrbitHostInf
|
||||
"hostname", hostInfo.Hostname,
|
||||
"platform", hostInfo.Platform,
|
||||
"osquery_identifier", hostInfo.OsqueryIdentifier,
|
||||
"computer_name", hostInfo.ComputerName,
|
||||
"hardware_model", hostInfo.HardwareModel,
|
||||
),
|
||||
level.Info,
|
||||
)
|
||||
@@ -155,11 +163,22 @@ func (svc *Service) EnrollOrbit(ctx context.Context, hostInfo fleet.OrbitHostInf
|
||||
return "", fleet.OrbitError{Message: "app config load failed: " + err.Error()}
|
||||
}
|
||||
|
||||
_, err = svc.ds.EnrollOrbit(ctx, appConfig.MDM.EnabledAndConfigured, hostInfo, orbitNodeKey, secret.TeamID)
|
||||
host, err := svc.ds.EnrollOrbit(ctx, appConfig.MDM.EnabledAndConfigured, hostInfo, orbitNodeKey, secret.TeamID)
|
||||
if err != nil {
|
||||
return "", fleet.OrbitError{Message: "failed to enroll " + err.Error()}
|
||||
}
|
||||
|
||||
if err := svc.NewActivity(
|
||||
ctx,
|
||||
nil,
|
||||
fleet.ActivityTypeFleetEnrolled{
|
||||
HostSerial: hostInfo.HardwareSerial,
|
||||
HostDisplayName: host.DisplayName(),
|
||||
},
|
||||
); err != nil {
|
||||
level.Error(svc.logger).Log("msg", "record fleet enroll activity", "err", err)
|
||||
}
|
||||
|
||||
return orbitNodeKey, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -456,6 +456,8 @@ func (oc *OrbitClient) enroll() (string, error) {
|
||||
Hostname: oc.hostInfo.Hostname,
|
||||
Platform: oc.hostInfo.Platform,
|
||||
OsqueryIdentifier: oc.hostInfo.OsqueryIdentifier,
|
||||
ComputerName: oc.hostInfo.ComputerName,
|
||||
HardwareModel: oc.hostInfo.HardwareModel,
|
||||
}
|
||||
var resp EnrollOrbitResponse
|
||||
err := oc.request(verb, path, params, &resp)
|
||||
|
||||
Reference in New Issue
Block a user