You may want to inform yourself about human rights in China.

On Using OVH's API

date: 2022-08-21
update: 2022-09-27

OVH provides a HTTP(s) API to automatize various tasks. There are multiple official wrappers (Python, PHP, Java, etc.);Β I’ll use here the Go one (static typing, cross-compilation with a single static binary)

All the code presented below can be found in this github repository, containing a small CLI tool, ovh-do, that can be used to access the API from the CLI and sh(1) scripts.

Our end goal here is to automatically re-install the latest Debian image on an existing VPS, with a pre-installed ssh(1) key.


Sketch of a Ribauldequin, a multiple-barrel gun

Sketch of a Ribauldequin, a multiple-barrel gun by Leonardo da Vinci through wikimedia.org – Public domain

General idea

API access

We’ll leave authentication aside for now (this will be covered in the next section. Almost always, the access points will take and return JSON blobs. Additionally, some of them will receive parameters from the URL/route (e.g. <keyName> is a parameter in GET /me/sshKey/<keyName>).

Note: To reduce confusion, will call (API) access points a pair of a HTTP method and a route, e.g. GET /me and POST /me are two different access points.

The Go wrapper being rather thin, there’s a little bit of extra-work to do because of the static typing, because it doesn’t provide struct to type those JSON blobs, so this need to be done manually.

The procedure to use an access point is as follow:

  1. Identify the access points you need on the web console;
  2. When applicable, test them from the browser, to get an idea on what kind of JSON blob is sent/returned (there’s a formal type definition otherwise);
  3. Create a struct to wrap the input and/or output, when applicable: for instance:
    • POST requests have both;
    • but GET or DELETE only have an output;
    • and that output often is void for DELETE;
  4. You can keep that struct partial; it seems reasonable to make the fields public (first capital letter), which requires to use json tags to perform the renaming;
  5. Finally, don’t forget to systematically look for errors along the way.

Example: Here’s a small example on how to access GET /me/api/application/<applicationId>, and how to retrieve the HTTP error code in case of issues:

// https://api.ovh.com/console/#/me/api/application/%7BapplicationId%7D~GET
// TODO: ALPHA API
type GetMeApiApplicationId struct {
	ApplicationId  int    `json:"applicationId"`
	Name           string `json:"name"`
	Description    string `json:"description"`
	Status         string `json:"status"`
	ApplicationKey string `json:"applicationKey"`
}

func test(c *ovh.Client, id string) error {
	var x GetMeApiApplicationId
	if err := c.Get("/me/api/application/"+strconv.Itoa(id), &x); err != nil {
		serr, ok := err.(*ovh.APIError)

		// application IDs referring to web console will 404;
		// just silently ignore those
		if !ok || serr.Code != http.StatusNotFound {
			return err
		}
	} else {
		fmt.Println(x)
	}
}

Foreach

There’s a recurring pattern occurring when accessing lists of objects: there is first an access point returning object IDs, and then an access point returning the data associated with an individual ID. Most of the time, those IDs are strings, but there are exceptions.

Besides, the route naming is (so far) systematic:

It can be convenient to wrap this pattern with a small forEach() iterator, using Go generics:

// XXX generic experimentation; perhaps they are better approaches
// let's see where this goes.
//
// This is a bit clumsy so far, but works.
type Item interface {
	GetMeApiApplicationId | GetVPSName | GetMeSSHKeyName | GetVPSNameImagesAvailableId
}
type ItemId interface{ string | int }

func id[T any](x T) T { return x }

func forEachItem[T Item, U ItemId](c *ovh.Client, r string,
	f func(T) (bool, error), g func(U) string) error {
	var xs []U
	var y T
	if err := c.Get(r, &xs); err != nil {
		return err
	}

	for _, x := range xs {
		if err := c.Get(r+"/"+g(x), &y); err != nil {
			return err
		}
		stop, err := f(y)
		if err != nil {
			return err
		}
		if stop {
			break
		}
	}

	return nil
}

Example: The following lists on stdout all the available applications, using both GET /me/api/application (alpha API) and GET /me/api/application/<applicationId> (alpha API)

// https://api.ovh.com/console/#/me/api/application~GET
// TODO: ALPHA API
type GetMeApiApplication []int

// https://api.ovh.com/console/#/me/api/application/%7BapplicationId%7D~GET
//	Retrieve meta-data associated to an application ID
// TODO: ALPHA API
type GetMeApiApplicationId struct {
	ApplicationId  int    `json:"applicationId"`
	Name           string `json:"name"`
	Description    string `json:"description"`
	Status         string `json:"status"`
	ApplicationKey string `json:"applicationKey"`
}

func lsApps(c *ovh.Client) error {
	return forEachItem(c,
		"/me/api/application",
		func(y GetMeApiApplicationId) (bool, error) {
			fmt.Printf("%s %d %s %s\n", y.Name, y.ApplicationId, y.Status, y.Description)
			return false, nil
		}, strconv.Itoa)
}

Authentication

Main process

Allowing an user to use an application that access the API is a two steps process, generating three “tokens”:

  1. Registering the application; this will generate:
    • An application key;
    • An application secret;
  2. Asking the user the authenticate on an URL; this will generate:
    • A consumer key.

The consumer key can both expire and be restricted in terms of access (e.g. only allowed to send GET requests on /me).

Note: Likely because you need an application to use the API, there is apparently no way to create an app directly from the API.

You can either generate all those three at once via either:

Alternatively, you can create the application key/secret first via api.ovh.com/createApp, and then use the API to generate the consumer key: the wrapper’s README.md contains a rather straightforward section on how to do so.

Sketch of a self propelled cart: two spiral coil springs, located behind the two big horizontal gears are first compressed, they then release a force when uncompressing, which is used to power the vehicule. The force released is smoothed by a balance wheel mechanism.

Sketch of a self propelled cart: two spiral coil springs, located behind the two big horizontal gears are first compressed, they then release a force when uncompressing, which is used to power the vehicule. The force released is smoothed by a balance wheel mechanism. by Leonardo da Vinci through wikimedia.org – Public domain

Consumer key update

While the wrapper can use a few different methods to access the 3 authentication tokens, ovh-do relies on the presence of a $HOME/.ovh.conf, which should contain all those; if the consumer_key is missing or expired/invalid, the app will automatically request for a new one, with full access:

[default]
endpoint=ovh-eu

[ovh-eu]
application_key=aaaaaaaaaaaaaaaa
application_secret=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb
consumer_key=cccccccccccccccccccccccccccccccc

The consumer_key is retrieved by the method described in the wrapper’s README.md; ovh-do then pools until the key has been validated:

// https://api.ovh.com/console/#/me/~GET
// TODO: incomplete
type GetMe struct {
	Email           string `json:"email"`
	Country         string `json:"country"`
	FirstName       string `json:"firstname"`
}

// ...

func isValidated(c *ovh.Client) (bool, error) {
	var y GetMe
	if err := c.Get("/me", &y); err != nil {
		serr, ok := err.(*ovh.APIError)
		if !ok || serr.Code != http.StatusForbidden {
			return false, err
		}
		return false, nil
	}
	return true, nil
}

func poolForValidated(c *ovh.Client) error {
	a := time.Now().Add(poolTimeout)
	for {
		time.Sleep(5 * time.Second)
		if time.Now().After(a) {
			break
		}
		ok, err := isValidated(c)
		if ok {
			return nil
		}
		if err != nil {
			return err
		}
	}
	return fmt.Errorf("Waiting for credential validation timeout")
}

func requestNewKey(c *ovh.Client) (string, error) {
	ck := c.NewCkRequest()
	ck.AddRecursiveRules(ovh.ReadWrite, "/")
	s, err := ck.Do()
	if err != nil {
		return "", err
	}

	// ...

	if err = poolForValidated(c); err != nil {
		return "", err
	}

	return c.ConsumerKey, nil
}

Note: the GET /me access point is used arbitrarily; any (available) authenticated access would do.

Finally, ovh-do will edit $HOME/.ovh.conf to use the new key. This is the reason why it depends on a $HOME/.ovh.conf instead of supporting the same options as the Go wrapper.

Flushing expired credentials

If you choose to prefer to use short-lived credentials, you may find useful to have a way to regularly clean the list of expired credentials:

// https://api.ovh.com/console/#/me/api/credential~GET
// TODO: ALPHA API
type GetMeApiCredential []int

// https://api.ovh.com/console/#/me/api/credential/%7BcredentialId%7D~GET
// TODO: ALPHA API
type GetMeApiCredentialIdRule struct {
	Method         string `json:"method"`
	Path           string `json:"path"`
}
type GetMeApiCredentialId struct {
	// XXX type uncertain
	AllowedIPs    []string                   `json:"allowedIPs"`
	// NOTE: 115/168 seem to correspond to the web console
	ApplicationId int                        `json:"applicationId"`
	Creation      time.Time                  `json:"creation"`
	CredentialId  int                        `json:"credentialId"`
	Expiration    time.Time                  `json:"expiration"`
	LastUse       time.Time                  `json:"lastUse"`
	OvhSupport    bool                       `json:"ovhSupport"`
	Status        string                     `json:"status"`
	Rules         []GetMeApiCredentialIdRule `json:"rules"`
}

// https://api.ovh.com/console/#/me/api/credential/%7BcredentialId%7D~DELETE
// TODO: ALPHA API
type DeleteMeApiCredentialId struct {}

// ...

func flushExpiredCredentials(c *ovh.Client) error {
	var xs GetMeApiCredential
	var d GetMeApiCredentialId
	var e DeleteMeApiCredentialId

	if err := c.Get("/me/api/credential", &xs); err != nil {
		return err
	}
	for _, x := range xs {
		if err := c.Get("/me/api/credential/"+strconv.Itoa(x), &d); err != nil {
			return err
		}
		if d.Status == "expired" {
			if err := c.Delete("/me/api/credential/"+strconv.Itoa(x), &e); err != nil {
				return err
			}
		}
	}

	return nil
}
Sketch of a siege defense mechanism, a “ladder remover”

Sketch of a siege defense mechanism, a “ladder remover” by Leonardo da Vinci through wikimedia.org – Public domain

SSH keys

The VPS reinstallation will be performed in the next section via POST /vps/<vps-id>/rebuild (beta API). This access has an optional sshKey parameter, which can be used automatically register a SSH key in the default user’s $HOME/.ssh/authorized_keys; on a fresh Debian install, this user is called debian.

That parameter doesn’t contain the key itself as a string, but refers to one of the keys registered via POST /me/sshKey.

Exercice: Try to implement a way to list all the available keys using the forEachItem() iterator presented earlier. SSH keys related access points are:

Feel free to peek into the code for a solution.

Note: ovh-do will automatically look for SSH keys in $PATH/.ssh/ so that ovh-do add-key should by default almost always do the Right Thingβ„’.

VPSs

Listing VPSs and available images

As for the SSH keys, this is covered by our forEachItem() iterator. The access points of interests are:

Note: ovh-do contain a few bits to help automatically find the latest image matching a given name/regexp, and some more bits to automatically target the latest Debian.

Rebuilding a VPS

ovh-do will perform re-installation of a VPS using stock images in three steps:

  1. Launch the rebuild;
  2. Pool the related task until completion (or timeout);
  3. Reset known_hosts entries associated to the VPS’s IPs.

Related routes:

Here’s a sample trace of re-installing the latest debian on the given VPS:

% ovh-do rebuild-debian vps-00ac1791.vps.ovh.net
2022/08/25 00:55:16 Installing Debian 11 (62129106-e97f-4ad2-b356-e9854f86a990) to vps-00ac1791.vps.ovh.net
44%
44%
44%
66%
2022/08/25 01:11:06 Warning: ssh-keyscan '2001:4:701:::5555' failed:
2022/08/25 01:11:06 connect (`2001:4:701:::5555'): Network is unreachable
2022/08/25 01:11:06 connect (`2001:4:701:::5555'): Network is unreachable
2022/08/25 01:11:06 connect (`2001:4:701:::5555'): Network is unreachable
2022/08/25 01:11:06 connect (`2001:4:701:::5555'): Network is unreachable
2022/08/25 01:11:06 connect (`2001:4:701:::5555'): Network is unreachable
$ ssh -p 22 debian@10.10.4.1 echo hello, world
hello, world
Sketch of a giant crossbow

Sketch of a giant crossbow by Leonardo da Vinci through wikimedia.org – Public domain


Comments

By email, at mathieu.bivert chez:

email