# Ports and SSH API

> Expose guest ports as HTTPS URLs, issue access tokens for private ports, and install an SSH key.

- [List exposed ports](#list-exposed-ports): `GET /v1/machines/{machine_id}/ports`
- [Expose a port](#expose-a-port): `POST /v1/machines/{machine_id}/ports`
- [Close a port](#close-a-port): `DELETE /v1/machines/{machine_id}/ports/{port}`
- [Issue a port access token](#issue-a-port-access-token): `POST /v1/machines/{machine_id}/ports/{port}/token`
- [Install an SSH key](#install-an-ssh-key): `PUT /v1/machines/{machine_id}/ssh-key`
- [Remove the SSH key](#remove-the-ssh-key): `DELETE /v1/machines/{machine_id}/ssh-key`

## List exposed ports

`GET /v1/machines/{machine_id}/ports`

- **API key:** `Authorization: Bearer vmy_…` with preset `read_only`, `use_existing`, `create_and_use`, `full_access`.
- **Browser session:** the `vmyard_session` cookie.
- **Who:** Any member.

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `machine_id` | path | `string (uuid)` | Yes |  |
| `X-Organization-ID` | header | `string (uuid)` | No | Required with a browser session. Optional with an API key, which fixes the organization; when sent it must match. |

**Responses**

| Status | Description | Body |
| --- | --- | --- |
| `200` | — | [`PortListDto`](/docs/api/schemas#portlistdto) |
| `401` | Not signed in, or the API key is malformed, expired, or revoked (`unauthenticated`, `invalid_api_key`). | [`ProblemDetails`](/docs/api/schemas#problemdetails) |
| `404` | Not found in this organization (`resource_not_found`). | [`ProblemDetails`](/docs/api/schemas#problemdetails) |
| `503` | `ingress_unavailable`: no edge is configured | [`ProblemDetails`](/docs/api/schemas#problemdetails) |

```sh title="Request"
curl "$VMYARD_API_URL/v1/machines/$MACHINE_ID/ports" \
  -H "Authorization: Bearer $VMYARD_API_KEY"
```

```json title="Response 200"
{
  "items": [
    {
      "localhost": false,
      "port": 0,
      "public": false,
      "url": "https://hooks.example.com/vmyard"
    }
  ],
  "label": "k7f2mq3x9a1b"
}
```

## Expose a port

`POST /v1/machines/{machine_id}/ports`

- **API key:** `Authorization: Bearer vmy_…` with preset `use_existing`, `create_and_use`, `full_access`.
- **Browser session:** the `vmyard_session` cookie and the `x-csrf-token` header.
- **Who:** Owner, Admin, or Operator.
- **Idempotency:** send a unique `Idempotency-Key`; replaying the same key and body returns the same operation.

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `machine_id` | path | `string (uuid)` | Yes |  |
| `X-Organization-ID` | header | `string (uuid)` | No | Required with a browser session. Optional with an API key, which fixes the organization; when sent it must match. |

**Request body** (`application/json`, [`ExposePortRequest`](/docs/api/schemas#exposeportrequest))

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `port` | `integer` | Yes | Guest TCP port, 1-65535 except 22 (use the SSH key for SSH) and the desktop's 5907 and 5908 (open the desktop instead). |
| `localhost` | `boolean` | No | Relay the guest's addresses to a service bound to `127.0.0.1`/`[::1]`. |
| `public` | `boolean` | No | Reachable without a token. Private ports (the default) need a token from `POST /v1/machines/{machine_id}/ports/{port}/token`. |

**Responses**

| Status | Description | Body |
| --- | --- | --- |
| `202` | The port is exposed by the operation; the machine must be running | [`OperationAccepted`](/docs/api/schemas#operationaccepted) |
| `401` | Not signed in, or the API key is malformed, expired, or revoked (`unauthenticated`, `invalid_api_key`). | [`ProblemDetails`](/docs/api/schemas#problemdetails) |
| `403` | Refused for this caller: role, key preset, CSRF token, or organization (`forbidden`, `insufficient_scope`, `csrf_invalid`, `organization_mismatch`, `session_required`). | [`ProblemDetails`](/docs/api/schemas#problemdetails) |
| `404` | Not found in this organization (`resource_not_found`). | [`ProblemDetails`](/docs/api/schemas#problemdetails) |
| `409` | `port_limit_reached` (16 ports), or `idempotency_conflict` | [`ProblemDetails`](/docs/api/schemas#problemdetails) |
| `422` | Invalid request (`invalid_request`); `field_errors` names the fields. | [`ProblemDetails`](/docs/api/schemas#problemdetails) |
| `503` | `ingress_unavailable`: no edge is configured | [`ProblemDetails`](/docs/api/schemas#problemdetails) |

```sh title="Request"
curl -X POST "$VMYARD_API_URL/v1/machines/$MACHINE_ID/ports" \
  -H "Authorization: Bearer $VMYARD_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
  "port": 8080,
  "public": false
}'
```

```json title="Response 202"
{
  "machine_id": "6f1c2d3e-4b5a-4c7d-8e9f-0a1b2c3d4e5f",
  "operation_id": "6f1c2d3e-4b5a-4c7d-8e9f-0a1b2c3d4e5f",
  "operation_url": "/v1/operations/6f1c2d3e-4b5a-4c7d-8e9f-0a1b2c3d4e5f",
  "requested_action": "port_expose",
  "status": "pending"
}
```

## Close a port

`DELETE /v1/machines/{machine_id}/ports/{port}`

- **API key:** `Authorization: Bearer vmy_…` with preset `use_existing`, `create_and_use`, `full_access`.
- **Browser session:** the `vmyard_session` cookie and the `x-csrf-token` header.
- **Who:** Owner, Admin, or Operator.
- **Idempotency:** send a unique `Idempotency-Key`; replaying the same key and body returns the same operation.

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `machine_id` | path | `string (uuid)` | Yes |  |
| `port` | path | `integer` | Yes |  |
| `X-Organization-ID` | header | `string (uuid)` | No | Required with a browser session. Optional with an API key, which fixes the organization; when sent it must match. |

**Responses**

| Status | Description | Body |
| --- | --- | --- |
| `202` | The port is closed by the operation; it fails if the port is not exposed | [`OperationAccepted`](/docs/api/schemas#operationaccepted) |
| `401` | Not signed in, or the API key is malformed, expired, or revoked (`unauthenticated`, `invalid_api_key`). | [`ProblemDetails`](/docs/api/schemas#problemdetails) |
| `403` | Refused for this caller: role, key preset, CSRF token, or organization (`forbidden`, `insufficient_scope`, `csrf_invalid`, `organization_mismatch`, `session_required`). | [`ProblemDetails`](/docs/api/schemas#problemdetails) |
| `404` | Not found in this organization (`resource_not_found`). | [`ProblemDetails`](/docs/api/schemas#problemdetails) |
| `409` | Conflicts with the current state, such as a reused idempotency key with another body (`idempotency_conflict`). | [`ProblemDetails`](/docs/api/schemas#problemdetails) |
| `422` | Invalid request (`invalid_request`); `field_errors` names the fields. | [`ProblemDetails`](/docs/api/schemas#problemdetails) |
| `503` | `ingress_unavailable`: no edge is configured | [`ProblemDetails`](/docs/api/schemas#problemdetails) |

```sh title="Request"
curl -X DELETE "$VMYARD_API_URL/v1/machines/$MACHINE_ID/ports/$PORT" \
  -H "Authorization: Bearer $VMYARD_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)"
```

```json title="Response 202"
{
  "machine_id": "6f1c2d3e-4b5a-4c7d-8e9f-0a1b2c3d4e5f",
  "operation_id": "6f1c2d3e-4b5a-4c7d-8e9f-0a1b2c3d4e5f",
  "operation_url": "/v1/operations/6f1c2d3e-4b5a-4c7d-8e9f-0a1b2c3d4e5f",
  "requested_action": "port_unexpose",
  "status": "pending"
}
```

## Issue a port access token

`POST /v1/machines/{machine_id}/ports/{port}/token`

- **API key:** `Authorization: Bearer vmy_…` with preset `use_existing`, `create_and_use`, `full_access`.
- **Browser session:** the `vmyard_session` cookie and the `x-csrf-token` header.
- **Who:** Owner, Admin, or Operator.

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `machine_id` | path | `string (uuid)` | Yes |  |
| `port` | path | `integer` | Yes |  |
| `X-Organization-ID` | header | `string (uuid)` | No | Required with a browser session. Optional with an API key, which fixes the organization; when sent it must match. |

**Request body** (`application/json`, [`PortTokenRequest`](/docs/api/schemas#porttokenrequest))

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `ttl_seconds` | `integer \| null` | No | Token lifetime, 1-3600 seconds; default 900. 1–3600. |

**Responses**

| Status | Description | Body |
| --- | --- | --- |
| `200` | — | [`PortTokenDto`](/docs/api/schemas#porttokendto) |
| `401` | Not signed in, or the API key is malformed, expired, or revoked (`unauthenticated`, `invalid_api_key`). | [`ProblemDetails`](/docs/api/schemas#problemdetails) |
| `403` | Refused for this caller: role, key preset, CSRF token, or organization (`forbidden`, `insufficient_scope`, `csrf_invalid`, `organization_mismatch`, `session_required`). | [`ProblemDetails`](/docs/api/schemas#problemdetails) |
| `404` | Unknown machine, or `port_not_exposed` | [`ProblemDetails`](/docs/api/schemas#problemdetails) |
| `409` | `machine_not_running`: the machine has no running or sleeping sandbox to bind the token to | [`ProblemDetails`](/docs/api/schemas#problemdetails) |
| `422` | Invalid request (`invalid_request`); `field_errors` names the fields. | [`ProblemDetails`](/docs/api/schemas#problemdetails) |
| `503` | `ingress_unavailable`: no edge is configured; `runtime_unavailable`: the fleet could not name the machine's sandbox | [`ProblemDetails`](/docs/api/schemas#problemdetails) |

```sh title="Request"
curl -X POST "$VMYARD_API_URL/v1/machines/$MACHINE_ID/ports/$PORT/token" \
  -H "Authorization: Bearer $VMYARD_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "ttl_seconds": 900
}'
```

```json title="Response 200"
{
  "expires_at": "string",
  "header_token": "string",
  "url": "https://hooks.example.com/vmyard",
  "url_with_token": "string"
}
```

## Install an SSH key

`PUT /v1/machines/{machine_id}/ssh-key`

- **API key:** `Authorization: Bearer vmy_…` with preset `use_existing`, `create_and_use`, `full_access`.
- **Browser session:** the `vmyard_session` cookie and the `x-csrf-token` header.
- **Who:** Owner, Admin, or Operator.
- **Idempotency:** send a unique `Idempotency-Key`; replaying the same key and body returns the same operation.

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `machine_id` | path | `string (uuid)` | Yes |  |
| `X-Organization-ID` | header | `string (uuid)` | No | Required with a browser session. Optional with an API key, which fixes the organization; when sent it must match. |

**Request body** (`application/json`, [`SetSshKeyRequest`](/docs/api/schemas#setsshkeyrequest))

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `public_key` | `string` | Yes | One OpenSSH public key line: `ssh-ed25519`, `ecdsa-sha2-nistp256/384/521`, or `ssh-rsa` of at least 3072 bits, with an optional comment, without a trailing newline. |

**Responses**

| Status | Description | Body |
| --- | --- | --- |
| `202` | The key is installed and an SSH port allocated by the operation; the machine must be running | [`OperationAccepted`](/docs/api/schemas#operationaccepted) |
| `401` | Not signed in, or the API key is malformed, expired, or revoked (`unauthenticated`, `invalid_api_key`). | [`ProblemDetails`](/docs/api/schemas#problemdetails) |
| `403` | Refused for this caller: role, key preset, CSRF token, or organization (`forbidden`, `insufficient_scope`, `csrf_invalid`, `organization_mismatch`, `session_required`). | [`ProblemDetails`](/docs/api/schemas#problemdetails) |
| `404` | Not found in this organization (`resource_not_found`). | [`ProblemDetails`](/docs/api/schemas#problemdetails) |
| `409` | Conflicts with the current state, such as a reused idempotency key with another body (`idempotency_conflict`). | [`ProblemDetails`](/docs/api/schemas#problemdetails) |
| `422` | Invalid request (`invalid_request`); `field_errors` names the fields. | [`ProblemDetails`](/docs/api/schemas#problemdetails) |
| `503` | `ingress_unavailable`: no edge is configured | [`ProblemDetails`](/docs/api/schemas#problemdetails) |

```sh title="Request"
curl -X PUT "$VMYARD_API_URL/v1/machines/$MACHINE_ID/ssh-key" \
  -H "Authorization: Bearer $VMYARD_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
  "public_key": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGq8n3nXxk0x3Sx3kq9dTq0bS4Cq3k0y8Gx4h0b7Y2pd you@laptop"
}'
```

```json title="Response 202"
{
  "machine_id": "6f1c2d3e-4b5a-4c7d-8e9f-0a1b2c3d4e5f",
  "operation_id": "6f1c2d3e-4b5a-4c7d-8e9f-0a1b2c3d4e5f",
  "operation_url": "/v1/operations/6f1c2d3e-4b5a-4c7d-8e9f-0a1b2c3d4e5f",
  "requested_action": "ssh_key_set",
  "status": "pending"
}
```

## Remove the SSH key

`DELETE /v1/machines/{machine_id}/ssh-key`

- **API key:** `Authorization: Bearer vmy_…` with preset `use_existing`, `create_and_use`, `full_access`.
- **Browser session:** the `vmyard_session` cookie and the `x-csrf-token` header.
- **Who:** Owner, Admin, or Operator.
- **Idempotency:** send a unique `Idempotency-Key`; replaying the same key and body returns the same operation.

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `machine_id` | path | `string (uuid)` | Yes |  |
| `X-Organization-ID` | header | `string (uuid)` | No | Required with a browser session. Optional with an API key, which fixes the organization; when sent it must match. |

**Responses**

| Status | Description | Body |
| --- | --- | --- |
| `202` | The key is removed and the SSH port released by the operation; the machine must be running | [`OperationAccepted`](/docs/api/schemas#operationaccepted) |
| `401` | Not signed in, or the API key is malformed, expired, or revoked (`unauthenticated`, `invalid_api_key`). | [`ProblemDetails`](/docs/api/schemas#problemdetails) |
| `403` | Refused for this caller: role, key preset, CSRF token, or organization (`forbidden`, `insufficient_scope`, `csrf_invalid`, `organization_mismatch`, `session_required`). | [`ProblemDetails`](/docs/api/schemas#problemdetails) |
| `404` | Not found in this organization (`resource_not_found`). | [`ProblemDetails`](/docs/api/schemas#problemdetails) |
| `409` | Conflicts with the current state, such as a reused idempotency key with another body (`idempotency_conflict`). | [`ProblemDetails`](/docs/api/schemas#problemdetails) |
| `422` | Invalid request (`invalid_request`); `field_errors` names the fields. | [`ProblemDetails`](/docs/api/schemas#problemdetails) |
| `503` | `ingress_unavailable`: no edge is configured | [`ProblemDetails`](/docs/api/schemas#problemdetails) |

```sh title="Request"
curl -X DELETE "$VMYARD_API_URL/v1/machines/$MACHINE_ID/ssh-key" \
  -H "Authorization: Bearer $VMYARD_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)"
```

```json title="Response 202"
{
  "machine_id": "6f1c2d3e-4b5a-4c7d-8e9f-0a1b2c3d4e5f",
  "operation_id": "6f1c2d3e-4b5a-4c7d-8e9f-0a1b2c3d4e5f",
  "operation_url": "/v1/operations/6f1c2d3e-4b5a-4c7d-8e9f-0a1b2c3d4e5f",
  "requested_action": "ssh_key_revoke",
  "status": "pending"
}
```
