diff --git a/integrations/ovhcloud/manifest.yaml b/integrations/ovhcloud/manifest.yaml index d2de9c7..63e5905 100644 --- a/integrations/ovhcloud/manifest.yaml +++ b/integrations/ovhcloud/manifest.yaml @@ -1,8 +1,8 @@ id: ovhcloud name: OVHcloud -version: 1.1.0 +version: 1.2.0 description: "OVHcloud (API v1) — cloud account and infrastructure visibility, plus VoIP on-call routing: read the account, list public cloud projects, list/read dedicated servers, and hold Riposte's on-call ladder in a telephony call queue so a client number reaches whoever is on duty without any channel on your own PBX. Signed application-key authentication; stdlib-only, no extra Python dependencies. (French vendor.)" -changelog: "1.1.0 — On-call routing: set_oncall_ladder / get_oncall_ladder / list_calls, backed by a VoIP call queue (easyHunting). Riposte publishes the ordered on-call numbers and the carrier rings them in that order, so an inbound client call never crosses your own trunk and spends no simultaneous call on it. Needs the three telephony_* config fields; every other command is unchanged. | 1.0.0 — Initial release: get account, list cloud projects, list/get dedicated servers." +changelog: "1.2.0 — Accept a SERVICE ACCOUNT (OAuth2 client credentials) as well as the historical application keys, because that is what the control panel offers on accounts where application keys are not available. Which scheme is used is decided by what you filled in, not by a mode selector, and an existing instance keeps working untouched. The API base is corrected to /v1 for a service account, since a Bearer token sent to the legacy /1.0 spelling fails in a way that reads as bad credentials. | 1.1.0 — On-call routing: set_oncall_ladder / get_oncall_ladder / list_calls, backed by a VoIP call queue (easyHunting). Riposte publishes the ordered on-call numbers and the carrier rings them in that order, so an inbound client call never crosses your own trunk and spends no simultaneous call on it. Needs the three telephony_* config fields; every other command is unchanged. | 1.0.0 — Initial release: get account, list cloud projects, list/get dedicated servers." category: cloud # Per-instance configuration. Requests are signed (SHA-1) with the application @@ -13,16 +13,32 @@ config_schema: type: string description: "OVH API endpoint" default: "https://eu.api.ovh.com/1.0" + # ── credentials: fill in ONE of the two sets ───────────────────────── + # + # A service account (OAuth2 client credentials) is what the control panel + # offers on accounts where application keys are not available. Filling in + # client_id AND client_secret selects it; anything else falls back to the + # historical signed scheme below. + client_id: + type: string + description: "Service account: client ID (OAuth2 client credentials)" + client_secret: + type: string + description: "Service account: client secret" + x-soar-sensitive: true + oauth_token_url: + type: string + description: "Service account: token endpoint, only if it differs from the region default (EU: https://www.ovh.com/auth/oauth2/token)" application_key: type: string - description: "Application key" + description: "Historical scheme: application key" application_secret: type: string - description: "Application secret" + description: "Historical scheme: application secret" x-soar-sensitive: true consumer_key: type: string - description: "Consumer key" + description: "Historical scheme: consumer key" x-soar-sensitive: true # ── VoIP on-call routing ───────────────────────────────────────────── # @@ -37,10 +53,12 @@ config_schema: telephony_queue_id: type: string description: "Queue id, only needed when the service holds more than one queue" - required: - - application_key - - application_secret - - consumer_key + # NOTHING IS REQUIRED HERE, and that is not laxity: this schema cannot express + # "one set or the other", and marking the historical three required would refuse + # every service-account instance. The scripts refuse an instance with neither, + # naming both sets — an error an operator can act on, where a form that will not + # submit is one they can only fight. + required: [] commands: - id: get_account diff --git a/integrations/ovhcloud/scripts/get_account.py b/integrations/ovhcloud/scripts/get_account.py index d17793b..871421c 100644 --- a/integrations/ovhcloud/scripts/get_account.py +++ b/integrations/ovhcloud/scripts/get_account.py @@ -1,4 +1,4 @@ -import json, os, sys, hashlib, urllib.parse, urllib.request, urllib.error +import json, os, sys, hashlib, time, urllib.parse, urllib.request, urllib.error def _cfg(): @@ -9,32 +9,126 @@ def _inputs(): return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) +# ── authentication ────────────────────────────────────────────────────────── +# +# TWO SCHEMES, because OVHcloud now offers two and an operator does not always get +# to choose. The historical one signs each request with an application key/secret +# plus a consumer key. The newer SERVICE ACCOUNT is OAuth2 client-credentials, and +# it is what the control panel offers when application keys are not available on +# the account. +# +# Which one is in use is decided by what was filled in, not by a mode selector: +# a client_id and a client_secret mean the service account, anything else falls +# back to the signed scheme. One less field to get wrong, and an instance created +# before service accounts existed keeps working untouched. +_TOKEN_HOSTS = { + "eu": "https://www.ovh.com", + "ca": "https://ca.ovh.com", + "us": "https://us.ovhcloud.com", +} +_CACHE = {} + + +def _oauth(cfg): + return bool(str(cfg.get("client_id") or "").strip() and str(cfg.get("client_secret") or "").strip()) + + def _endpoint(cfg): - return (str(cfg.get("endpoint") or "https://eu.api.ovh.com/1.0")).rstrip("/") + """The API base, corrected to match the credentials in use. + + The same routes answer under /1.0 and under /v1; the first is the signed + scheme's historical spelling and the second is the one OAuth2 documents. An + operator should not have to know that, so a service account gets /v1 whatever + was typed — a Bearer token sent to the legacy spelling is the kind of failure + that reads as "bad credentials" and sends somebody to regenerate a perfectly + good secret. + """ + raw = (str(cfg.get("endpoint") or "https://eu.api.ovh.com/1.0")).rstrip("/") + if not _oauth(cfg): + return raw + if raw.endswith("/1.0"): + return raw[:-4] + "/v1" + if raw.endswith("/v1") or raw.endswith("/v2"): + return raw + return raw + "/v1" -def _server_time(cfg): - req = urllib.request.Request(_endpoint(cfg) + "/auth/time", method="GET") +def _token_url(cfg): + explicit = str(cfg.get("oauth_token_url") or "").strip() + if explicit: + return explicit + host = urllib.parse.urlparse(_endpoint(cfg)).netloc + region = host.split(".")[0] if host else "eu" + return _TOKEN_HOSTS.get(region, _TOKEN_HOSTS["eu"]) + "/auth/oauth2/token" + + +def _bearer(cfg): + """The service account's access token. + + CACHED FOR THE LIFE OF THE PROCESS, which is not a micro-optimisation: publishing + an on-call ladder makes a dozen or more API calls, and a token request in front + of each one would triple the traffic and the time — on the path that runs every + time the rota turns. + """ + if _CACHE.get("token"): + return _CACHE["token"] + data = urllib.parse.urlencode({ + "grant_type": "client_credentials", + "client_id": str(cfg.get("client_id", "")).strip(), + "client_secret": str(cfg.get("client_secret", "")).strip(), + # The scope the service account was granted in the control panel is what + # actually bounds it; "all" here asks for everything that account may do. + # Narrow the SERVICE ACCOUNT's own IAM policy rather than this value. + "scope": "all", + }).encode("utf-8") + req = urllib.request.Request( + _token_url(cfg), data=data, method="POST", + headers={"Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json"}) with urllib.request.urlopen(req, timeout=30) as r: - return r.read().decode("utf-8", "replace").strip() + payload = json.loads(r.read() or b"{}") + token = payload.get("access_token") + if not token: + raise Exception("the service account returned no access token: " + json.dumps(payload)[:200]) + _CACHE["token"] = token + return token + + +def _time_offset(cfg): + """Seconds between this clock and OVH's, read once. + + The signature carries a timestamp and OVH refuses one that has drifted, so the + server's own time is the safe source. Read ONCE and applied as an offset rather + than fetched per request: the ladder makes many calls, and an extra round trip + in front of each was doubling them. + """ + if "offset" not in _CACHE: + req = urllib.request.Request(_endpoint(cfg) + "/auth/time", method="GET") + with urllib.request.urlopen(req, timeout=30) as r: + server = int(r.read().decode("utf-8", "replace").strip()) + _CACHE["offset"] = server - int(time.time()) + return _CACHE["offset"] def request(method, path, cfg, body=None): url = _endpoint(cfg) + path - app_key = str(cfg.get("application_key", "")) - app_secret = str(cfg.get("application_secret", "")) - consumer = str(cfg.get("consumer_key", "")) - ts = _server_time(cfg) body_str = json.dumps(body) if body is not None else "" - to_sign = app_secret + "+" + consumer + "+" + method.upper() + "+" + url + "+" + body_str + "+" + ts - signature = "$1$" + hashlib.sha1(to_sign.encode("utf-8")).hexdigest() - headers = { - "X-Ovh-Application": app_key, - "X-Ovh-Consumer": consumer, - "X-Ovh-Timestamp": ts, - "X-Ovh-Signature": signature, - "Accept": "application/json", - } + headers = {"Accept": "application/json"} + if _oauth(cfg): + headers["Authorization"] = "Bearer " + _bearer(cfg) + else: + app_key = str(cfg.get("application_key", "")).strip() + app_secret = str(cfg.get("application_secret", "")).strip() + consumer = str(cfg.get("consumer_key", "")).strip() + if not (app_key and app_secret and consumer): + raise Exception( + "this instance has no credentials: fill in either a service account " + "(client_id + client_secret) or the application key, secret and consumer key") + ts = str(int(time.time()) + _time_offset(cfg)) + to_sign = app_secret + "+" + consumer + "+" + method.upper() + "+" + url + "+" + body_str + "+" + ts + headers["X-Ovh-Application"] = app_key + headers["X-Ovh-Consumer"] = consumer + headers["X-Ovh-Timestamp"] = ts + headers["X-Ovh-Signature"] = "$1$" + hashlib.sha1(to_sign.encode("utf-8")).hexdigest() data = None if body is not None: data = body_str.encode("utf-8") diff --git a/integrations/ovhcloud/scripts/get_dedicated_server.py b/integrations/ovhcloud/scripts/get_dedicated_server.py index 42f2af0..c0c6b9c 100644 --- a/integrations/ovhcloud/scripts/get_dedicated_server.py +++ b/integrations/ovhcloud/scripts/get_dedicated_server.py @@ -1,4 +1,4 @@ -import json, os, sys, hashlib, urllib.parse, urllib.request, urllib.error +import json, os, sys, hashlib, time, urllib.parse, urllib.request, urllib.error def _cfg(): @@ -9,32 +9,126 @@ def _inputs(): return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) +# ── authentication ────────────────────────────────────────────────────────── +# +# TWO SCHEMES, because OVHcloud now offers two and an operator does not always get +# to choose. The historical one signs each request with an application key/secret +# plus a consumer key. The newer SERVICE ACCOUNT is OAuth2 client-credentials, and +# it is what the control panel offers when application keys are not available on +# the account. +# +# Which one is in use is decided by what was filled in, not by a mode selector: +# a client_id and a client_secret mean the service account, anything else falls +# back to the signed scheme. One less field to get wrong, and an instance created +# before service accounts existed keeps working untouched. +_TOKEN_HOSTS = { + "eu": "https://www.ovh.com", + "ca": "https://ca.ovh.com", + "us": "https://us.ovhcloud.com", +} +_CACHE = {} + + +def _oauth(cfg): + return bool(str(cfg.get("client_id") or "").strip() and str(cfg.get("client_secret") or "").strip()) + + def _endpoint(cfg): - return (str(cfg.get("endpoint") or "https://eu.api.ovh.com/1.0")).rstrip("/") + """The API base, corrected to match the credentials in use. + + The same routes answer under /1.0 and under /v1; the first is the signed + scheme's historical spelling and the second is the one OAuth2 documents. An + operator should not have to know that, so a service account gets /v1 whatever + was typed — a Bearer token sent to the legacy spelling is the kind of failure + that reads as "bad credentials" and sends somebody to regenerate a perfectly + good secret. + """ + raw = (str(cfg.get("endpoint") or "https://eu.api.ovh.com/1.0")).rstrip("/") + if not _oauth(cfg): + return raw + if raw.endswith("/1.0"): + return raw[:-4] + "/v1" + if raw.endswith("/v1") or raw.endswith("/v2"): + return raw + return raw + "/v1" -def _server_time(cfg): - req = urllib.request.Request(_endpoint(cfg) + "/auth/time", method="GET") +def _token_url(cfg): + explicit = str(cfg.get("oauth_token_url") or "").strip() + if explicit: + return explicit + host = urllib.parse.urlparse(_endpoint(cfg)).netloc + region = host.split(".")[0] if host else "eu" + return _TOKEN_HOSTS.get(region, _TOKEN_HOSTS["eu"]) + "/auth/oauth2/token" + + +def _bearer(cfg): + """The service account's access token. + + CACHED FOR THE LIFE OF THE PROCESS, which is not a micro-optimisation: publishing + an on-call ladder makes a dozen or more API calls, and a token request in front + of each one would triple the traffic and the time — on the path that runs every + time the rota turns. + """ + if _CACHE.get("token"): + return _CACHE["token"] + data = urllib.parse.urlencode({ + "grant_type": "client_credentials", + "client_id": str(cfg.get("client_id", "")).strip(), + "client_secret": str(cfg.get("client_secret", "")).strip(), + # The scope the service account was granted in the control panel is what + # actually bounds it; "all" here asks for everything that account may do. + # Narrow the SERVICE ACCOUNT's own IAM policy rather than this value. + "scope": "all", + }).encode("utf-8") + req = urllib.request.Request( + _token_url(cfg), data=data, method="POST", + headers={"Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json"}) with urllib.request.urlopen(req, timeout=30) as r: - return r.read().decode("utf-8", "replace").strip() + payload = json.loads(r.read() or b"{}") + token = payload.get("access_token") + if not token: + raise Exception("the service account returned no access token: " + json.dumps(payload)[:200]) + _CACHE["token"] = token + return token + + +def _time_offset(cfg): + """Seconds between this clock and OVH's, read once. + + The signature carries a timestamp and OVH refuses one that has drifted, so the + server's own time is the safe source. Read ONCE and applied as an offset rather + than fetched per request: the ladder makes many calls, and an extra round trip + in front of each was doubling them. + """ + if "offset" not in _CACHE: + req = urllib.request.Request(_endpoint(cfg) + "/auth/time", method="GET") + with urllib.request.urlopen(req, timeout=30) as r: + server = int(r.read().decode("utf-8", "replace").strip()) + _CACHE["offset"] = server - int(time.time()) + return _CACHE["offset"] def request(method, path, cfg, body=None): url = _endpoint(cfg) + path - app_key = str(cfg.get("application_key", "")) - app_secret = str(cfg.get("application_secret", "")) - consumer = str(cfg.get("consumer_key", "")) - ts = _server_time(cfg) body_str = json.dumps(body) if body is not None else "" - to_sign = app_secret + "+" + consumer + "+" + method.upper() + "+" + url + "+" + body_str + "+" + ts - signature = "$1$" + hashlib.sha1(to_sign.encode("utf-8")).hexdigest() - headers = { - "X-Ovh-Application": app_key, - "X-Ovh-Consumer": consumer, - "X-Ovh-Timestamp": ts, - "X-Ovh-Signature": signature, - "Accept": "application/json", - } + headers = {"Accept": "application/json"} + if _oauth(cfg): + headers["Authorization"] = "Bearer " + _bearer(cfg) + else: + app_key = str(cfg.get("application_key", "")).strip() + app_secret = str(cfg.get("application_secret", "")).strip() + consumer = str(cfg.get("consumer_key", "")).strip() + if not (app_key and app_secret and consumer): + raise Exception( + "this instance has no credentials: fill in either a service account " + "(client_id + client_secret) or the application key, secret and consumer key") + ts = str(int(time.time()) + _time_offset(cfg)) + to_sign = app_secret + "+" + consumer + "+" + method.upper() + "+" + url + "+" + body_str + "+" + ts + headers["X-Ovh-Application"] = app_key + headers["X-Ovh-Consumer"] = consumer + headers["X-Ovh-Timestamp"] = ts + headers["X-Ovh-Signature"] = "$1$" + hashlib.sha1(to_sign.encode("utf-8")).hexdigest() data = None if body is not None: data = body_str.encode("utf-8") diff --git a/integrations/ovhcloud/scripts/get_oncall_ladder.py b/integrations/ovhcloud/scripts/get_oncall_ladder.py index 5d48401..3f0e1c8 100644 --- a/integrations/ovhcloud/scripts/get_oncall_ladder.py +++ b/integrations/ovhcloud/scripts/get_oncall_ladder.py @@ -1,4 +1,4 @@ -import json, os, sys, hashlib, urllib.parse, urllib.request, urllib.error +import json, os, sys, hashlib, time, urllib.parse, urllib.request, urllib.error def _cfg(): @@ -9,32 +9,126 @@ def _inputs(): return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) +# ── authentication ────────────────────────────────────────────────────────── +# +# TWO SCHEMES, because OVHcloud now offers two and an operator does not always get +# to choose. The historical one signs each request with an application key/secret +# plus a consumer key. The newer SERVICE ACCOUNT is OAuth2 client-credentials, and +# it is what the control panel offers when application keys are not available on +# the account. +# +# Which one is in use is decided by what was filled in, not by a mode selector: +# a client_id and a client_secret mean the service account, anything else falls +# back to the signed scheme. One less field to get wrong, and an instance created +# before service accounts existed keeps working untouched. +_TOKEN_HOSTS = { + "eu": "https://www.ovh.com", + "ca": "https://ca.ovh.com", + "us": "https://us.ovhcloud.com", +} +_CACHE = {} + + +def _oauth(cfg): + return bool(str(cfg.get("client_id") or "").strip() and str(cfg.get("client_secret") or "").strip()) + + def _endpoint(cfg): - return (str(cfg.get("endpoint") or "https://eu.api.ovh.com/1.0")).rstrip("/") + """The API base, corrected to match the credentials in use. + + The same routes answer under /1.0 and under /v1; the first is the signed + scheme's historical spelling and the second is the one OAuth2 documents. An + operator should not have to know that, so a service account gets /v1 whatever + was typed — a Bearer token sent to the legacy spelling is the kind of failure + that reads as "bad credentials" and sends somebody to regenerate a perfectly + good secret. + """ + raw = (str(cfg.get("endpoint") or "https://eu.api.ovh.com/1.0")).rstrip("/") + if not _oauth(cfg): + return raw + if raw.endswith("/1.0"): + return raw[:-4] + "/v1" + if raw.endswith("/v1") or raw.endswith("/v2"): + return raw + return raw + "/v1" -def _server_time(cfg): - req = urllib.request.Request(_endpoint(cfg) + "/auth/time", method="GET") +def _token_url(cfg): + explicit = str(cfg.get("oauth_token_url") or "").strip() + if explicit: + return explicit + host = urllib.parse.urlparse(_endpoint(cfg)).netloc + region = host.split(".")[0] if host else "eu" + return _TOKEN_HOSTS.get(region, _TOKEN_HOSTS["eu"]) + "/auth/oauth2/token" + + +def _bearer(cfg): + """The service account's access token. + + CACHED FOR THE LIFE OF THE PROCESS, which is not a micro-optimisation: publishing + an on-call ladder makes a dozen or more API calls, and a token request in front + of each one would triple the traffic and the time — on the path that runs every + time the rota turns. + """ + if _CACHE.get("token"): + return _CACHE["token"] + data = urllib.parse.urlencode({ + "grant_type": "client_credentials", + "client_id": str(cfg.get("client_id", "")).strip(), + "client_secret": str(cfg.get("client_secret", "")).strip(), + # The scope the service account was granted in the control panel is what + # actually bounds it; "all" here asks for everything that account may do. + # Narrow the SERVICE ACCOUNT's own IAM policy rather than this value. + "scope": "all", + }).encode("utf-8") + req = urllib.request.Request( + _token_url(cfg), data=data, method="POST", + headers={"Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json"}) with urllib.request.urlopen(req, timeout=30) as r: - return r.read().decode("utf-8", "replace").strip() + payload = json.loads(r.read() or b"{}") + token = payload.get("access_token") + if not token: + raise Exception("the service account returned no access token: " + json.dumps(payload)[:200]) + _CACHE["token"] = token + return token + + +def _time_offset(cfg): + """Seconds between this clock and OVH's, read once. + + The signature carries a timestamp and OVH refuses one that has drifted, so the + server's own time is the safe source. Read ONCE and applied as an offset rather + than fetched per request: the ladder makes many calls, and an extra round trip + in front of each was doubling them. + """ + if "offset" not in _CACHE: + req = urllib.request.Request(_endpoint(cfg) + "/auth/time", method="GET") + with urllib.request.urlopen(req, timeout=30) as r: + server = int(r.read().decode("utf-8", "replace").strip()) + _CACHE["offset"] = server - int(time.time()) + return _CACHE["offset"] def request(method, path, cfg, body=None): url = _endpoint(cfg) + path - app_key = str(cfg.get("application_key", "")) - app_secret = str(cfg.get("application_secret", "")) - consumer = str(cfg.get("consumer_key", "")) - ts = _server_time(cfg) body_str = json.dumps(body) if body is not None else "" - to_sign = app_secret + "+" + consumer + "+" + method.upper() + "+" + url + "+" + body_str + "+" + ts - signature = "$1$" + hashlib.sha1(to_sign.encode("utf-8")).hexdigest() - headers = { - "X-Ovh-Application": app_key, - "X-Ovh-Consumer": consumer, - "X-Ovh-Timestamp": ts, - "X-Ovh-Signature": signature, - "Accept": "application/json", - } + headers = {"Accept": "application/json"} + if _oauth(cfg): + headers["Authorization"] = "Bearer " + _bearer(cfg) + else: + app_key = str(cfg.get("application_key", "")).strip() + app_secret = str(cfg.get("application_secret", "")).strip() + consumer = str(cfg.get("consumer_key", "")).strip() + if not (app_key and app_secret and consumer): + raise Exception( + "this instance has no credentials: fill in either a service account " + "(client_id + client_secret) or the application key, secret and consumer key") + ts = str(int(time.time()) + _time_offset(cfg)) + to_sign = app_secret + "+" + consumer + "+" + method.upper() + "+" + url + "+" + body_str + "+" + ts + headers["X-Ovh-Application"] = app_key + headers["X-Ovh-Consumer"] = consumer + headers["X-Ovh-Timestamp"] = ts + headers["X-Ovh-Signature"] = "$1$" + hashlib.sha1(to_sign.encode("utf-8")).hexdigest() data = None if body is not None: data = body_str.encode("utf-8") diff --git a/integrations/ovhcloud/scripts/list_calls.py b/integrations/ovhcloud/scripts/list_calls.py index e1290a0..2b7c643 100644 --- a/integrations/ovhcloud/scripts/list_calls.py +++ b/integrations/ovhcloud/scripts/list_calls.py @@ -1,4 +1,4 @@ -import json, os, sys, hashlib, urllib.parse, urllib.request, urllib.error +import json, os, sys, hashlib, time, urllib.parse, urllib.request, urllib.error def _cfg(): @@ -9,32 +9,126 @@ def _inputs(): return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) +# ── authentication ────────────────────────────────────────────────────────── +# +# TWO SCHEMES, because OVHcloud now offers two and an operator does not always get +# to choose. The historical one signs each request with an application key/secret +# plus a consumer key. The newer SERVICE ACCOUNT is OAuth2 client-credentials, and +# it is what the control panel offers when application keys are not available on +# the account. +# +# Which one is in use is decided by what was filled in, not by a mode selector: +# a client_id and a client_secret mean the service account, anything else falls +# back to the signed scheme. One less field to get wrong, and an instance created +# before service accounts existed keeps working untouched. +_TOKEN_HOSTS = { + "eu": "https://www.ovh.com", + "ca": "https://ca.ovh.com", + "us": "https://us.ovhcloud.com", +} +_CACHE = {} + + +def _oauth(cfg): + return bool(str(cfg.get("client_id") or "").strip() and str(cfg.get("client_secret") or "").strip()) + + def _endpoint(cfg): - return (str(cfg.get("endpoint") or "https://eu.api.ovh.com/1.0")).rstrip("/") + """The API base, corrected to match the credentials in use. + + The same routes answer under /1.0 and under /v1; the first is the signed + scheme's historical spelling and the second is the one OAuth2 documents. An + operator should not have to know that, so a service account gets /v1 whatever + was typed — a Bearer token sent to the legacy spelling is the kind of failure + that reads as "bad credentials" and sends somebody to regenerate a perfectly + good secret. + """ + raw = (str(cfg.get("endpoint") or "https://eu.api.ovh.com/1.0")).rstrip("/") + if not _oauth(cfg): + return raw + if raw.endswith("/1.0"): + return raw[:-4] + "/v1" + if raw.endswith("/v1") or raw.endswith("/v2"): + return raw + return raw + "/v1" -def _server_time(cfg): - req = urllib.request.Request(_endpoint(cfg) + "/auth/time", method="GET") +def _token_url(cfg): + explicit = str(cfg.get("oauth_token_url") or "").strip() + if explicit: + return explicit + host = urllib.parse.urlparse(_endpoint(cfg)).netloc + region = host.split(".")[0] if host else "eu" + return _TOKEN_HOSTS.get(region, _TOKEN_HOSTS["eu"]) + "/auth/oauth2/token" + + +def _bearer(cfg): + """The service account's access token. + + CACHED FOR THE LIFE OF THE PROCESS, which is not a micro-optimisation: publishing + an on-call ladder makes a dozen or more API calls, and a token request in front + of each one would triple the traffic and the time — on the path that runs every + time the rota turns. + """ + if _CACHE.get("token"): + return _CACHE["token"] + data = urllib.parse.urlencode({ + "grant_type": "client_credentials", + "client_id": str(cfg.get("client_id", "")).strip(), + "client_secret": str(cfg.get("client_secret", "")).strip(), + # The scope the service account was granted in the control panel is what + # actually bounds it; "all" here asks for everything that account may do. + # Narrow the SERVICE ACCOUNT's own IAM policy rather than this value. + "scope": "all", + }).encode("utf-8") + req = urllib.request.Request( + _token_url(cfg), data=data, method="POST", + headers={"Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json"}) with urllib.request.urlopen(req, timeout=30) as r: - return r.read().decode("utf-8", "replace").strip() + payload = json.loads(r.read() or b"{}") + token = payload.get("access_token") + if not token: + raise Exception("the service account returned no access token: " + json.dumps(payload)[:200]) + _CACHE["token"] = token + return token + + +def _time_offset(cfg): + """Seconds between this clock and OVH's, read once. + + The signature carries a timestamp and OVH refuses one that has drifted, so the + server's own time is the safe source. Read ONCE and applied as an offset rather + than fetched per request: the ladder makes many calls, and an extra round trip + in front of each was doubling them. + """ + if "offset" not in _CACHE: + req = urllib.request.Request(_endpoint(cfg) + "/auth/time", method="GET") + with urllib.request.urlopen(req, timeout=30) as r: + server = int(r.read().decode("utf-8", "replace").strip()) + _CACHE["offset"] = server - int(time.time()) + return _CACHE["offset"] def request(method, path, cfg, body=None): url = _endpoint(cfg) + path - app_key = str(cfg.get("application_key", "")) - app_secret = str(cfg.get("application_secret", "")) - consumer = str(cfg.get("consumer_key", "")) - ts = _server_time(cfg) body_str = json.dumps(body) if body is not None else "" - to_sign = app_secret + "+" + consumer + "+" + method.upper() + "+" + url + "+" + body_str + "+" + ts - signature = "$1$" + hashlib.sha1(to_sign.encode("utf-8")).hexdigest() - headers = { - "X-Ovh-Application": app_key, - "X-Ovh-Consumer": consumer, - "X-Ovh-Timestamp": ts, - "X-Ovh-Signature": signature, - "Accept": "application/json", - } + headers = {"Accept": "application/json"} + if _oauth(cfg): + headers["Authorization"] = "Bearer " + _bearer(cfg) + else: + app_key = str(cfg.get("application_key", "")).strip() + app_secret = str(cfg.get("application_secret", "")).strip() + consumer = str(cfg.get("consumer_key", "")).strip() + if not (app_key and app_secret and consumer): + raise Exception( + "this instance has no credentials: fill in either a service account " + "(client_id + client_secret) or the application key, secret and consumer key") + ts = str(int(time.time()) + _time_offset(cfg)) + to_sign = app_secret + "+" + consumer + "+" + method.upper() + "+" + url + "+" + body_str + "+" + ts + headers["X-Ovh-Application"] = app_key + headers["X-Ovh-Consumer"] = consumer + headers["X-Ovh-Timestamp"] = ts + headers["X-Ovh-Signature"] = "$1$" + hashlib.sha1(to_sign.encode("utf-8")).hexdigest() data = None if body is not None: data = body_str.encode("utf-8") diff --git a/integrations/ovhcloud/scripts/list_cloud_projects.py b/integrations/ovhcloud/scripts/list_cloud_projects.py index 7c607bc..113b909 100644 --- a/integrations/ovhcloud/scripts/list_cloud_projects.py +++ b/integrations/ovhcloud/scripts/list_cloud_projects.py @@ -1,4 +1,4 @@ -import json, os, sys, hashlib, urllib.parse, urllib.request, urllib.error +import json, os, sys, hashlib, time, urllib.parse, urllib.request, urllib.error def _cfg(): @@ -9,32 +9,126 @@ def _inputs(): return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) +# ── authentication ────────────────────────────────────────────────────────── +# +# TWO SCHEMES, because OVHcloud now offers two and an operator does not always get +# to choose. The historical one signs each request with an application key/secret +# plus a consumer key. The newer SERVICE ACCOUNT is OAuth2 client-credentials, and +# it is what the control panel offers when application keys are not available on +# the account. +# +# Which one is in use is decided by what was filled in, not by a mode selector: +# a client_id and a client_secret mean the service account, anything else falls +# back to the signed scheme. One less field to get wrong, and an instance created +# before service accounts existed keeps working untouched. +_TOKEN_HOSTS = { + "eu": "https://www.ovh.com", + "ca": "https://ca.ovh.com", + "us": "https://us.ovhcloud.com", +} +_CACHE = {} + + +def _oauth(cfg): + return bool(str(cfg.get("client_id") or "").strip() and str(cfg.get("client_secret") or "").strip()) + + def _endpoint(cfg): - return (str(cfg.get("endpoint") or "https://eu.api.ovh.com/1.0")).rstrip("/") + """The API base, corrected to match the credentials in use. + + The same routes answer under /1.0 and under /v1; the first is the signed + scheme's historical spelling and the second is the one OAuth2 documents. An + operator should not have to know that, so a service account gets /v1 whatever + was typed — a Bearer token sent to the legacy spelling is the kind of failure + that reads as "bad credentials" and sends somebody to regenerate a perfectly + good secret. + """ + raw = (str(cfg.get("endpoint") or "https://eu.api.ovh.com/1.0")).rstrip("/") + if not _oauth(cfg): + return raw + if raw.endswith("/1.0"): + return raw[:-4] + "/v1" + if raw.endswith("/v1") or raw.endswith("/v2"): + return raw + return raw + "/v1" -def _server_time(cfg): - req = urllib.request.Request(_endpoint(cfg) + "/auth/time", method="GET") +def _token_url(cfg): + explicit = str(cfg.get("oauth_token_url") or "").strip() + if explicit: + return explicit + host = urllib.parse.urlparse(_endpoint(cfg)).netloc + region = host.split(".")[0] if host else "eu" + return _TOKEN_HOSTS.get(region, _TOKEN_HOSTS["eu"]) + "/auth/oauth2/token" + + +def _bearer(cfg): + """The service account's access token. + + CACHED FOR THE LIFE OF THE PROCESS, which is not a micro-optimisation: publishing + an on-call ladder makes a dozen or more API calls, and a token request in front + of each one would triple the traffic and the time — on the path that runs every + time the rota turns. + """ + if _CACHE.get("token"): + return _CACHE["token"] + data = urllib.parse.urlencode({ + "grant_type": "client_credentials", + "client_id": str(cfg.get("client_id", "")).strip(), + "client_secret": str(cfg.get("client_secret", "")).strip(), + # The scope the service account was granted in the control panel is what + # actually bounds it; "all" here asks for everything that account may do. + # Narrow the SERVICE ACCOUNT's own IAM policy rather than this value. + "scope": "all", + }).encode("utf-8") + req = urllib.request.Request( + _token_url(cfg), data=data, method="POST", + headers={"Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json"}) with urllib.request.urlopen(req, timeout=30) as r: - return r.read().decode("utf-8", "replace").strip() + payload = json.loads(r.read() or b"{}") + token = payload.get("access_token") + if not token: + raise Exception("the service account returned no access token: " + json.dumps(payload)[:200]) + _CACHE["token"] = token + return token + + +def _time_offset(cfg): + """Seconds between this clock and OVH's, read once. + + The signature carries a timestamp and OVH refuses one that has drifted, so the + server's own time is the safe source. Read ONCE and applied as an offset rather + than fetched per request: the ladder makes many calls, and an extra round trip + in front of each was doubling them. + """ + if "offset" not in _CACHE: + req = urllib.request.Request(_endpoint(cfg) + "/auth/time", method="GET") + with urllib.request.urlopen(req, timeout=30) as r: + server = int(r.read().decode("utf-8", "replace").strip()) + _CACHE["offset"] = server - int(time.time()) + return _CACHE["offset"] def request(method, path, cfg, body=None): url = _endpoint(cfg) + path - app_key = str(cfg.get("application_key", "")) - app_secret = str(cfg.get("application_secret", "")) - consumer = str(cfg.get("consumer_key", "")) - ts = _server_time(cfg) body_str = json.dumps(body) if body is not None else "" - to_sign = app_secret + "+" + consumer + "+" + method.upper() + "+" + url + "+" + body_str + "+" + ts - signature = "$1$" + hashlib.sha1(to_sign.encode("utf-8")).hexdigest() - headers = { - "X-Ovh-Application": app_key, - "X-Ovh-Consumer": consumer, - "X-Ovh-Timestamp": ts, - "X-Ovh-Signature": signature, - "Accept": "application/json", - } + headers = {"Accept": "application/json"} + if _oauth(cfg): + headers["Authorization"] = "Bearer " + _bearer(cfg) + else: + app_key = str(cfg.get("application_key", "")).strip() + app_secret = str(cfg.get("application_secret", "")).strip() + consumer = str(cfg.get("consumer_key", "")).strip() + if not (app_key and app_secret and consumer): + raise Exception( + "this instance has no credentials: fill in either a service account " + "(client_id + client_secret) or the application key, secret and consumer key") + ts = str(int(time.time()) + _time_offset(cfg)) + to_sign = app_secret + "+" + consumer + "+" + method.upper() + "+" + url + "+" + body_str + "+" + ts + headers["X-Ovh-Application"] = app_key + headers["X-Ovh-Consumer"] = consumer + headers["X-Ovh-Timestamp"] = ts + headers["X-Ovh-Signature"] = "$1$" + hashlib.sha1(to_sign.encode("utf-8")).hexdigest() data = None if body is not None: data = body_str.encode("utf-8") diff --git a/integrations/ovhcloud/scripts/list_dedicated_servers.py b/integrations/ovhcloud/scripts/list_dedicated_servers.py index 472bde7..c5b33ca 100644 --- a/integrations/ovhcloud/scripts/list_dedicated_servers.py +++ b/integrations/ovhcloud/scripts/list_dedicated_servers.py @@ -1,4 +1,4 @@ -import json, os, sys, hashlib, urllib.parse, urllib.request, urllib.error +import json, os, sys, hashlib, time, urllib.parse, urllib.request, urllib.error def _cfg(): @@ -9,32 +9,126 @@ def _inputs(): return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) +# ── authentication ────────────────────────────────────────────────────────── +# +# TWO SCHEMES, because OVHcloud now offers two and an operator does not always get +# to choose. The historical one signs each request with an application key/secret +# plus a consumer key. The newer SERVICE ACCOUNT is OAuth2 client-credentials, and +# it is what the control panel offers when application keys are not available on +# the account. +# +# Which one is in use is decided by what was filled in, not by a mode selector: +# a client_id and a client_secret mean the service account, anything else falls +# back to the signed scheme. One less field to get wrong, and an instance created +# before service accounts existed keeps working untouched. +_TOKEN_HOSTS = { + "eu": "https://www.ovh.com", + "ca": "https://ca.ovh.com", + "us": "https://us.ovhcloud.com", +} +_CACHE = {} + + +def _oauth(cfg): + return bool(str(cfg.get("client_id") or "").strip() and str(cfg.get("client_secret") or "").strip()) + + def _endpoint(cfg): - return (str(cfg.get("endpoint") or "https://eu.api.ovh.com/1.0")).rstrip("/") + """The API base, corrected to match the credentials in use. + + The same routes answer under /1.0 and under /v1; the first is the signed + scheme's historical spelling and the second is the one OAuth2 documents. An + operator should not have to know that, so a service account gets /v1 whatever + was typed — a Bearer token sent to the legacy spelling is the kind of failure + that reads as "bad credentials" and sends somebody to regenerate a perfectly + good secret. + """ + raw = (str(cfg.get("endpoint") or "https://eu.api.ovh.com/1.0")).rstrip("/") + if not _oauth(cfg): + return raw + if raw.endswith("/1.0"): + return raw[:-4] + "/v1" + if raw.endswith("/v1") or raw.endswith("/v2"): + return raw + return raw + "/v1" -def _server_time(cfg): - req = urllib.request.Request(_endpoint(cfg) + "/auth/time", method="GET") +def _token_url(cfg): + explicit = str(cfg.get("oauth_token_url") or "").strip() + if explicit: + return explicit + host = urllib.parse.urlparse(_endpoint(cfg)).netloc + region = host.split(".")[0] if host else "eu" + return _TOKEN_HOSTS.get(region, _TOKEN_HOSTS["eu"]) + "/auth/oauth2/token" + + +def _bearer(cfg): + """The service account's access token. + + CACHED FOR THE LIFE OF THE PROCESS, which is not a micro-optimisation: publishing + an on-call ladder makes a dozen or more API calls, and a token request in front + of each one would triple the traffic and the time — on the path that runs every + time the rota turns. + """ + if _CACHE.get("token"): + return _CACHE["token"] + data = urllib.parse.urlencode({ + "grant_type": "client_credentials", + "client_id": str(cfg.get("client_id", "")).strip(), + "client_secret": str(cfg.get("client_secret", "")).strip(), + # The scope the service account was granted in the control panel is what + # actually bounds it; "all" here asks for everything that account may do. + # Narrow the SERVICE ACCOUNT's own IAM policy rather than this value. + "scope": "all", + }).encode("utf-8") + req = urllib.request.Request( + _token_url(cfg), data=data, method="POST", + headers={"Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json"}) with urllib.request.urlopen(req, timeout=30) as r: - return r.read().decode("utf-8", "replace").strip() + payload = json.loads(r.read() or b"{}") + token = payload.get("access_token") + if not token: + raise Exception("the service account returned no access token: " + json.dumps(payload)[:200]) + _CACHE["token"] = token + return token + + +def _time_offset(cfg): + """Seconds between this clock and OVH's, read once. + + The signature carries a timestamp and OVH refuses one that has drifted, so the + server's own time is the safe source. Read ONCE and applied as an offset rather + than fetched per request: the ladder makes many calls, and an extra round trip + in front of each was doubling them. + """ + if "offset" not in _CACHE: + req = urllib.request.Request(_endpoint(cfg) + "/auth/time", method="GET") + with urllib.request.urlopen(req, timeout=30) as r: + server = int(r.read().decode("utf-8", "replace").strip()) + _CACHE["offset"] = server - int(time.time()) + return _CACHE["offset"] def request(method, path, cfg, body=None): url = _endpoint(cfg) + path - app_key = str(cfg.get("application_key", "")) - app_secret = str(cfg.get("application_secret", "")) - consumer = str(cfg.get("consumer_key", "")) - ts = _server_time(cfg) body_str = json.dumps(body) if body is not None else "" - to_sign = app_secret + "+" + consumer + "+" + method.upper() + "+" + url + "+" + body_str + "+" + ts - signature = "$1$" + hashlib.sha1(to_sign.encode("utf-8")).hexdigest() - headers = { - "X-Ovh-Application": app_key, - "X-Ovh-Consumer": consumer, - "X-Ovh-Timestamp": ts, - "X-Ovh-Signature": signature, - "Accept": "application/json", - } + headers = {"Accept": "application/json"} + if _oauth(cfg): + headers["Authorization"] = "Bearer " + _bearer(cfg) + else: + app_key = str(cfg.get("application_key", "")).strip() + app_secret = str(cfg.get("application_secret", "")).strip() + consumer = str(cfg.get("consumer_key", "")).strip() + if not (app_key and app_secret and consumer): + raise Exception( + "this instance has no credentials: fill in either a service account " + "(client_id + client_secret) or the application key, secret and consumer key") + ts = str(int(time.time()) + _time_offset(cfg)) + to_sign = app_secret + "+" + consumer + "+" + method.upper() + "+" + url + "+" + body_str + "+" + ts + headers["X-Ovh-Application"] = app_key + headers["X-Ovh-Consumer"] = consumer + headers["X-Ovh-Timestamp"] = ts + headers["X-Ovh-Signature"] = "$1$" + hashlib.sha1(to_sign.encode("utf-8")).hexdigest() data = None if body is not None: data = body_str.encode("utf-8") diff --git a/integrations/ovhcloud/scripts/set_oncall_ladder.py b/integrations/ovhcloud/scripts/set_oncall_ladder.py index 2fb8af9..4d2ab46 100644 --- a/integrations/ovhcloud/scripts/set_oncall_ladder.py +++ b/integrations/ovhcloud/scripts/set_oncall_ladder.py @@ -1,4 +1,4 @@ -import json, os, sys, hashlib, urllib.parse, urllib.request, urllib.error +import json, os, sys, hashlib, time, urllib.parse, urllib.request, urllib.error def _cfg(): @@ -9,32 +9,126 @@ def _inputs(): return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) +# ── authentication ────────────────────────────────────────────────────────── +# +# TWO SCHEMES, because OVHcloud now offers two and an operator does not always get +# to choose. The historical one signs each request with an application key/secret +# plus a consumer key. The newer SERVICE ACCOUNT is OAuth2 client-credentials, and +# it is what the control panel offers when application keys are not available on +# the account. +# +# Which one is in use is decided by what was filled in, not by a mode selector: +# a client_id and a client_secret mean the service account, anything else falls +# back to the signed scheme. One less field to get wrong, and an instance created +# before service accounts existed keeps working untouched. +_TOKEN_HOSTS = { + "eu": "https://www.ovh.com", + "ca": "https://ca.ovh.com", + "us": "https://us.ovhcloud.com", +} +_CACHE = {} + + +def _oauth(cfg): + return bool(str(cfg.get("client_id") or "").strip() and str(cfg.get("client_secret") or "").strip()) + + def _endpoint(cfg): - return (str(cfg.get("endpoint") or "https://eu.api.ovh.com/1.0")).rstrip("/") + """The API base, corrected to match the credentials in use. + + The same routes answer under /1.0 and under /v1; the first is the signed + scheme's historical spelling and the second is the one OAuth2 documents. An + operator should not have to know that, so a service account gets /v1 whatever + was typed — a Bearer token sent to the legacy spelling is the kind of failure + that reads as "bad credentials" and sends somebody to regenerate a perfectly + good secret. + """ + raw = (str(cfg.get("endpoint") or "https://eu.api.ovh.com/1.0")).rstrip("/") + if not _oauth(cfg): + return raw + if raw.endswith("/1.0"): + return raw[:-4] + "/v1" + if raw.endswith("/v1") or raw.endswith("/v2"): + return raw + return raw + "/v1" -def _server_time(cfg): - req = urllib.request.Request(_endpoint(cfg) + "/auth/time", method="GET") +def _token_url(cfg): + explicit = str(cfg.get("oauth_token_url") or "").strip() + if explicit: + return explicit + host = urllib.parse.urlparse(_endpoint(cfg)).netloc + region = host.split(".")[0] if host else "eu" + return _TOKEN_HOSTS.get(region, _TOKEN_HOSTS["eu"]) + "/auth/oauth2/token" + + +def _bearer(cfg): + """The service account's access token. + + CACHED FOR THE LIFE OF THE PROCESS, which is not a micro-optimisation: publishing + an on-call ladder makes a dozen or more API calls, and a token request in front + of each one would triple the traffic and the time — on the path that runs every + time the rota turns. + """ + if _CACHE.get("token"): + return _CACHE["token"] + data = urllib.parse.urlencode({ + "grant_type": "client_credentials", + "client_id": str(cfg.get("client_id", "")).strip(), + "client_secret": str(cfg.get("client_secret", "")).strip(), + # The scope the service account was granted in the control panel is what + # actually bounds it; "all" here asks for everything that account may do. + # Narrow the SERVICE ACCOUNT's own IAM policy rather than this value. + "scope": "all", + }).encode("utf-8") + req = urllib.request.Request( + _token_url(cfg), data=data, method="POST", + headers={"Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json"}) with urllib.request.urlopen(req, timeout=30) as r: - return r.read().decode("utf-8", "replace").strip() + payload = json.loads(r.read() or b"{}") + token = payload.get("access_token") + if not token: + raise Exception("the service account returned no access token: " + json.dumps(payload)[:200]) + _CACHE["token"] = token + return token + + +def _time_offset(cfg): + """Seconds between this clock and OVH's, read once. + + The signature carries a timestamp and OVH refuses one that has drifted, so the + server's own time is the safe source. Read ONCE and applied as an offset rather + than fetched per request: the ladder makes many calls, and an extra round trip + in front of each was doubling them. + """ + if "offset" not in _CACHE: + req = urllib.request.Request(_endpoint(cfg) + "/auth/time", method="GET") + with urllib.request.urlopen(req, timeout=30) as r: + server = int(r.read().decode("utf-8", "replace").strip()) + _CACHE["offset"] = server - int(time.time()) + return _CACHE["offset"] def request(method, path, cfg, body=None): url = _endpoint(cfg) + path - app_key = str(cfg.get("application_key", "")) - app_secret = str(cfg.get("application_secret", "")) - consumer = str(cfg.get("consumer_key", "")) - ts = _server_time(cfg) body_str = json.dumps(body) if body is not None else "" - to_sign = app_secret + "+" + consumer + "+" + method.upper() + "+" + url + "+" + body_str + "+" + ts - signature = "$1$" + hashlib.sha1(to_sign.encode("utf-8")).hexdigest() - headers = { - "X-Ovh-Application": app_key, - "X-Ovh-Consumer": consumer, - "X-Ovh-Timestamp": ts, - "X-Ovh-Signature": signature, - "Accept": "application/json", - } + headers = {"Accept": "application/json"} + if _oauth(cfg): + headers["Authorization"] = "Bearer " + _bearer(cfg) + else: + app_key = str(cfg.get("application_key", "")).strip() + app_secret = str(cfg.get("application_secret", "")).strip() + consumer = str(cfg.get("consumer_key", "")).strip() + if not (app_key and app_secret and consumer): + raise Exception( + "this instance has no credentials: fill in either a service account " + "(client_id + client_secret) or the application key, secret and consumer key") + ts = str(int(time.time()) + _time_offset(cfg)) + to_sign = app_secret + "+" + consumer + "+" + method.upper() + "+" + url + "+" + body_str + "+" + ts + headers["X-Ovh-Application"] = app_key + headers["X-Ovh-Consumer"] = consumer + headers["X-Ovh-Timestamp"] = ts + headers["X-Ovh-Signature"] = "$1$" + hashlib.sha1(to_sign.encode("utf-8")).hexdigest() data = None if body is not None: data = body_str.encode("utf-8") diff --git a/integrations/ovhcloud/scripts/test_connection.py b/integrations/ovhcloud/scripts/test_connection.py index a873004..f31e58c 100644 --- a/integrations/ovhcloud/scripts/test_connection.py +++ b/integrations/ovhcloud/scripts/test_connection.py @@ -1,4 +1,4 @@ -import json, os, sys, hashlib, urllib.parse, urllib.request, urllib.error +import json, os, sys, hashlib, time, urllib.parse, urllib.request, urllib.error def _cfg(): @@ -9,32 +9,126 @@ def _inputs(): return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) +# ── authentication ────────────────────────────────────────────────────────── +# +# TWO SCHEMES, because OVHcloud now offers two and an operator does not always get +# to choose. The historical one signs each request with an application key/secret +# plus a consumer key. The newer SERVICE ACCOUNT is OAuth2 client-credentials, and +# it is what the control panel offers when application keys are not available on +# the account. +# +# Which one is in use is decided by what was filled in, not by a mode selector: +# a client_id and a client_secret mean the service account, anything else falls +# back to the signed scheme. One less field to get wrong, and an instance created +# before service accounts existed keeps working untouched. +_TOKEN_HOSTS = { + "eu": "https://www.ovh.com", + "ca": "https://ca.ovh.com", + "us": "https://us.ovhcloud.com", +} +_CACHE = {} + + +def _oauth(cfg): + return bool(str(cfg.get("client_id") or "").strip() and str(cfg.get("client_secret") or "").strip()) + + def _endpoint(cfg): - return (str(cfg.get("endpoint") or "https://eu.api.ovh.com/1.0")).rstrip("/") + """The API base, corrected to match the credentials in use. + + The same routes answer under /1.0 and under /v1; the first is the signed + scheme's historical spelling and the second is the one OAuth2 documents. An + operator should not have to know that, so a service account gets /v1 whatever + was typed — a Bearer token sent to the legacy spelling is the kind of failure + that reads as "bad credentials" and sends somebody to regenerate a perfectly + good secret. + """ + raw = (str(cfg.get("endpoint") or "https://eu.api.ovh.com/1.0")).rstrip("/") + if not _oauth(cfg): + return raw + if raw.endswith("/1.0"): + return raw[:-4] + "/v1" + if raw.endswith("/v1") or raw.endswith("/v2"): + return raw + return raw + "/v1" -def _server_time(cfg): - req = urllib.request.Request(_endpoint(cfg) + "/auth/time", method="GET") +def _token_url(cfg): + explicit = str(cfg.get("oauth_token_url") or "").strip() + if explicit: + return explicit + host = urllib.parse.urlparse(_endpoint(cfg)).netloc + region = host.split(".")[0] if host else "eu" + return _TOKEN_HOSTS.get(region, _TOKEN_HOSTS["eu"]) + "/auth/oauth2/token" + + +def _bearer(cfg): + """The service account's access token. + + CACHED FOR THE LIFE OF THE PROCESS, which is not a micro-optimisation: publishing + an on-call ladder makes a dozen or more API calls, and a token request in front + of each one would triple the traffic and the time — on the path that runs every + time the rota turns. + """ + if _CACHE.get("token"): + return _CACHE["token"] + data = urllib.parse.urlencode({ + "grant_type": "client_credentials", + "client_id": str(cfg.get("client_id", "")).strip(), + "client_secret": str(cfg.get("client_secret", "")).strip(), + # The scope the service account was granted in the control panel is what + # actually bounds it; "all" here asks for everything that account may do. + # Narrow the SERVICE ACCOUNT's own IAM policy rather than this value. + "scope": "all", + }).encode("utf-8") + req = urllib.request.Request( + _token_url(cfg), data=data, method="POST", + headers={"Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json"}) with urllib.request.urlopen(req, timeout=30) as r: - return r.read().decode("utf-8", "replace").strip() + payload = json.loads(r.read() or b"{}") + token = payload.get("access_token") + if not token: + raise Exception("the service account returned no access token: " + json.dumps(payload)[:200]) + _CACHE["token"] = token + return token + + +def _time_offset(cfg): + """Seconds between this clock and OVH's, read once. + + The signature carries a timestamp and OVH refuses one that has drifted, so the + server's own time is the safe source. Read ONCE and applied as an offset rather + than fetched per request: the ladder makes many calls, and an extra round trip + in front of each was doubling them. + """ + if "offset" not in _CACHE: + req = urllib.request.Request(_endpoint(cfg) + "/auth/time", method="GET") + with urllib.request.urlopen(req, timeout=30) as r: + server = int(r.read().decode("utf-8", "replace").strip()) + _CACHE["offset"] = server - int(time.time()) + return _CACHE["offset"] def request(method, path, cfg, body=None): url = _endpoint(cfg) + path - app_key = str(cfg.get("application_key", "")) - app_secret = str(cfg.get("application_secret", "")) - consumer = str(cfg.get("consumer_key", "")) - ts = _server_time(cfg) body_str = json.dumps(body) if body is not None else "" - to_sign = app_secret + "+" + consumer + "+" + method.upper() + "+" + url + "+" + body_str + "+" + ts - signature = "$1$" + hashlib.sha1(to_sign.encode("utf-8")).hexdigest() - headers = { - "X-Ovh-Application": app_key, - "X-Ovh-Consumer": consumer, - "X-Ovh-Timestamp": ts, - "X-Ovh-Signature": signature, - "Accept": "application/json", - } + headers = {"Accept": "application/json"} + if _oauth(cfg): + headers["Authorization"] = "Bearer " + _bearer(cfg) + else: + app_key = str(cfg.get("application_key", "")).strip() + app_secret = str(cfg.get("application_secret", "")).strip() + consumer = str(cfg.get("consumer_key", "")).strip() + if not (app_key and app_secret and consumer): + raise Exception( + "this instance has no credentials: fill in either a service account " + "(client_id + client_secret) or the application key, secret and consumer key") + ts = str(int(time.time()) + _time_offset(cfg)) + to_sign = app_secret + "+" + consumer + "+" + method.upper() + "+" + url + "+" + body_str + "+" + ts + headers["X-Ovh-Application"] = app_key + headers["X-Ovh-Consumer"] = consumer + headers["X-Ovh-Timestamp"] = ts + headers["X-Ovh-Signature"] = "$1$" + hashlib.sha1(to_sign.encode("utf-8")).hexdigest() data = None if body is not None: data = body_str.encode("utf-8")