Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
165 changes: 106 additions & 59 deletions .github/ISSUE_TEMPLATE/testplan.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,67 +180,114 @@ as well as an upgrade of the previous version of Teleport.

- [ ] Interact with a cluster using `tsh`

These commands should ideally be tested for recording and non-recording modes as they are implemented in a different ways.

- [ ] tsh ssh \<regular-node\>
- [ ] tsh ssh \<node-remote-cluster\>
- [ ] tsh ssh \<agentless-node\>
- [ ] tsh ssh \<agentless-node-remote-cluster\>
- [ ] tsh ssh -A \<regular-node\>
- [ ] tsh ssh -A \<node-remote-cluster\>
- [ ] tsh ssh -A \<agentless-node\>
- [ ] tsh ssh -A \<agentless-node-remote-cluster\>
- [ ] tsh ssh \<regular-node\> ls
- [ ] tsh ssh \<node-remote-cluster\> ls
- [ ] tsh ssh \<agentless-node\> ls
- [ ] tsh ssh \<agentless-node-remote-cluster\> ls
- [ ] tsh join \<regular-node\>
- [ ] tsh join \<node-remote-cluster\>
- [ ] tsh play \<regular-node\>
- [ ] tsh play \<node-remote-cluster\>
- [ ] tsh play \<agentless-node\>
- [ ] tsh play \<agentless-node-remote-cluster\>
- [ ] tsh scp \<regular-node\>
- [ ] tsh scp \<node-remote-cluster\>
- [ ] tsh scp \<agentless-node\>
- [ ] tsh scp \<agentless-node-remote-cluster\>
- [ ] tsh ssh -L \<regular-node\>
- [ ] tsh ssh -L \<node-remote-cluster\>
- [ ] tsh ssh -L \<agentless-node\>
- [ ] tsh ssh -L \<agentless-node-remote-cluster\>
- [ ] tsh ssh -R \<regular-node\>
- [ ] tsh ssh -R \<node-remote-cluster\>
- [ ] tsh ssh -R \<agentless-node\>
- [ ] tsh ssh -R \<agentless-node-remote-cluster\>
- [ ] tsh ls
- [ ] tsh clusters
These commands should ideally be tested for recording and non-recording modes as they are implemented in a different ways.
Recording can be disabled by adding `session_recording: off` to `auth_service` in your config. A regular node refers to
a [Teleport SSH service](https://goteleport.com/docs/enroll-resources/server-access/getting-started/). An agentless node is an [OpenSSH server](https://goteleport.com/docs/enroll-resources/server-access/openssh/openssh-agentless) that has been enrolled into Teleport. A remote cluster is a leaf cluster that is connected to a root cluster via a [trusted cluster setup](https://goteleport.com/docs/admin-guides/management/admin/trustedclusters/). Here's a recommended setup for testing:

```
┌───────────────┐
│ │
┌►│ Regular Node │
┌───────────────┐ ┌───────────────┐ │ │ │
│ │ │ │ │ └───────────────┘
│ Root Cluster ├───►│ Leaf Cluster ├─┤
│ │ │ │ │ ┌───────────────┐
└───────────────┘ └───────────────┘ │ │ │
└►│ OpenSSH Node │
│ │
└───────────────┘
```

When you want to test a non-remote-cluster, use the Leaf Cluster as your proxy target.

- [ ] `tsh ssh <regular-node>`
- [ ] `tsh ssh <node-remote-cluster>`
- [ ] `tsh ssh <agentless-node>`
- [ ] `tsh ssh <agentless-node-remote-cluster>`

Test agent had been forwarded by running `ssh-add -L` and check that your teleport keys are listed. Each cluster requires the `permit-agent-forwarding` flag and the role you're assuming in the leaf cluster needs `Agent Forwarding` enabled. Example connection command:
`tsh ssh -A --proxy $PROXY --cluster $REMOTE_CLUSTER $USER@$NODE_NAME`

- [ ] `tsh ssh -A <regular-node>`
- [ ] `tsh ssh -A <node-remote-cluster>`
- [ ] `tsh ssh -A <agentless-node>`
- [ ] `tsh ssh -A <agentless-node-remote-cluster>`
- [ ] `tsh ssh <regular-node> ls`
- [ ] `tsh ssh <node-remote-cluster> ls`
- [ ] `tsh ssh <agentless-node> ls`
- [ ] `tsh ssh <agentless-node-remote-cluster> ls`
- [ ] `tsh join <regular-node-session-id>`
- [ ] `tsh join <node-remote-cluster-session-id>`

For `tsh play`, ensure the role you assume on the leaf cluster has `read` and `list` for the `session` resource. Example allow rule:
```yaml
spec:
allow:
rules:
- resources:
- session
verbs:
- read
- list
```

- [ ] `tsh play <regular-node-session-id>`
- [ ] `tsh play <node-remote-cluster-session-id>`
- [ ] `tsh play <agentless-node>`
- [ ] `tsh play <agentless-node-remote-cluster>`
- [ ] `tsh scp <regular-node>`
- [ ] `tsh scp <node-remote-cluster>`
- [ ] `tsh scp <agentless-node>`
- [ ] `tsh scp <agentless-node-remote-cluster>`

This forwards the local port to the remote node, test this with a web server running on the remote node, e.g. `python3 -m http.server 8000` on the remote node, setup a tunnel to the node with `tsh ssh -L 9000:localhost:8000 <remote-node>`, then `curl http://localhost:9000` from your local machine.

- [ ] `tsh ssh -L <regular-node>`
- [ ] `tsh ssh -L <node-remote-cluster>`
- [ ] `tsh ssh -L <agentless-node>`
- [ ] `tsh ssh -L <agentless-node-remote-cluster>`

`-R` forwards the remote port to the local machine, test this with a web server running on your local machine, e.g. `python3 -m http.server 8000`, setup a tunnel to the node with `tsh ssh -R 9000:localhost:8000 <remote-node>`, then `curl http://localhost:9000` from the remote node.

- [ ] `tsh ssh -R <regular-node>`
- [ ] `tsh ssh -R <node-remote-cluster>`
- [ ] `tsh ssh -R <agentless-node>`
- [ ] `tsh ssh -R <agentless-node-remote-cluster>`
- [ ] `tsh ls`
- [ ] `tsh clusters`

- [ ] Interact with a cluster using `ssh`
Make sure to test both recording and regular proxy modes.
- [ ] ssh \<regular-node\>
- [ ] ssh \<node-remote-cluster\>
- [ ] ssh \<agentless-node\>
- [ ] ssh \<agentless-node-remote-cluster\>
- [ ] ssh -A \<regular-node\>
- [ ] ssh -A \<node-remote-cluster\>
- [ ] ssh -A \<agentless-node\>
- [ ] ssh -A \<agentless-node-remote-cluster\>
- [ ] ssh \<regular-node\> ls
- [ ] ssh \<node-remote-cluster\> ls
- [ ] ssh \<agentless-node\> ls
- [ ] ssh \<agentless-node-remote-cluster\> ls
- [ ] scp \<regular-node\>
- [ ] scp \<node-remote-cluster\>
- [ ] scp \<agentless-node\>
- [ ] scp \<agentless-node-remote-cluster\>
- [ ] ssh -L \<regular-node\>
- [ ] ssh -L \<node-remote-cluster\>
- [ ] ssh -L \<agentless-node\>
- [ ] ssh -L \<agentless-node-remote-cluster\>
- [ ] ssh -R \<regular-node\>
- [ ] ssh -R \<node-remote-cluster\>
- [ ] ssh -R \<agentless-node\>
- [ ] ssh -R \<agentless-node-remote-cluster\>

Make sure to test both recording and regular proxy modes. Generate an [SSH config](https://goteleport.com/docs/reference/cli/tsh/#tsh-config), one per cluster. An SSH command will look something like this:

`ssh -p 22 -F /path/to/generated/ssh_config <user>@<node-name>.<cluster-that-the-node-is-in>`

To test connecting to a remote cluster, use the root cluster's `ssh_config` and the name of the remote cluster for `<cluster-that-the-node-is-in>`.

- [ ] `ssh <regular-node>`
- [ ] `ssh <node-remote-cluster>`
- [ ] `ssh <agentless-node>`
- [ ] `ssh <agentless-node-remote-cluster>`
- [ ] `ssh -A <regular-node>`
- [ ] `ssh -A <node-remote-cluster>`
- [ ] `ssh -A <agentless-node>`
- [ ] `ssh -A <agentless-node-remote-cluster>`
- [ ] `ssh <regular-node> ls`
- [ ] `ssh <node-remote-cluster> ls`
- [ ] `ssh <agentless-node> ls`
- [ ] `ssh <agentless-node-remote-cluster> ls`
- [ ] `scp <regular-node>`
- [ ] `scp <node-remote-cluster>`
- [ ] `scp <agentless-node>`
- [ ] `scp <agentless-node-remote-cluster>`
- [ ] `ssh -L <regular-node>`
- [ ] `ssh -L <node-remote-cluster>`
- [ ] `ssh -L <agentless-node>`
- [ ] `ssh -L <agentless-node-remote-cluster>`
- [ ] `ssh -R <regular-node>`
- [ ] `ssh -R <node-remote-cluster>`
- [ ] `ssh -R <agentless-node>`
- [ ] `ssh -R <agentless-node-remote-cluster>`

- [ ] Verify proxy jump functionality
Log into leaf cluster via root, shut down the root proxy and verify proxy jump works.
Expand Down
97 changes: 97 additions & 0 deletions api/proto/teleport/legacy/types/events/events.proto
Original file line number Diff line number Diff line change
Expand Up @@ -4823,6 +4823,9 @@ message OneOf {
events.MCPSessionEnd MCPSessionEnd = 217;
events.MCPSessionRequest MCPSessionRequest = 218;
events.MCPSessionNotification MCPSessionNotification = 219;
events.BoundKeypairRecovery BoundKeypairRecovery = 220;
events.BoundKeypairRotation BoundKeypairRotation = 221;
events.BoundKeypairJoinStateVerificationFailed BoundKeypairJoinStateVerificationFailed = 222;
}
}

Expand Down Expand Up @@ -8701,3 +8704,97 @@ message MCPSessionNotification {
(gogoproto.jsontag) = "message,omitempty"
];
}

// BoundKeypairRecovery is emitted when a client performs a self recovery using
// a bound_keypair joining token. This event is also emitted upon first join.
message BoundKeypairRecovery {
// Metadata is a common event metadata.
Metadata Metadata = 1 [
(gogoproto.nullable) = false,
(gogoproto.embed) = true,
(gogoproto.jsontag) = ""
];
// Status contains common command or operation status fields.
Status Status = 2 [
(gogoproto.nullable) = false,
(gogoproto.embed) = true,
(gogoproto.jsontag) = ""
];
// ConnectionMetadata holds information about the connection
ConnectionMetadata Connection = 3 [
(gogoproto.nullable) = false,
(gogoproto.embed) = true,
(gogoproto.jsontag) = ""
];
// TokenName is the name of the provision token used to join.
string TokenName = 4 [(gogoproto.jsontag) = "token_name"];
// BotName is the name of the bot attempting to join, if any.
string BotName = 5 [(gogoproto.jsontag) = "bot_name,omitempty"];
// PublicKey is the public key at the completion of the joining process, in
// SSH authorized_keys format. If a keypair rotation occurred, this is the
// keypair trusted at the end of the join process.
string PublicKey = 6 [(gogoproto.jsontag) = "public_key,omitempty"];
// RecoveryCount is the recovery counter value at the time of this recovery.
uint32 RecoveryCount = 7 [(gogoproto.jsontag) = "recovery_count"];
// RecoveryMode is the bound keypair token's configured recovery mode.
string RecoveryMode = 8 [(gogoproto.jsontag) = "recovery_mode"];
}

// BoundKeypairRotation is emitted when a keypair rotation takes place.
message BoundKeypairRotation {
// Metadata is a common event metadata.
Metadata Metadata = 1 [
(gogoproto.nullable) = false,
(gogoproto.embed) = true,
(gogoproto.jsontag) = ""
];
// Status contains common command or operation status fields.
Status Status = 2 [
(gogoproto.nullable) = false,
(gogoproto.embed) = true,
(gogoproto.jsontag) = ""
];
// ConnectionMetadata holds information about the connection
ConnectionMetadata Connection = 3 [
(gogoproto.nullable) = false,
(gogoproto.embed) = true,
(gogoproto.jsontag) = ""
];
// TokenName is the name of the provision token used to join.
string TokenName = 4 [(gogoproto.jsontag) = "token_name"];
// BotName is the name of the bot attempting to join, if any.
string BotName = 5 [(gogoproto.jsontag) = "bot_name,omitempty"];
// PreviousPublicKey is the previous public key in SSH authorized_keys format.
// On first join using a registration secret, this value will be empty.
string PreviousPublicKey = 6 [(gogoproto.jsontag) = "previous_public_key,omitempty"];
// NewPublicKey is the new public key after rotation. If rotation fails, this
// value will be empty.
string NewPublicKey = 7 [(gogoproto.jsontag) = "new_public_key,omitempty"];
}

// BoundKeypairJoinStateVerificationFailed is emitted when join state
// verification fails, potentially indicating a compromised keypair.
message BoundKeypairJoinStateVerificationFailed {
// Metadata is a common event metadata.
Metadata Metadata = 1 [
(gogoproto.nullable) = false,
(gogoproto.embed) = true,
(gogoproto.jsontag) = ""
];
// Status contains information about the failure.
Status Status = 2 [
(gogoproto.nullable) = false,
(gogoproto.embed) = true,
(gogoproto.jsontag) = ""
];
// ConnectionMetadata holds information about the connection
ConnectionMetadata Connection = 3 [
(gogoproto.nullable) = false,
(gogoproto.embed) = true,
(gogoproto.jsontag) = ""
];
// TokenName is the name of the provision token used to join.
string TokenName = 4 [(gogoproto.jsontag) = "token_name"];
// BotName is the name of the bot attempting to join, if any.
string BotName = 5 [(gogoproto.jsontag) = "bot_name,omitempty"];
}
66 changes: 66 additions & 0 deletions api/types/events/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -2598,3 +2598,69 @@ func (m *MCPSessionNotification) TrimToMaxSize(maxSize int) AuditEvent {
out.Message = m.Message.trimToMaxSize(maxSize)
return out
}

func (m *BoundKeypairRecovery) TrimToMaxSize(maxSize int) AuditEvent {
size := m.Size()
if size <= maxSize {
return m
}
out := utils.CloneProtoMsg(m)
out.Status = Status{}
out.TokenName = ""
out.BotName = ""
out.PublicKey = ""

maxSize = adjustedMaxSize(out, maxSize)
customFieldsCount := m.Status.nonEmptyStrs() + nonEmptyStrs(m.TokenName, m.BotName, m.PublicKey)
maxFieldsSize := maxSizePerField(maxSize, customFieldsCount)

out.Status = m.Status.trimToMaxSize(maxFieldsSize)
out.TokenName = trimStr(m.TokenName, maxFieldsSize)
out.BotName = trimStr(m.BotName, maxFieldsSize)
out.PublicKey = trimStr(m.PublicKey, maxFieldsSize)
return out
}

func (m *BoundKeypairRotation) TrimToMaxSize(maxSize int) AuditEvent {
size := m.Size()
if size <= maxSize {
return m
}
out := utils.CloneProtoMsg(m)
out.Status = Status{}
out.TokenName = ""
out.BotName = ""
out.PreviousPublicKey = ""
out.NewPublicKey = ""

maxSize = adjustedMaxSize(out, maxSize)
customFieldsCount := m.Status.nonEmptyStrs() + nonEmptyStrs(m.TokenName, m.BotName, m.PreviousPublicKey, m.NewPublicKey)
maxFieldsSize := maxSizePerField(maxSize, customFieldsCount)

out.Status = m.Status.trimToMaxSize(maxFieldsSize)
out.TokenName = trimStr(m.TokenName, maxFieldsSize)
out.BotName = trimStr(m.BotName, maxFieldsSize)
out.PreviousPublicKey = trimStr(m.PreviousPublicKey, maxFieldsSize)
out.NewPublicKey = trimStr(m.NewPublicKey, maxFieldsSize)
return out
}

func (m *BoundKeypairJoinStateVerificationFailed) TrimToMaxSize(maxSize int) AuditEvent {
size := m.Size()
if size <= maxSize {
return m
}
out := utils.CloneProtoMsg(m)
out.Status = Status{}
out.TokenName = ""
out.BotName = ""

maxSize = adjustedMaxSize(out, maxSize)
customFieldsCount := m.Status.nonEmptyStrs() + nonEmptyStrs(m.TokenName, m.BotName)
maxFieldsSize := maxSizePerField(maxSize, customFieldsCount)

out.Status = m.Status.trimToMaxSize(maxFieldsSize)
out.TokenName = trimStr(m.TokenName, maxFieldsSize)
out.BotName = trimStr(m.BotName, maxFieldsSize)
return out
}
Loading