Luther Monson, Staff Software Engineer at SUSE

Luther Monson

Go by way of PHP · Containers · VMs · Windows · Kubernetes

Logging In to Proxmox From Go: Tickets, CSRF Tokens and API Tokens

Proxmox VE has two ways to authenticate against its API. Here's how go-proxmox handles each one and why the client is now configured entirely with functional options.

5-Minute Read

I just spent a week of evenings on the access side of go-proxmox. Realms, groups, users and roles all landed. Before any of that, I reworked how a client gets its credentials in the first place. Proxmox gives you two different ways to authenticate. They behave differently enough that it’s worth writing down how each one works on the wire and what the client does with it.

Ticket and API token flows

Tickets

This is what the web UI does. You POST /access/ticket with a username and password and get back a ticket and a CSRF token. Add an otp if the user has TFA, and optionally a realm:

type Credentials struct {
	Username string `json:"username"`
	Password string `json:"password"`
	Otp      string `json:"otp,omitempty"` // One-time password for Two-factor authentication.
	Path     string `json:"path,omitempty"`
	Privs    string `json:"privs,omitempty"`
	Realm    string `json:"realm,omitempty"`
}

type Session struct {
	Username            string `json:"username"`
	CSRFPreventionToken string `json:"CSRFPreventionToken,omitempty"`

	// Cap is being returned but not documented in the API docs, likely will get rewritten later with better types
	Cap         map[string]map[string]int `json:"cap,omitempty"`
	ClusterName string                    `json:"clustername,omitempty"`
	Ticket      string                    `json:"ticket,omitempty"`
}

The ticket goes back to Proxmox as a cookie named PVEAuthCookie. The CSRFPreventionToken goes in a header of the same name. Proxmox requires it on every request that writes (POST, PUT, DELETE). A GET with just the cookie works. A POST with just the cookie gets you a 401, which is a fun one to debug the first time. The client sends both on every request, so you never have to think about it.

The thing to know about tickets is that they expire. Proxmox signs them with a lifetime of two hours ($ticket_lifetime = 3600 * 2 in PVE::AccessControl). That’s great for a browser tab and less great for a daemon.

API tokens

Tokens are the better fit for automation. You create one under a user in the UI (Datacenter, Permissions, API Tokens). You get a token ID like root@pam!mytoken and a UUID secret, shown exactly once. There is no login round trip. You send a single header on every request:

Authorization: PVEAPIToken=root@pam!mytoken=<uuid secret>

No CSRF token, no cookie, and no two hour expiry. Tokens can have an expiry date but default to none. The one decision you make when creating one is privilege separation. With privsep on, which is the default, the token only gets the permissions you grant to the token itself. With it off, the token has everything the user has. For a CI job or a provisioning tool I always leave it on and grant exactly what the tool needs.

Picking between the two happens in one place in the client:

func (c *Client) authHeaders(header *http.Header) {
	header.Add("User-Agent", c.userAgent)
	header.Add("Accept", "application/json")
	if c.token != "" {
		header.Add("Authorization", "PVEAPIToken="+c.token)
	} else if c.session != nil {
		header.Add("Cookie", "PVEAuthCookie="+c.session.Ticket)
		header.Add("CSRFPreventionToken", c.session.CSRFPreventionToken)
	}
}

Logging in lazily

When you give the client credentials, it doesn’t log in when you construct it. The first request goes out with no auth, and Proxmox answers 401. Req notices it has credentials but no session. It grabs a ticket and replays the request:

if res.StatusCode == http.StatusUnauthorized || res.StatusCode == http.StatusForbidden {
	if path == (c.baseURL + "/access/ticket") {
		// received an unauthorized while trying to create a session
		return ErrNotAuthorized
	}

	if c.credentials != nil && c.session == nil {
		// credentials passed but no session started, try a login and retry the request
		if _, err := c.Ticket(c.credentials); err != nil {
			return err
		}
		return c.Req(method, path, data, v)
	}
	return ErrNotAuthorized
}

NewClient can’t return an error the way it’s built. I didn’t want a constructor that makes network calls anyway, so lazy login keeps it cheap. The /access/ticket check is there so a bad password returns ErrNotAuthorized instead of recursing.

Read that c.session == nil condition again though. Once a session exists, the client never logs in again. So when the ticket hits its two hour limit, every call after that comes back ErrNotAuthorized. For a CLI that runs for ten seconds that’s irrelevant. For something long running, use an API token until I get renewal sorted out.

Everything is an option now

The other half of this round of work was getting rid of the setters. The client used to have client.Login(user, pass) and client.APIToken(id, secret) that you called after NewClient. That meant there was a window where you had a client that wasn’t configured yet. Now everything goes through functional options:

type Option func(*Client)

func WithCredentials(credentials *Credentials) Option {
	return func(c *Client) {
		c.credentials = credentials
	}
}

func WithAPIToken(tokenID, secret string) Option {
	return func(c *Client) {
		c.token = fmt.Sprintf("%s=%s", tokenID, secret)
	}
}

// WithSession experimental
func WithSession(ticket, CSRFPreventionToken string) Option {
	return func(c *Client) {
		c.session = &Session{
			Ticket:              ticket,
			CSRFPreventionToken: CSRFPreventionToken,
		}
	}
}

WithSession is for when something else already did the login, like a web app that is passing through a user’s ticket. The old Login, APIToken, WithLogins and WithClient are still there. They have // Deprecated: comments pointing at the replacements, so nobody’s build breaks on the next alpha.

A token client with a self-signed lab cert looks like this:

insecureHTTPClient := http.Client{
	Transport: &http.Transport{
		TLSClientConfig: &tls.Config{
			InsecureSkipVerify: true,
		},
	},
}

client := proxmox.NewClient("https://localhost:8006/api2/json",
	proxmox.WithHTTPClient(&insecureHTTPClient),
	proxmox.WithAPIToken("root@pam!mytoken", "somegeneratedapitokenguidefromtheproxmoxui"),
)

version, err := client.Version()
if err != nil {
	panic(err)
}
fmt.Println(version.Release)

Users and their tokens are readable from Go now too. The token list comes back on the user. So the next step is the write side: creating a token from a password-based client and handing it to whatever runs unattended. That’s how I want the integration tests to bootstrap themselves.

Recent Posts

Categories

About

Luther is a 20+ year software veteran specializing in backend APIs and architecting products. Employed at SUSE as the Team Lead for the Containers on Windows Team working on Rancher + Kubernetes