mirror of
https://github.com/gravitational/teleport.git
synced 2026-10-11 22:49:54 +00:00
Add audit events for CreatePairedDeviceEnrollToken (#68835)
* Add audit events for CreatePairedDeviceEnrollToken * Add Web UI formatter for new audit events * Update audit events reference * Use `OSType` from generated protos
This commit is contained in:
@@ -5,6 +5,7 @@ inputs:
|
|||||||
paths:
|
paths:
|
||||||
- api/proto/teleport/userpreferences/
|
- api/proto/teleport/userpreferences/
|
||||||
- api/proto/teleport/desktop/
|
- api/proto/teleport/desktop/
|
||||||
|
- api/proto/teleport/devicetrust/v1/os_type.proto
|
||||||
- proto/prehog/
|
- proto/prehog/
|
||||||
- proto/teleport/lib/teleterm/
|
- proto/teleport/lib/teleterm/
|
||||||
- proto/teleport/lib/vnet/diag/
|
- proto/teleport/lib/vnet/diag/
|
||||||
|
|||||||
@@ -3633,6 +3633,58 @@ Example:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## device.enroll_pairing.request
|
||||||
|
|
||||||
|
There are multiple events with the `device.enroll_pairing.request` type.
|
||||||
|
|
||||||
|
### TV010I
|
||||||
|
|
||||||
|
Device Enroll Pairing Requested
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"cluster_name": "im-a-cluster-name",
|
||||||
|
"code": "TV010I",
|
||||||
|
"device": {
|
||||||
|
"asset_tag": "M2CQVQV64R",
|
||||||
|
"device_id": "",
|
||||||
|
"os_type": 4
|
||||||
|
},
|
||||||
|
"ei": 0,
|
||||||
|
"event": "device.enroll_pairing.request",
|
||||||
|
"success": true,
|
||||||
|
"time": "2024-04-08T19:37:48.1Z",
|
||||||
|
"uid": "a7b3e9f1-4d5c-6a8b-9012-def345678901",
|
||||||
|
"user": "llama"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### TV010W
|
||||||
|
|
||||||
|
Device Enroll Pairing Request Failed
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"cluster_name": "im-a-cluster-name",
|
||||||
|
"code": "TV010W",
|
||||||
|
"device": {
|
||||||
|
"asset_tag": "M2CQVQV64R",
|
||||||
|
"device_id": "",
|
||||||
|
"os_type": 4
|
||||||
|
},
|
||||||
|
"ei": 0,
|
||||||
|
"error": "invalid pairing token",
|
||||||
|
"event": "device.enroll_pairing.request",
|
||||||
|
"success": false,
|
||||||
|
"time": "2024-04-08T19:38:48.1Z",
|
||||||
|
"uid": "a7b3e9f1-4d5c-6a8b-9012-def345678902"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## device.token.create
|
## device.token.create
|
||||||
|
|
||||||
Device Enroll Token Created
|
Device Enroll Token Created
|
||||||
|
|||||||
+61
@@ -0,0 +1,61 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
// @generated by protobuf-ts 2.9.3 with parameter add_pb_suffix,eslint_disable,generate_dependencies,server_grpc1,ts_nocheck
|
||||||
|
// @generated from protobuf file "teleport/devicetrust/v1/os_type.proto" (package "teleport.devicetrust.v1", syntax proto3)
|
||||||
|
// tslint:disable
|
||||||
|
// @ts-nocheck
|
||||||
|
//
|
||||||
|
// Copyright 2022 Gravitational, Inc
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
//
|
||||||
|
/**
|
||||||
|
* OSType represents the operating system of a device.
|
||||||
|
*
|
||||||
|
* @generated from protobuf enum teleport.devicetrust.v1.OSType
|
||||||
|
*/
|
||||||
|
export enum OSType {
|
||||||
|
/**
|
||||||
|
* @generated from protobuf enum value: OS_TYPE_UNSPECIFIED = 0;
|
||||||
|
*/
|
||||||
|
OS_TYPE_UNSPECIFIED = 0,
|
||||||
|
/**
|
||||||
|
* Linux.
|
||||||
|
*
|
||||||
|
* @generated from protobuf enum value: OS_TYPE_LINUX = 1;
|
||||||
|
*/
|
||||||
|
OS_TYPE_LINUX = 1,
|
||||||
|
/**
|
||||||
|
* macOS.
|
||||||
|
*
|
||||||
|
* @generated from protobuf enum value: OS_TYPE_MACOS = 2;
|
||||||
|
*/
|
||||||
|
OS_TYPE_MACOS = 2,
|
||||||
|
/**
|
||||||
|
* Windows.
|
||||||
|
*
|
||||||
|
* @generated from protobuf enum value: OS_TYPE_WINDOWS = 3;
|
||||||
|
*/
|
||||||
|
OS_TYPE_WINDOWS = 3,
|
||||||
|
/**
|
||||||
|
* iOS.
|
||||||
|
*
|
||||||
|
* @generated from protobuf enum value: OS_TYPE_IOS = 4;
|
||||||
|
*/
|
||||||
|
OS_TYPE_IOS = 4,
|
||||||
|
/**
|
||||||
|
* iPadOS.
|
||||||
|
*
|
||||||
|
* @generated from protobuf enum value: OS_TYPE_IPADOS = 5;
|
||||||
|
*/
|
||||||
|
OS_TYPE_IPADOS = 5
|
||||||
|
}
|
||||||
@@ -707,6 +707,11 @@ const (
|
|||||||
// A confirmed web authentication means the WebSession itself now holds
|
// A confirmed web authentication means the WebSession itself now holds
|
||||||
// augmented TLS and SSH certificates.
|
// augmented TLS and SSH certificates.
|
||||||
DeviceAuthenticateConfirmEvent = "device.authenticate.confirm"
|
DeviceAuthenticateConfirmEvent = "device.authenticate.confirm"
|
||||||
|
// DeviceEnrollPairingRequestEvent is emitted when a device presents an enroll
|
||||||
|
// pairing token to request enrollment. On success the pairing transitions to
|
||||||
|
// awaiting approval. It is also emitted on failure (e.g. an invalid token),
|
||||||
|
// before any transition, in which case there is no associated user.
|
||||||
|
DeviceEnrollPairingRequestEvent = "device.enroll_pairing.request"
|
||||||
|
|
||||||
// BotJoinEvent is emitted when a bot joins
|
// BotJoinEvent is emitted when a bot joins
|
||||||
BotJoinEvent = "bot.join"
|
BotJoinEvent = "bot.join"
|
||||||
|
|||||||
@@ -525,6 +525,11 @@ const (
|
|||||||
DeviceWebTokenCreateCode = "TV008I"
|
DeviceWebTokenCreateCode = "TV008I"
|
||||||
// DeviceAuthenticateConfirmCode is the device authentication confirm code.
|
// DeviceAuthenticateConfirmCode is the device authentication confirm code.
|
||||||
DeviceAuthenticateConfirmCode = "TV009I"
|
DeviceAuthenticateConfirmCode = "TV009I"
|
||||||
|
// DeviceEnrollPairingRequestCode is the device enroll pairing request code.
|
||||||
|
DeviceEnrollPairingRequestCode = "TV010I"
|
||||||
|
// DeviceEnrollPairingRequestFailureCode is the device enroll pairing request
|
||||||
|
// failure code.
|
||||||
|
DeviceEnrollPairingRequestFailureCode = "TV010W"
|
||||||
|
|
||||||
// LoginRuleCreateCode is the login rule create code.
|
// LoginRuleCreateCode is the login rule create code.
|
||||||
LoginRuleCreateCode = "TLR00I"
|
LoginRuleCreateCode = "TLR00I"
|
||||||
|
|||||||
@@ -296,7 +296,8 @@ func FromEventFields(fields EventFields) (events.AuditEvent, error) {
|
|||||||
DeviceAuthenticateEvent,
|
DeviceAuthenticateEvent,
|
||||||
DeviceEnrollTokenCreateEvent,
|
DeviceEnrollTokenCreateEvent,
|
||||||
DeviceWebTokenCreateEvent,
|
DeviceWebTokenCreateEvent,
|
||||||
DeviceAuthenticateConfirmEvent:
|
DeviceAuthenticateConfirmEvent,
|
||||||
|
DeviceEnrollPairingRequestEvent:
|
||||||
e = &events.DeviceEvent2{}
|
e = &events.DeviceEvent2{}
|
||||||
case LockCreatedEvent:
|
case LockCreatedEvent:
|
||||||
e = &events.LockCreate{}
|
e = &events.LockCreate{}
|
||||||
|
|||||||
@@ -155,6 +155,7 @@ var eventsMap = map[string]apievents.AuditEvent{
|
|||||||
DeviceEnrollTokenCreateEvent: &apievents.DeviceEvent2{},
|
DeviceEnrollTokenCreateEvent: &apievents.DeviceEvent2{},
|
||||||
DeviceWebTokenCreateEvent: &apievents.DeviceEvent2{},
|
DeviceWebTokenCreateEvent: &apievents.DeviceEvent2{},
|
||||||
DeviceAuthenticateConfirmEvent: &apievents.DeviceEvent2{},
|
DeviceAuthenticateConfirmEvent: &apievents.DeviceEvent2{},
|
||||||
|
DeviceEnrollPairingRequestEvent: &apievents.DeviceEvent2{},
|
||||||
LockCreatedEvent: &apievents.LockCreate{},
|
LockCreatedEvent: &apievents.LockCreate{},
|
||||||
LockDeletedEvent: &apievents.LockDelete{},
|
LockDeletedEvent: &apievents.LockDelete{},
|
||||||
RecoveryCodeGeneratedEvent: &apievents.RecoveryCodeGenerate{},
|
RecoveryCodeGeneratedEvent: &apievents.RecoveryCodeGenerate{},
|
||||||
|
|||||||
@@ -206,6 +206,8 @@ export const EventIconMap: Record<EventCode, any> = {
|
|||||||
[eventCodes.DEVICE_UPDATE]: Icons.Info,
|
[eventCodes.DEVICE_UPDATE]: Icons.Info,
|
||||||
[eventCodes.DEVICE_WEB_TOKEN_CREATE]: Icons.Info,
|
[eventCodes.DEVICE_WEB_TOKEN_CREATE]: Icons.Info,
|
||||||
[eventCodes.DEVICE_AUTHENTICATE_CONFIRM]: Icons.Info,
|
[eventCodes.DEVICE_AUTHENTICATE_CONFIRM]: Icons.Info,
|
||||||
|
[eventCodes.DEVICE_ENROLL_PAIRING_REQUEST]: Icons.Info,
|
||||||
|
[eventCodes.DEVICE_ENROLL_PAIRING_REQUEST_FAILURE]: Icons.Info,
|
||||||
[eventCodes.MFA_DEVICE_ADD]: Icons.Info,
|
[eventCodes.MFA_DEVICE_ADD]: Icons.Info,
|
||||||
[eventCodes.MFA_DEVICE_DELETE]: Icons.Info,
|
[eventCodes.MFA_DEVICE_DELETE]: Icons.Info,
|
||||||
[eventCodes.BILLING_CARD_CREATE]: Icons.CreditCard,
|
[eventCodes.BILLING_CARD_CREATE]: Icons.CreditCard,
|
||||||
|
|||||||
@@ -3291,6 +3291,36 @@ export const events = [
|
|||||||
user: 'llama',
|
user: 'llama',
|
||||||
user_kind: 1,
|
user_kind: 1,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
cluster_name: 'im-a-cluster-name',
|
||||||
|
code: 'TV010I',
|
||||||
|
device: {
|
||||||
|
asset_tag: 'M2CQVQV64R',
|
||||||
|
device_id: '',
|
||||||
|
os_type: 4,
|
||||||
|
},
|
||||||
|
ei: 0,
|
||||||
|
event: 'device.enroll_pairing.request',
|
||||||
|
success: true,
|
||||||
|
time: '2024-04-08T19:37:48.1Z',
|
||||||
|
uid: 'a7b3e9f1-4d5c-6a8b-9012-def345678901',
|
||||||
|
user: 'llama',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
cluster_name: 'im-a-cluster-name',
|
||||||
|
code: 'TV010W',
|
||||||
|
device: {
|
||||||
|
asset_tag: 'M2CQVQV64R',
|
||||||
|
device_id: '',
|
||||||
|
os_type: 4,
|
||||||
|
},
|
||||||
|
ei: 0,
|
||||||
|
error: 'invalid pairing token',
|
||||||
|
event: 'device.enroll_pairing.request',
|
||||||
|
success: false,
|
||||||
|
time: '2024-04-08T19:38:48.1Z',
|
||||||
|
uid: 'a7b3e9f1-4d5c-6a8b-9012-def345678902',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
cluster_name: 'im-a-cluster-name',
|
cluster_name: 'im-a-cluster-name',
|
||||||
code: 'TLR00I',
|
code: 'TLR00I',
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { PagerPosition } from 'design/DataTable/types';
|
import { PagerPosition } from 'design/DataTable/types';
|
||||||
|
import { OSType } from 'gen-proto-ts/teleport/devicetrust/v1/os_type_pb';
|
||||||
|
|
||||||
export type TrustedDevice = {
|
export type TrustedDevice = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -56,6 +57,28 @@ export type DeviceSource = {
|
|||||||
*/
|
*/
|
||||||
export type TrustedDeviceOSType = TrustedDevice['osType'];
|
export type TrustedDeviceOSType = TrustedDevice['osType'];
|
||||||
|
|
||||||
|
export function osTypeLabel(
|
||||||
|
osType: OSType
|
||||||
|
): TrustedDevice['osType'] | undefined {
|
||||||
|
switch (osType) {
|
||||||
|
case OSType.OS_TYPE_LINUX:
|
||||||
|
return 'Linux';
|
||||||
|
case OSType.OS_TYPE_MACOS:
|
||||||
|
return 'macOS';
|
||||||
|
case OSType.OS_TYPE_WINDOWS:
|
||||||
|
return 'Windows';
|
||||||
|
case OSType.OS_TYPE_IOS:
|
||||||
|
return 'iOS';
|
||||||
|
case OSType.OS_TYPE_IPADOS:
|
||||||
|
return 'iPadOS';
|
||||||
|
case OSType.OS_TYPE_UNSPECIFIED:
|
||||||
|
return undefined;
|
||||||
|
default:
|
||||||
|
osType satisfies never;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export type TrustedDeviceResponse = {
|
export type TrustedDeviceResponse = {
|
||||||
items: TrustedDevice[];
|
items: TrustedDevice[];
|
||||||
startKey: string;
|
startKey: string;
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ import { formatDistanceStrict } from 'date-fns';
|
|||||||
|
|
||||||
import { pluralize } from 'shared/utils/text';
|
import { pluralize } from 'shared/utils/text';
|
||||||
|
|
||||||
|
import { osTypeLabel } from 'teleport/DeviceTrust/types';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Event,
|
Event,
|
||||||
EventCode,
|
EventCode,
|
||||||
@@ -1491,6 +1493,20 @@ export const formatters: Formatters = {
|
|||||||
? `User [${user}] has confirmed device web authentication`
|
? `User [${user}] has confirmed device web authentication`
|
||||||
: `User [${user}] has failed to confirm device web authentication`,
|
: `User [${user}] has failed to confirm device web authentication`,
|
||||||
},
|
},
|
||||||
|
[eventCodes.DEVICE_ENROLL_PAIRING_REQUEST]: {
|
||||||
|
type: 'device.enroll_pairing.request',
|
||||||
|
desc: 'Device Enroll Pairing Requested',
|
||||||
|
format: ({ user, device }) =>
|
||||||
|
`Device enrollment was requested for user [${user}]${formatDevice(device)}`,
|
||||||
|
},
|
||||||
|
[eventCodes.DEVICE_ENROLL_PAIRING_REQUEST_FAILURE]: {
|
||||||
|
type: 'device.enroll_pairing.request',
|
||||||
|
desc: 'Device Enroll Pairing Request Failed',
|
||||||
|
format: ({ device, error }) =>
|
||||||
|
error
|
||||||
|
? `Device enrollment request failed${formatDevice(device)}: ${error}`
|
||||||
|
: `Device enrollment request failed${formatDevice(device)}`,
|
||||||
|
},
|
||||||
[eventCodes.X11_FORWARD]: {
|
[eventCodes.X11_FORWARD]: {
|
||||||
type: 'x11-forward',
|
type: 'x11-forward',
|
||||||
desc: 'X11 Forwarding Requested',
|
desc: 'X11 Forwarding Requested',
|
||||||
@@ -2989,3 +3005,22 @@ export function formatSqn({
|
|||||||
}
|
}
|
||||||
return `${scope}::${name}`;
|
return `${scope}::${name}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// formatDevice renders the OS type and serial number (carried as asset_tag) of a device audit
|
||||||
|
// event's device metadata, e.g. " (OS [iOS], serial number [ABC123])". Returns an empty string when
|
||||||
|
// neither is present.
|
||||||
|
const formatDevice = (device?: {
|
||||||
|
asset_tag?: string;
|
||||||
|
os_type?: number;
|
||||||
|
}): string => {
|
||||||
|
const parts: string[] = [];
|
||||||
|
// Account for os_type possibly being 0 (UNSPECIFIED) with os_type != null.
|
||||||
|
const os = device?.os_type != null ? osTypeLabel(device.os_type) : undefined;
|
||||||
|
if (os) {
|
||||||
|
parts.push(`OS [${os}]`);
|
||||||
|
}
|
||||||
|
if (device?.asset_tag) {
|
||||||
|
parts.push(`serial number [${device.asset_tag}]`);
|
||||||
|
}
|
||||||
|
return parts.length ? ` (${parts.join(', ')})` : '';
|
||||||
|
};
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export const eventGroupTypes = {
|
|||||||
subsystem: 'Subsystem Request',
|
subsystem: 'Subsystem Request',
|
||||||
'user.login': 'User Logins',
|
'user.login': 'User Logins',
|
||||||
'spiffe.svid.issued': 'SPIFFE SVID Issuance',
|
'spiffe.svid.issued': 'SPIFFE SVID Issuance',
|
||||||
|
'device.enroll_pairing.request': 'Device Enroll Pairing Request',
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -43,7 +44,6 @@ export const eventGroupTypes = {
|
|||||||
* These duplicated event types needs to be defined in `eventGroupTypes` object
|
* These duplicated event types needs to be defined in `eventGroupTypes` object
|
||||||
* 3: Define icons for events under `EventTypeCell.tsx` file
|
* 3: Define icons for events under `EventTypeCell.tsx` file
|
||||||
* 4: Add an actual JSON event to the fixtures file in `src/Audit/fixtures/index.ts`.
|
* 4: Add an actual JSON event to the fixtures file in `src/Audit/fixtures/index.ts`.
|
||||||
* 5: Check fixture is rendered in storybook, then update snapshot for `Audit.story.test.tsx`
|
|
||||||
*/
|
*/
|
||||||
export const eventCodes = {
|
export const eventCodes = {
|
||||||
ACCESS_REQUEST_CREATED: 'T5000I',
|
ACCESS_REQUEST_CREATED: 'T5000I',
|
||||||
@@ -138,6 +138,8 @@ export const eventCodes = {
|
|||||||
DEVICE_UPDATE: 'TV007I',
|
DEVICE_UPDATE: 'TV007I',
|
||||||
DEVICE_WEB_TOKEN_CREATE: 'TV008I',
|
DEVICE_WEB_TOKEN_CREATE: 'TV008I',
|
||||||
DEVICE_AUTHENTICATE_CONFIRM: 'TV009I',
|
DEVICE_AUTHENTICATE_CONFIRM: 'TV009I',
|
||||||
|
DEVICE_ENROLL_PAIRING_REQUEST: 'TV010I',
|
||||||
|
DEVICE_ENROLL_PAIRING_REQUEST_FAILURE: 'TV010W',
|
||||||
EXEC_FAILURE: 'T3002E',
|
EXEC_FAILURE: 'T3002E',
|
||||||
EXEC: 'T3002I',
|
EXEC: 'T3002I',
|
||||||
GITHUB_CONNECTOR_CREATED: 'T8000I',
|
GITHUB_CONNECTOR_CREATED: 'T8000I',
|
||||||
@@ -1445,6 +1447,15 @@ export type RawEvents = {
|
|||||||
[eventCodes.DEVICE_AUTHENTICATE_CONFIRM]: RawDeviceEvent<
|
[eventCodes.DEVICE_AUTHENTICATE_CONFIRM]: RawDeviceEvent<
|
||||||
typeof eventCodes.DEVICE_AUTHENTICATE_CONFIRM
|
typeof eventCodes.DEVICE_AUTHENTICATE_CONFIRM
|
||||||
>;
|
>;
|
||||||
|
[eventCodes.DEVICE_ENROLL_PAIRING_REQUEST]: RawDeviceEvent<
|
||||||
|
typeof eventCodes.DEVICE_ENROLL_PAIRING_REQUEST
|
||||||
|
>;
|
||||||
|
// The failure event carries no user (the pairing lookup that resolves the user
|
||||||
|
// is what failed) and surfaces the failure reason via the error field.
|
||||||
|
[eventCodes.DEVICE_ENROLL_PAIRING_REQUEST_FAILURE]: RawEvent<
|
||||||
|
typeof eventCodes.DEVICE_ENROLL_PAIRING_REQUEST_FAILURE,
|
||||||
|
Merge<DeviceEventFields, { error?: string }>
|
||||||
|
>;
|
||||||
[eventCodes.UNKNOWN]: RawEvent<
|
[eventCodes.UNKNOWN]: RawEvent<
|
||||||
typeof eventCodes.UNKNOWN,
|
typeof eventCodes.UNKNOWN,
|
||||||
{
|
{
|
||||||
@@ -2597,16 +2608,15 @@ type RawEventData<T extends EventCode> = RawEvent<
|
|||||||
}
|
}
|
||||||
>;
|
>;
|
||||||
|
|
||||||
type RawDeviceEvent<T extends EventCode> = RawEvent<
|
type DeviceEventFields = {
|
||||||
T,
|
device: { asset_tag: string; device_id: string; os_type: number };
|
||||||
{
|
success?: boolean;
|
||||||
device: { asset_tag: string; device_id: string; os_type: number };
|
user?: string;
|
||||||
success?: boolean;
|
// status from "legacy" event format.
|
||||||
user?: string;
|
status?: { success: boolean };
|
||||||
// status from "legacy" event format.
|
};
|
||||||
status?: { success: boolean };
|
|
||||||
}
|
type RawDeviceEvent<T extends EventCode> = RawEvent<T, DeviceEventFields>;
|
||||||
>;
|
|
||||||
|
|
||||||
type RawEventCommand<T extends EventCode> = RawEvent<
|
type RawEventCommand<T extends EventCode> = RawEvent<
|
||||||
T,
|
T,
|
||||||
|
|||||||
Reference in New Issue
Block a user