0
0
mirror of https://github.com/bpg/terraform-provider-proxmox.git synced 2025-08-22 11:28:33 +00:00

feat(provider): add support for pre(external) auth'd session tokens (#1441)

* feat(provider): add support for pre(external) auth'd session tokens

adds provider config inputs:
  - env vars: PROXMOX_VE_AUTH_PAYLOAD; PROXMOX_VE_AUTH_TICKET with PROXMOX_VE_CSRF_PREVENTION_TOKEN
  - provider-config: auth_payload; auth_ticket with csrf_prevention_token

Signed-off-by: vanillaSprinkles <vanillaSprinkles@users.noreply.github.com>

* add //nolint to "todo" comments/questions and lll for build to pass; add flags to terraform-plugin-docs

Signed-off-by: vanillaSprinkles <vanillaSprinkles@users.noreply.github.com>

* address first iteration of comments: remove auth-payload, improve index.md

Signed-off-by: vanillaSprinkles <vanillaSprinkles@users.noreply.github.com>

* refactor credentials using struct composition, other minor cleanups

Signed-off-by: Pavel Boldyrev <627562+bpg@users.noreply.github.com>

* fix linter error

Signed-off-by: Pavel Boldyrev <627562+bpg@users.noreply.github.com>

* fix make docs, add terraform to handle fmt

Signed-off-by: vanillaSprinkles <vanillaSprinkles@users.noreply.github.com>

---------

Signed-off-by: vanillaSprinkles <vanillaSprinkles@users.noreply.github.com>
Signed-off-by: Pavel Boldyrev <627562+bpg@users.noreply.github.com>
Co-authored-by: Pavel Boldyrev <627562+bpg@users.noreply.github.com>
This commit is contained in:
vanillaSprinkles 2024-10-03 00:40:33 +00:00 committed by GitHub
parent bf2d2dc396
commit eb2f36be21
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 493 additions and 267 deletions

View File

@ -88,5 +88,7 @@ jobs:
name: test-log
path: /tmp/gotest.log
- uses: hashicorp/setup-terraform@v3
- name: Check for uncommitted changes in generated docs
run: make docs && git diff --exit-code

1
.gitignore vendored
View File

@ -19,6 +19,7 @@ modules-dev/
.run/
*~
*#
*.backup
*.bak
*.dll

View File

@ -15,10 +15,12 @@ Use the navigation to the left to read about the available resources.
```hcl
provider "proxmox" {
endpoint = "https://10.0.0.2:8006/"
# TODO: use terraform variable or remove the line, and use PROXMOX_VE_USERNAME environment variable
username = "root@pam"
# TODO: use terraform variable or remove the line, and use PROXMOX_VE_PASSWORD environment variable
password = "the-password-set-during-installation-of-proxmox-ve"
# because self-signed TLS certificate is in use
insecure = true
# uncomment (unless on Windows...)
@ -35,15 +37,20 @@ provider "proxmox" {
## Authentication
The Proxmox provider offers a flexible means of providing credentials for authentication.
Static credentials can be provided to the `proxmox` block through either a `api_token` or a combination of `username` and `password` arguments.
Static credentials and pre-authenticated session-ticket can be provided to the `proxmox` block through one the choices of arguments below, ordered by precedence:
- `auth_ticket` and `csrf_prevention_token`
- `api_token`
- `username` and `password`
!> Hard-coding credentials into any Terraform configuration is not recommended, and risks secret leakage should this file ever be committed to a public version control system.
Static credentials can be provided by adding a `username` and `password`, or `api_token` in-line in the Proxmox provider block:
Static credentials can be provided in-line in the Proxmox provider block, by adding one of the arguments above (example with username and password):
```hcl
provider "proxmox" {
endpoint = "https://10.0.0.2:8006/"
username = "username@realm"
password = "a-strong-password"
}
@ -54,6 +61,7 @@ A better approach is to extract these values into Terraform variables, and refer
```hcl
provider "proxmox" {
endpoint = var.virtual_environment_endpoint
username = var.virtual_environment_username
password = var.virtual_environment_password
}
@ -75,12 +83,54 @@ provider "proxmox" {
```sh
export PROXMOX_VE_USERNAME="username@realm"
export PROXMOX_VE_PASSWORD="a-strong-password"
export PROXMOX_VE_PASSWORD='a-strong-password'
terraform plan
```
See the [Argument Reference](#argument-reference) section for the supported variable names and use cases.
## Pre-Authentication, or Passing an Authentication Ticket into the provider
It is possible to generate a session ticket with the API, and to pass the ticket and csrf_prevention_token into the provider using environment variables `PROXMOX_VE_AUTH_TICKET` and `PROXMOX_VE_CSRF_PREVENTION_TOKEN` (or provider's arguments `auth_ticket` and `csrf_prevention_token`).
An example of using `curl` and `jq` to query the Proxmox API to get a Proxmox session ticket; it is also very easy to pass in a TOTP password this way:
```hcl
provider "proxmox" {
endpoint = "https://10.0.0.2:8006/"
}
```
```bash
#!/usr/bin/bash
## assume vars are set: PROXMOX_VE_ENDPOINT, PROXMOX_VE_USERNAME, PROXMOX_VE_PASSWORD
## end-goal: automatically set PROXMOX_VE_AUTH_TICKET and PROXMOX_VE_CSRF_PREVENTION_TOKEN
_user_totp_password='123456' ## optional TOTP password
proxmox_api_ticket_path='api2/json/access/ticket' ## cannot have double "//" - ensure endpoint ends with a "/" and this string does not begin with a "/", or vice-versa
## call the auth api endpoint
resp=$( curl -q -s -k --data-urlencode "username=${PROXMOX_VE_USERNAME}" --data-urlencode "password=${PROXMOX_VE_PASSWORD}" "${PROXMOX_VE_ENDPOINT}${proxmox_api_ticket_path}" )
auth_ticket=$( jq -r '.data.ticket' <<<"${resp}" )
resp_csrf=$( jq -r '.data.CSRFPreventionToken' <<<"${resp}" )
## check if the response payload needs a TFA (totp) passed, call the auth-api endpoint again
if [[ $(jq -r '.data.NeedTFA' <<<"${resp}") == 1 ]]; then
resp=$( curl -q -s -k -H "CSRFPreventionToken: ${resp_csrf}" --data-urlencode "username=${PROXMOX_VE_USERNAME}" --data-urlencode "tfa-challenge=${auth_ticket}" --data-urlencode "password=totp:${_user_totp_password}" "${PROXMOX_VE_ENDPOINT}${proxmox_api_ticket_path}" )
auth_ticket=$( jq -r '.data.ticket' <<<"${resp}" )
resp_csrf=$( jq -r '.data.CSRFPreventionToken' <<<"${resp}" )
fi
export PROXMOX_VE_AUTH_TICKET="${auth_ticket}"
export PROXMOX_VE_CSRF_PREVENTION_TOKEN="${resp_csrf}"
terraform plan
```
## SSH Connection
~> Please read if you are using VMs with custom disk images, or uploading snippets.
@ -120,12 +170,13 @@ The SSH agent authentication takes precedence over the `private_key` and `passwo
### SSH Private Key
In some cases where SSH agent is not available, for example when using a CI/CD pipeline that does not support SSH agent forwarding,
In some cases where SSH agent is not available, for example when using a CI/CD pipeline that does not support SSH agent forwarding,
you can use the `private_key` argument in the `ssh` block (or alternatively `PROXMOX_VE_SSH_PRIVATE_KEY` environment variable) to provide the private key for the SSH connection.
The private key mut not be encrypted, and must be in PEM format.
You can provide the private key from a file:
```hcl
provider "proxmox" {
// ...
@ -138,6 +189,7 @@ provider "proxmox" {
Alternatively, although not recommended due to the increased risk of exposing an unprotected key, heredoc syntax can be used to supply the private key as a string.
Note that the content of the private key is injected using `<<-` format to ignore indentation:
```hcl
provider "proxmox" {
// ...
@ -273,7 +325,7 @@ provider "proxmox" {
}
```
If enabled, this method will be used for all SSH connections to the target nodes in the cluster.
If enabled, this method will be used for all SSH connections to the target nodes in the cluster.
## API Token Authentication
@ -328,8 +380,8 @@ provider "proxmox" {
-> The token authentication is taking precedence over the password authentication.
-> Not all Proxmox API operations are supported via API Token.
You may see errors like
`error creating container: received an HTTP 403 response - Reason: Permission check failed (changing feature flags for privileged container is only allowed for root@pam)` or
You may see errors like
`error creating container: received an HTTP 403 response - Reason: Permission check failed (changing feature flags for privileged container is only allowed for root@pam)` or
`error creating VM: received an HTTP 500 response - Reason: only root can set 'arch' config` or
`Permission check failed (user != root@pam)` when using API Token authentication, even when `Administrator` role or the `root@pam` user is used with the token.
The workaround is to use password authentication for those operations.
@ -349,10 +401,17 @@ In addition to [generic provider arguments](https://www.terraform.io/docs/config
- `endpoint` - (Required) The endpoint for the Proxmox Virtual Environment API (can also be sourced from `PROXMOX_VE_ENDPOINT`). Usually this is `https://<your-cluster-endpoint>:8006/`. **Do not** include `/api2/json` at the end.
- `insecure` - (Optional) Whether to skip the TLS verification step (can also be sourced from `PROXMOX_VE_INSECURE`). If omitted, defaults to `false`.
- `min_tls` - (Optional) The minimum required TLS version for API calls (can also be sourced from `PROXMOX_VE_MIN_TLS`). Supported values: `1.0|1.1|1.2|1.3`. If omitted, defaults to `1.3`.
- `auth_ticket` - (Optional) The auth ticket from an external auth call (can also be sourced from `PROXMOX_VE_AUTH_TICKET`). To be used in conjunction with `csrf_prevention_token`, takes precedence over `api_token` and `username` with `password`. For example, `PVE:username@realm:12345678::some_base64_payload==`.
- `csrf_prevention_token` - (Optional) The CSRF Prevention Token from an external auth call (can also be sourced from `PROXMOX_VE_CSRF_PREVENTION_TOKEN`). For example, `12345678:some_blob`.
- `api_token` - (Optional) The API Token for the Proxmox Virtual Environment API (can also be sourced from `PROXMOX_VE_API_TOKEN`). Takes precedence over `username` with `password`. For example, `username@realm!for-terraform-provider=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`.
- `otp` - (Optional, Deprecated) The one-time password for the Proxmox Virtual Environment API (can also be sourced from `PROXMOX_VE_OTP`).
- `password` - (Required) The password for the Proxmox Virtual Environment API (can also be sourced from `PROXMOX_VE_PASSWORD`).
- `username` - (Required) The username and realm for the Proxmox Virtual Environment API (can also be sourced from `PROXMOX_VE_USERNAME`). For example, `root@pam`.
- `api_token` - (Optional) The API Token for the Proxmox Virtual Environment API (can also be sourced from `PROXMOX_VE_API_TOKEN`). For example, `root@pam!for-terraform-provider=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`.
- `password` - (Required) The password for the Proxmox Virtual Environment API (can also be sourced from `PROXMOX_VE_PASSWORD`).
- `ssh` - (Optional) The SSH connection configuration to a Proxmox node. This is a block, whose fields are documented below.
- `username` - (Optional) The username to use for the SSH connection. Defaults to the username used for the Proxmox API connection. Can also be sourced from `PROXMOX_VE_SSH_USERNAME`. Required when using API Token.
- `password` - (Optional) The password to use for the SSH connection. Defaults to the password used for the Proxmox API connection. Can also be sourced from `PROXMOX_VE_SSH_PASSWORD`.

View File

@ -10,7 +10,6 @@ import (
"context"
"fmt"
"net"
"regexp"
"strings"
"github.com/hashicorp/terraform-plugin-framework-validators/int64validator"
@ -60,14 +59,17 @@ type proxmoxProvider struct {
// proxmoxProviderModel maps provider schema data.
type proxmoxProviderModel struct {
APIToken types.String `tfsdk:"api_token"`
Endpoint types.String `tfsdk:"endpoint"`
Insecure types.Bool `tfsdk:"insecure"`
MinTLS types.String `tfsdk:"min_tls"`
OTP types.String `tfsdk:"otp"`
Username types.String `tfsdk:"username"`
Password types.String `tfsdk:"password"`
SSH []struct {
Endpoint types.String `tfsdk:"endpoint"`
Insecure types.Bool `tfsdk:"insecure"`
MinTLS types.String `tfsdk:"min_tls"`
AuthTicket types.String `tfsdk:"auth_ticket"`
CSRFPreventionToken types.String `tfsdk:"csrf_prevention_token"`
APIToken types.String `tfsdk:"api_token"`
OTP types.String `tfsdk:"otp"`
Username types.String `tfsdk:"username"`
Password types.String `tfsdk:"password"`
SSH []struct {
Agent types.Bool `tfsdk:"agent"`
AgentSocket types.String `tfsdk:"agent_socket"`
PrivateKey types.String `tfsdk:"private_key"`
@ -100,12 +102,16 @@ func (p *proxmoxProvider) Schema(_ context.Context, _ provider.SchemaRequest, re
Description: "The API token for the Proxmox VE API.",
Optional: true,
Sensitive: true,
Validators: []validator.String{
stringvalidator.RegexMatches(
regexp.MustCompile(`^\S+@\S+!\S+=([a-zA-Z0-9-]+)$`),
`must be a valid API token, e.g. 'USER@REALM!TOKENID=UUID'`,
),
},
},
"auth_ticket": schema.StringAttribute{
Description: "The pre-authenticated Ticket for the Proxmox VE API.",
Optional: true,
Sensitive: true,
},
"csrf_prevention_token": schema.StringAttribute{
Description: "The pre-authenticated CSRF Prevention Token for the Proxmox VE API.",
Optional: true,
Sensitive: true,
},
"endpoint": schema.StringAttribute{
Description: "The endpoint for the Proxmox VE API.",
@ -134,14 +140,14 @@ func (p *proxmoxProvider) Schema(_ context.Context, _ provider.SchemaRequest, re
Optional: true,
Sensitive: true,
},
"username": schema.StringAttribute{
Description: "The username for the Proxmox VE API.",
Optional: true,
},
"tmp_dir": schema.StringAttribute{
Description: "The alternative temporary directory.",
Optional: true,
},
"username": schema.StringAttribute{
Description: "The username for the Proxmox VE API.",
Optional: true,
},
},
Blocks: map[string]schema.Block{
// have to define it as a list due to backwards compatibility
@ -164,12 +170,6 @@ func (p *proxmoxProvider) Schema(_ context.Context, _ provider.SchemaRequest, re
"environment variable.",
Optional: true,
},
"private_key": schema.StringAttribute{
Description: "The unencrypted private key (in PEM format) used for the SSH connection. " +
"Defaults to the value of the `PROXMOX_VE_SSH_PRIVATE_KEY` environment variable.",
Optional: true,
Sensitive: true,
},
"password": schema.StringAttribute{
Description: "The password used for the SSH connection. " +
"Defaults to the value of the `password` field of the " +
@ -177,11 +177,17 @@ func (p *proxmoxProvider) Schema(_ context.Context, _ provider.SchemaRequest, re
Optional: true,
Sensitive: true,
},
"username": schema.StringAttribute{
Description: "The username used for the SSH connection. " +
"Defaults to the value of the `username` field of the " +
"`provider` block.",
Optional: true,
"private_key": schema.StringAttribute{
Description: "The unencrypted private key (in PEM format) used for the SSH connection. " +
"Defaults to the value of the `PROXMOX_VE_SSH_PRIVATE_KEY` environment variable.",
Optional: true,
Sensitive: true,
},
"socks5_password": schema.StringAttribute{
Description: "The password for the SOCKS5 proxy server. " +
"Defaults to the value of the `PROXMOX_VE_SSH_SOCKS5_PASSWORD` environment variable.",
Optional: true,
Sensitive: true,
},
"socks5_server": schema.StringAttribute{
Description: "The address:port of the SOCKS5 proxy server. " +
@ -193,11 +199,11 @@ func (p *proxmoxProvider) Schema(_ context.Context, _ provider.SchemaRequest, re
"Defaults to the value of the `PROXMOX_VE_SSH_SOCKS5_USERNAME` environment variable.",
Optional: true,
},
"socks5_password": schema.StringAttribute{
Description: "The password for the SOCKS5 proxy server. " +
"Defaults to the value of the `PROXMOX_VE_SSH_SOCKS5_PASSWORD` environment variable.",
Optional: true,
Sensitive: true,
"username": schema.StringAttribute{
Description: "The username used for the SSH connection. " +
"Defaults to the value of the `username` field of the " +
"`provider` block.",
Optional: true,
},
},
Blocks: map[string]schema.Block{
@ -205,14 +211,14 @@ func (p *proxmoxProvider) Schema(_ context.Context, _ provider.SchemaRequest, re
Description: "Overrides for SSH connection configuration for a Proxmox VE node.",
NestedObject: schema.NestedBlockObject{
Attributes: map[string]schema.Attribute{
"name": schema.StringAttribute{
Description: "The name of the Proxmox VE node.",
Required: true,
},
"address": schema.StringAttribute{
Description: "The address of the Proxmox VE node.",
Required: true,
},
"name": schema.StringAttribute{
Description: "The name of the Proxmox VE node.",
Required: true,
},
"port": schema.Int64Attribute{
Description: "The port of the Proxmox VE node.",
Optional: true,
@ -265,17 +271,15 @@ func (p *proxmoxProvider) Configure(
// with Terraform configuration value if set.
// Check environment variables
apiToken := utils.GetAnyStringEnv("PROXMOX_VE_API_TOKEN")
endpoint := utils.GetAnyStringEnv("PROXMOX_VE_ENDPOINT")
insecure := utils.GetAnyBoolEnv("PROXMOX_VE_INSECURE")
minTLS := utils.GetAnyStringEnv("PROXMOX_VE_MIN_TLS")
authTicket := utils.GetAnyStringEnv("PROXMOX_VE_AUTH_TICKET")
csrfPreventionToken := utils.GetAnyStringEnv("PROXMOX_VE_CSRF_PREVENTION_TOKEN")
apiToken := utils.GetAnyStringEnv("PROXMOX_VE_API_TOKEN")
username := utils.GetAnyStringEnv("PROXMOX_VE_USERNAME")
password := utils.GetAnyStringEnv("PROXMOX_VE_PASSWORD")
if !config.APIToken.IsNull() {
apiToken = config.APIToken.ValueString()
}
if !config.Endpoint.IsNull() {
endpoint = config.Endpoint.ValueString()
}
@ -288,6 +292,18 @@ func (p *proxmoxProvider) Configure(
minTLS = config.MinTLS.ValueString()
}
if !config.AuthTicket.IsNull() {
authTicket = config.AuthTicket.ValueString()
}
if !config.CSRFPreventionToken.IsNull() {
csrfPreventionToken = config.CSRFPreventionToken.ValueString()
}
if !config.APIToken.IsNull() {
apiToken = config.APIToken.ValueString()
}
if !config.Username.IsNull() {
username = config.Username.ValueString()
}
@ -312,7 +328,7 @@ func (p *proxmoxProvider) Configure(
// Create the Proxmox VE API client
creds, err := api.NewCredentials(username, password, "", apiToken)
creds, err := api.NewCredentials(username, password, "", apiToken, authTicket, csrfPreventionToken)
if err != nil {
resp.Diagnostics.AddError(
"Unable to create Proxmox VE API credentials",
@ -401,12 +417,12 @@ func (p *proxmoxProvider) Configure(
}
}
if sshUsername == "" {
sshUsername = strings.Split(creds.Username, "@")[0]
if sshUsername == "" && creds.UserCredentials != nil {
sshUsername = strings.Split(creds.UserCredentials.Username, "@")[0]
}
if sshPassword == "" {
sshPassword = creds.Password
if sshPassword == "" && creds.UserCredentials != nil {
sshPassword = creds.UserCredentials.Password
}
sshClient, err := ssh.NewClient(

View File

@ -144,12 +144,14 @@ func (e *Environment) Client() api.Client {
if e.c == nil {
e.once.Do(
func() {
endpoint := utils.GetAnyStringEnv("PROXMOX_VE_ENDPOINT")
authTicket := utils.GetAnyStringEnv("PROXMOX_VE_AUTH_TICKET")
csrfPreventionToken := utils.GetAnyStringEnv("PROXMOX_VE_CSRF_PREVENTION_TOKEN")
apiToken := utils.GetAnyStringEnv("PROXMOX_VE_API_TOKEN")
username := utils.GetAnyStringEnv("PROXMOX_VE_USERNAME")
password := utils.GetAnyStringEnv("PROXMOX_VE_PASSWORD")
endpoint := utils.GetAnyStringEnv("PROXMOX_VE_ENDPOINT")
apiToken := utils.GetAnyStringEnv("PROXMOX_VE_API_TOKEN")
creds, err := api.NewCredentials(username, password, "", apiToken)
creds, err := api.NewCredentials(username, password, "", apiToken, authTicket, csrfPreventionToken)
if err != nil {
panic(err)
}

View File

@ -115,11 +115,7 @@ type client struct {
}
// NewClient creates and initializes a VirtualEnvironmentClient instance.
func NewClient(creds *Credentials, conn *Connection) (Client, error) {
if creds == nil {
return nil, errors.New("credentials must not be nil")
}
func NewClient(creds Credentials, conn *Connection) (Client, error) {
if conn == nil {
return nil, errors.New("connection must not be nil")
}
@ -128,14 +124,19 @@ func NewClient(creds *Credentials, conn *Connection) (Client, error) {
var err error
if creds.APIToken != nil {
auth, err = NewTokenAuthenticator(*creds.APIToken)
} else {
auth, err = NewTicketAuthenticator(conn, creds)
switch {
case creds.TokenCredentials != nil:
auth, err = NewTokenAuthenticator(*creds.TokenCredentials)
case creds.TicketCredentials != nil:
auth, err = NewTicketAuthenticator(*creds.TicketCredentials)
case creds.UserCredentials != nil:
auth = NewUserAuthenticator(*creds.UserCredentials, conn)
default:
return nil, errors.New("must provide either user credentials, an API token, or a ticket")
}
if err != nil {
return nil, err
return nil, fmt.Errorf("failed to create API client: %w", err)
}
return &client{

View File

@ -8,54 +8,95 @@ package api
import (
"errors"
"regexp"
"strings"
)
const rootUsername = "root@pam"
// Credentials is a struct that holds the credentials for the Proxmox Virtual
// Environment API.
// Credentials contains the credentials for authenticating with the Proxmox VE API.
type Credentials struct {
Username string
Password string
OTP *string
APIToken *string
UserCredentials *UserCredentials
TokenCredentials *TokenCredentials
TicketCredentials *TicketCredentials
}
// NewCredentials creates a new Credentials struct.
func NewCredentials(username, password, otp, apiToken string) (*Credentials, error) {
if apiToken != "" {
return &Credentials{
APIToken: &apiToken,
}, nil
// UserCredentials contains the username, password, and OTP for authenticating with the Proxmox VE API.
type UserCredentials struct {
Username string
Password string
OTP string
}
// TokenCredentials contains the API token for authenticating with the Proxmox VE API.
type TokenCredentials struct {
APIToken string
}
// TicketCredentials contains the auth ticket and CSRF prevention token for authenticating with the Proxmox VE API.
type TicketCredentials struct {
AuthTicket string
CSRFPreventionToken string
}
// NewCredentials creates a new set of credentials for authenticating with the Proxmox VE API.
// The order of precedence is:
// 1. API token
// 2. Ticket
// 3. User credentials.
func NewCredentials(username, password, otp, apiToken, authTicket, csrfPreventionToken string) (Credentials, error) {
tok, err := newTokenCredentials(apiToken)
if err == nil {
return Credentials{TokenCredentials: &tok}, nil
}
if password == "" {
return nil, errors.New(
"you must specify a password for the Proxmox Virtual Environment API",
)
tic, err := newTicketCredentials(authTicket, csrfPreventionToken)
if err == nil {
return Credentials{TicketCredentials: &tic}, nil
}
if username == "" {
return nil, errors.New(
"you must specify a username for the Proxmox Virtual Environment API",
)
usr, err := newUserCredentials(username, password, otp)
if err == nil {
return Credentials{UserCredentials: &usr}, nil
}
return Credentials{}, errors.New("must provide either user credentials, an API token, or a ticket")
}
func newUserCredentials(username, password, otp string) (UserCredentials, error) {
if username == "" || password == "" {
return UserCredentials{}, errors.New("both username and password are required")
}
if !strings.Contains(username, "@") {
return nil, errors.New(
"make sure the username for the Proxmox Virtual Environment API ends in '@pve or @pam'",
)
return UserCredentials{}, errors.New("username must end with '@pve' or '@pam'")
}
c := &Credentials{
return UserCredentials{
Username: username,
Password: password,
}
if otp != "" {
c.OTP = &otp
}
return c, nil
OTP: otp,
}, nil
}
func newTokenCredentials(apiToken string) (TokenCredentials, error) {
re := regexp.MustCompile(`^\S+@\S+!\S+=([a-zA-Z0-9-]+)$`)
if !re.MatchString(apiToken) {
return TokenCredentials{}, errors.New("must be a valid API token, e.g. 'USER@REALM!TOKENID=UUID'")
}
return TokenCredentials{
APIToken: apiToken,
}, nil
}
func newTicketCredentials(authTicket, csrfPreventionToken string) (TicketCredentials, error) {
if authTicket == "" || csrfPreventionToken == "" {
return TicketCredentials{}, errors.New("both authTicket and csrfPreventionToken are required")
}
return TicketCredentials{
AuthTicket: authTicket,
CSRFPreventionToken: csrfPreventionToken,
}, nil
}

View File

@ -7,114 +7,39 @@
package api
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"sync"
"github.com/hashicorp/terraform-plugin-log/tflog"
"github.com/bpg/terraform-provider-proxmox/utils"
"strings"
)
type ticketAuthenticator struct {
conn *Connection
authRequest string
authData *AuthenticationResponseData
mu sync.Mutex
authData *AuthenticationResponseData
}
// NewTicketAuthenticator returns a new ticket authenticator.
func NewTicketAuthenticator(conn *Connection, creds *Credentials) (Authenticator, error) {
authRequest := fmt.Sprintf(
"username=%s&password=%s",
url.QueryEscape(creds.Username),
url.QueryEscape(creds.Password),
)
func NewTicketAuthenticator(creds TicketCredentials) (Authenticator, error) {
ard := &AuthenticationResponseData{}
ard.Ticket = &(creds.AuthTicket)
ard.CSRFPreventionToken = &(creds.CSRFPreventionToken)
// OTP is optional, and probably doesn't make much sense for most provider users.
if creds.OTP != nil {
authRequest = fmt.Sprintf("%s&otp=%s", authRequest, url.QueryEscape(*creds.OTP))
authTicketSplits := strings.Split(creds.AuthTicket, ":")
if len(authTicketSplits) > 3 {
ard.Username = strings.Split(creds.AuthTicket, ":")[1]
} else {
return nil, errors.New("AuthTicket must include a valid username")
}
if !strings.Contains(ard.Username, "@") {
return nil, errors.New("username must end with '@pve' or '@pam'")
}
return &ticketAuthenticator{
conn: conn,
authRequest: authRequest,
authData: ard,
}, nil
}
func (t *ticketAuthenticator) authenticate(ctx context.Context) (*AuthenticationResponseData, error) {
t.mu.Lock()
defer t.mu.Unlock()
if t.authData != nil {
return t.authData, nil
}
req, err := http.NewRequestWithContext(
ctx,
http.MethodPost,
fmt.Sprintf("%s/%s/access/ticket", t.conn.endpoint, basePathJSONAPI),
bytes.NewBufferString(t.authRequest),
)
if err != nil {
return nil, fmt.Errorf("failed to create authentication request: %w", err)
}
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
tflog.Debug(ctx, "Sending authentication request", map[string]interface{}{
"path": req.URL.Path,
})
//nolint:bodyclose
res, err := t.conn.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to retrieve authentication response: %w", err)
}
defer utils.CloseOrLogError(ctx)(res.Body)
err = validateResponseCode(res)
if err != nil {
return nil, fmt.Errorf("failed to authenticate: %w", err)
}
resBody := AuthenticationResponseBody{}
err = json.NewDecoder(res.Body).Decode(&resBody)
if err != nil {
return nil, fmt.Errorf("failed to decode authentication response, %w", err)
}
if resBody.Data == nil {
return nil, errors.New("the server did not include a data object in the authentication response")
}
if resBody.Data.CSRFPreventionToken == nil {
return nil, errors.New(
"the server did not include a CSRF prevention token in the authentication response",
)
}
if resBody.Data.Ticket == nil {
return nil, errors.New("the server did not include a ticket in the authentication response")
}
if resBody.Data.Username == "" {
return nil, errors.New("the server did not include the username in the authentication response")
}
t.authData = resBody.Data
return resBody.Data, nil
}
func (t *ticketAuthenticator) IsRoot() bool {
return t.authData != nil && t.authData.Username == rootUsername
}
@ -124,21 +49,16 @@ func (t *ticketAuthenticator) IsRootTicket() bool {
}
// AuthenticateRequest adds authentication data to a new request.
func (t *ticketAuthenticator) AuthenticateRequest(ctx context.Context, req *http.Request) error {
a, err := t.authenticate(ctx)
if err != nil {
return fmt.Errorf("failed to authenticate: %w", err)
}
func (t *ticketAuthenticator) AuthenticateRequest(_ context.Context, req *http.Request) error {
req.AddCookie(&http.Cookie{
HttpOnly: true,
Name: "PVEAuthCookie",
Secure: true,
Value: *a.Ticket,
Value: *t.authData.Ticket,
})
if req.Method != http.MethodGet {
req.Header.Add("CSRFPreventionToken", *a.CSRFPreventionToken)
req.Header.Add("CSRFPreventionToken", *t.authData.CSRFPreventionToken)
}
return nil

View File

@ -19,7 +19,9 @@ type AuthenticationResponseBody struct {
type AuthenticationResponseCapabilities struct {
Access *types.CustomPrivileges `json:"access,omitempty"`
Datacenter *types.CustomPrivileges `json:"dc,omitempty"`
Mapping *types.CustomPrivileges `json:"mapping,omitempty"`
Nodes *types.CustomPrivileges `json:"nodes,omitempty"`
SDN *types.CustomPrivileges `json:"sdn,omitempty"`
Storage *types.CustomPrivileges `json:"storage,omitempty"`
VMs *types.CustomPrivileges `json:"vms,omitempty"`
}

View File

@ -19,10 +19,10 @@ type tokenAuthenticator struct {
// NewTokenAuthenticator creates a new authenticator that uses a PVE API Token
// for authentication.
func NewTokenAuthenticator(token string) (Authenticator, error) {
func NewTokenAuthenticator(toc TokenCredentials) (Authenticator, error) {
return &tokenAuthenticator{
username: strings.Split(token, "!")[0],
token: token,
username: strings.Split(toc.APIToken, "!")[0],
token: toc.APIToken,
}, nil
}

151
proxmox/api/user_auth.go Normal file
View File

@ -0,0 +1,151 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package api
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"sync"
"github.com/hashicorp/terraform-plugin-log/tflog"
"github.com/bpg/terraform-provider-proxmox/utils"
)
type userAuthenticator struct {
conn *Connection
authRequest string
authData *AuthenticationResponseData
mu sync.Mutex
}
// NewUserAuthenticator creates a new authenticator that uses a username and password for authentication.
func NewUserAuthenticator(creds UserCredentials, conn *Connection) Authenticator {
authRequest := fmt.Sprintf(
"username=%s&password=%s",
url.QueryEscape(creds.Username),
url.QueryEscape(creds.Password),
)
// OTP is optional, and probably doesn't make much sense for most provider users.
// TOTP uses 2x requests; one with payloads `username=` and `password=`,
// (this returns a payload including: 'NeedTFA=1')
// followed by a 2nd request with payloads:
// `username=`, `tfa-challenge=<firsts response ticket>`, `password=totp:######`,
// and header: `CSRFPreventionToken: <first response CSRF>`
// Ticket generated lasts for ~2hours (to verify)
if creds.OTP != "" {
authRequest = fmt.Sprintf("%s&otp=%s", authRequest, url.QueryEscape(creds.OTP))
}
return &userAuthenticator{
conn: conn,
authRequest: authRequest,
}
}
func (t *userAuthenticator) authenticate(ctx context.Context) (*AuthenticationResponseData, error) {
t.mu.Lock()
defer t.mu.Unlock()
if t.authData != nil {
return t.authData, nil
}
req, err := http.NewRequestWithContext(
ctx,
http.MethodPost,
fmt.Sprintf("%s/%s/access/ticket", t.conn.endpoint, basePathJSONAPI),
bytes.NewBufferString(t.authRequest),
)
if err != nil {
return nil, fmt.Errorf("failed to create authentication request: %w", err)
}
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
tflog.Debug(ctx, "Sending authentication request", map[string]interface{}{
"path": req.URL.Path,
})
//nolint:bodyclose
res, err := t.conn.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to retrieve authentication response: %w", err)
}
defer utils.CloseOrLogError(ctx)(res.Body)
err = validateResponseCode(res)
if err != nil {
return nil, fmt.Errorf("failed to authenticate: %w", err)
}
resBody := AuthenticationResponseBody{}
err = json.NewDecoder(res.Body).Decode(&resBody)
if err != nil {
return nil, fmt.Errorf("failed to decode authentication response, %w", err)
}
if resBody.Data == nil {
return nil, errors.New("the server did not include a data object in the authentication response")
}
if resBody.Data.CSRFPreventionToken == nil {
return nil, errors.New(
"the server did not include a CSRF prevention token in the authentication response",
)
}
if resBody.Data.Ticket == nil {
return nil, errors.New("the server did not include a ticket in the authentication response")
}
if resBody.Data.Username == "" {
return nil, errors.New("the server did not include the username in the authentication response")
}
t.authData = resBody.Data
return resBody.Data, nil
}
func (t *userAuthenticator) IsRoot() bool {
return t.authData != nil && t.authData.Username == rootUsername
}
func (t *userAuthenticator) IsRootTicket() bool {
return t.IsRoot()
}
// AuthenticateRequest adds authentication data to a new request.
func (t *userAuthenticator) AuthenticateRequest(ctx context.Context, req *http.Request) error {
a, err := t.authenticate(ctx)
if err != nil {
return fmt.Errorf("failed to authenticate: %w", err)
}
req.AddCookie(&http.Cookie{
HttpOnly: true,
Name: "PVEAuthCookie",
Secure: true,
Value: *a.Ticket,
})
if req.Method != http.MethodGet {
req.Header.Add("CSRFPreventionToken", *a.CSRFPreventionToken)
}
return nil
}

View File

@ -42,22 +42,20 @@ func providerConfigure(_ context.Context, d *schema.ResourceData) (interface{},
var sshClient ssh.Client
var creds *api.Credentials
var creds api.Credentials
var conn *api.Connection
// Check environment variables
apiToken := utils.GetAnyStringEnv("PROXMOX_VE_API_TOKEN", "PM_VE_API_TOKEN")
endpoint := utils.GetAnyStringEnv("PROXMOX_VE_ENDPOINT", "PM_VE_ENDPOINT")
insecure := utils.GetAnyBoolEnv("PROXMOX_VE_INSECURE", "PM_VE_INSECURE")
minTLS := utils.GetAnyStringEnv("PROXMOX_VE_MIN_TLS", "PM_VE_MIN_TLS")
authTicket := utils.GetAnyStringEnv("PROXMOX_VE_AUTH_TICKET", "PM_VE_AUTH_TICKET")
csrfPreventionToken := utils.GetAnyStringEnv("PROXMOX_VE_CSRF_PREVENTION_TOKEN", "PM_VE_CSRF_PREVENTION_TOKEN")
apiToken := utils.GetAnyStringEnv("PROXMOX_VE_API_TOKEN", "PM_VE_API_TOKEN")
otp := utils.GetAnyStringEnv("PROXMOX_VE_OTP", "PM_VE_OTP")
username := utils.GetAnyStringEnv("PROXMOX_VE_USERNAME", "PM_VE_USERNAME")
password := utils.GetAnyStringEnv("PROXMOX_VE_PASSWORD", "PM_VE_PASSWORD")
otp := utils.GetAnyStringEnv("PROXMOX_VE_OTP", "PM_VE_OTP")
if v, ok := d.GetOk(mkProviderAPIToken); ok {
apiToken = v.(string)
}
if v, ok := d.GetOk(mkProviderEndpoint); ok {
endpoint = v.(string)
@ -71,6 +69,22 @@ func providerConfigure(_ context.Context, d *schema.ResourceData) (interface{},
minTLS = v.(string)
}
if v, ok := d.GetOk(mkProviderAuthTicket); ok {
authTicket = v.(string)
}
if v, ok := d.GetOk(mkProviderCSRFPreventionToken); ok {
csrfPreventionToken = v.(string)
}
if v, ok := d.GetOk(mkProviderAPIToken); ok {
apiToken = v.(string)
}
if v, ok := d.GetOk(mkProviderOTP); ok {
otp = v.(string)
}
if v, ok := d.GetOk(mkProviderUsername); ok {
username = v.(string)
}
@ -79,11 +93,7 @@ func providerConfigure(_ context.Context, d *schema.ResourceData) (interface{},
password = v.(string)
}
if v, ok := d.GetOk(mkProviderOTP); ok {
otp = v.(string)
}
creds, err = api.NewCredentials(username, password, otp, apiToken)
creds, err = api.NewCredentials(username, password, otp, apiToken, authTicket, csrfPreventionToken)
diags = append(diags, diag.FromErr(err)...)
conn, err = api.NewConnection(endpoint, insecure, minTLS)
@ -117,18 +127,24 @@ func providerConfigure(_ context.Context, d *schema.ResourceData) (interface{},
sshSocks5Password := utils.GetAnyStringEnv("PROXMOX_VE_SSH_SOCKS5_PASSWORD")
if v, ok := sshConf[mkProviderSSHUsername]; !ok || v.(string) == "" {
if sshUsername != "" {
switch {
case sshUsername != "":
sshConf[mkProviderSSHUsername] = sshUsername
} else {
sshConf[mkProviderSSHUsername] = strings.Split(creds.Username, "@")[0]
case creds.UserCredentials != nil:
sshConf[mkProviderSSHUsername] = strings.Split(creds.UserCredentials.Username, "@")[0]
default:
sshConf[mkProviderSSHUsername] = ""
}
}
if v, ok := sshConf[mkProviderSSHPassword]; !ok || v.(string) == "" {
if sshPassword != "" {
switch {
case sshPassword != "":
sshConf[mkProviderSSHPassword] = sshPassword
} else {
sshConf[mkProviderSSHPassword] = creds.Password
case creds.UserCredentials != nil:
sshConf[mkProviderSSHPassword] = creds.UserCredentials.Password
default:
sshConf[mkProviderSSHPassword] = ""
}
}

View File

@ -31,21 +31,25 @@ func TestProviderSchema(t *testing.T) {
s := ProxmoxVirtualEnvironment().Schema
test.AssertOptionalArguments(t, s, []string{
mkProviderUsername,
mkProviderPassword,
mkProviderEndpoint,
mkProviderInsecure,
mkProviderMinTLS,
mkProviderAuthTicket,
mkProviderCSRFPreventionToken,
mkProviderOTP,
mkProviderUsername,
mkProviderPassword,
})
test.AssertValueTypes(t, s, map[string]schema.ValueType{
mkProviderUsername: schema.TypeString,
mkProviderPassword: schema.TypeString,
mkProviderEndpoint: schema.TypeString,
mkProviderInsecure: schema.TypeBool,
mkProviderMinTLS: schema.TypeString,
mkProviderOTP: schema.TypeString,
mkProviderEndpoint: schema.TypeString,
mkProviderInsecure: schema.TypeBool,
mkProviderMinTLS: schema.TypeString,
mkProviderAuthTicket: schema.TypeString,
mkProviderCSRFPreventionToken: schema.TypeString,
mkProviderOTP: schema.TypeString,
mkProviderUsername: schema.TypeString,
mkProviderPassword: schema.TypeString,
})
providerSSHSchema := test.AssertNestedSchemaExistence(t, s, mkProviderSSH)

View File

@ -8,30 +8,31 @@ package provider
import (
"os"
"regexp"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation"
)
const (
mkProviderEndpoint = "endpoint"
mkProviderInsecure = "insecure"
mkProviderMinTLS = "min_tls"
mkProviderOTP = "otp"
mkProviderPassword = "password"
mkProviderUsername = "username"
mkProviderAPIToken = "api_token"
mkProviderTmpDir = "tmp_dir"
mkProviderSSH = "ssh"
mkProviderSSHUsername = "username"
mkProviderSSHPassword = "password"
mkProviderSSHAgent = "agent"
mkProviderSSHAgentSocket = "agent_socket"
mkProviderSSHPrivateKey = "private_key"
mkProviderSSHSocks5Server = "socks5_server"
mkProviderSSHSocks5Username = "socks5_username"
mkProviderSSHSocks5Password = "socks5_password"
mkProviderEndpoint = "endpoint"
mkProviderInsecure = "insecure"
mkProviderMinTLS = "min_tls"
mkProviderAuthTicket = "auth_ticket"
mkProviderCSRFPreventionToken = "csrf_prevention_token" // #nosec G101
mkProviderAPIToken = "api_token"
mkProviderOTP = "otp"
mkProviderPassword = "password"
mkProviderUsername = "username"
mkProviderTmpDir = "tmp_dir"
mkProviderSSH = "ssh"
mkProviderSSHUsername = "username"
mkProviderSSHPassword = "password"
mkProviderSSHAgent = "agent"
mkProviderSSHAgentSocket = "agent_socket"
mkProviderSSHPrivateKey = "private_key"
mkProviderSSHSocks5Server = "socks5_server"
mkProviderSSHSocks5Username = "socks5_username"
mkProviderSSHSocks5Password = "socks5_password"
mkProviderSSHNode = "node"
mkProviderSSHNodeName = "name"
@ -58,6 +59,27 @@ func createSchema() map[string]*schema.Schema {
Description: "The minimum required TLS version for API calls." +
"Supported values: `1.0|1.1|1.2|1.3`. Defaults to `1.3`.",
},
mkProviderAuthTicket: {
Type: schema.TypeString,
Optional: true,
Sensitive: true,
Description: "The pre-authenticated Ticket for the Proxmox VE API.",
ValidateFunc: validation.StringIsNotEmpty,
},
mkProviderCSRFPreventionToken: {
Type: schema.TypeString,
Optional: true,
Sensitive: true,
Description: "The pre-authenticated CSRF Prevention Token for the Proxmox VE API.",
ValidateFunc: validation.StringIsNotEmpty,
},
mkProviderAPIToken: {
Type: schema.TypeString,
Optional: true,
Sensitive: true,
Description: "The API token for the Proxmox VE API.",
ValidateFunc: validation.StringIsNotEmpty,
},
mkProviderOTP: {
Type: schema.TypeString,
Optional: true,
@ -65,28 +87,18 @@ func createSchema() map[string]*schema.Schema {
Deprecated: "The `otp` attribute is deprecated and will be removed in a future release. " +
"Please use the `api_token` attribute instead.",
},
mkProviderPassword: {
Type: schema.TypeString,
Optional: true,
Sensitive: true,
Description: "The password for the Proxmox VE API.",
ValidateFunc: validation.StringIsNotEmpty,
},
mkProviderUsername: {
Type: schema.TypeString,
Optional: true,
Description: "The username for the Proxmox VE API.",
ValidateFunc: validation.StringIsNotEmpty,
},
mkProviderAPIToken: {
Type: schema.TypeString,
Optional: true,
Sensitive: true,
Description: "The API token for the Proxmox VE API.",
ValidateDiagFunc: validation.ToDiagFunc(validation.StringMatch(
regexp.MustCompile(`^\S+@\S+!\S+=([a-zA-Z0-9-]+)$`),
"Must be a valid API token, e.g. 'USER@REALM!TOKENID=UUID'",
)),
mkProviderPassword: {
Type: schema.TypeString,
Optional: true,
Sensitive: true,
Description: "The password for the Proxmox VE API.",
ValidateFunc: validation.StringIsNotEmpty,
},
mkProviderSSH: {
Type: schema.TypeList,

View File

@ -886,7 +886,6 @@ func readURL(
if httpLastModified != "" {
var timeParsed time.Time
timeParsed, err = time.Parse(time.RFC1123, httpLastModified)
if err != nil {
timeParsed, err = time.Parse(time.RFC1123Z, httpLastModified)
if err != nil {

View File

@ -23,7 +23,7 @@ import (
// to ensure the documentation is formatted properly.
//go:generate terraform fmt -recursive ../examples/
// Generate documentation.
//go:generate go run github.com/hashicorp/terraform-plugin-docs/cmd/tfplugindocs generate --provider-dir ../ --rendered-website-dir ./build/docs-gen
//go:generate go run github.com/hashicorp/terraform-plugin-docs/cmd/tfplugindocs generate --provider-dir ../ --rendered-website-dir ./build/docs-gen --provider-name "terraform-provider-proxmox" --rendered-provider-name "terraform-provider-proxmox" //nolint:lll
// Temporary: while migrating to the TF framework, we need to copy the generated docs to the right place
// for the resources / data sources that have been migrated.