diff options
Diffstat (limited to 'mod')
-rw-r--r-- | mod/mod-ci-github.cxx | 397 | ||||
-rw-r--r-- | mod/mod-ci-github.hxx | 8 |
2 files changed, 380 insertions, 25 deletions
diff --git a/mod/mod-ci-github.cxx b/mod/mod-ci-github.cxx index b31c144..aee2235 100644 --- a/mod/mod-ci-github.cxx +++ b/mod/mod-ci-github.cxx @@ -3,19 +3,132 @@ #include <mod/mod-ci-github.hxx> -//#include <libbutl/manifest-parser.hxx> -#include <libbutl/manifest-serializer.hxx> +#include <libbutl/json/parser.hxx> #include <mod/module-options.hxx> +#include <iostream> + +// @@ TODO +// +// Building CI checks with a GitHub App +// https://docs.github.com/en/apps/creating-github-apps/writing-code-for-a-github-app/building-ci-checks-with-a-github-app +// + +// @@ TODO Best practices +// +// Webhooks: +// https://docs.github.com/en/webhooks/using-webhooks/best-practices-for-using-webhooks +// +// REST API: +// https://docs.github.com/en/rest/using-the-rest-api/best-practices-for-using-the-rest-api?apiVersion=2022-11-28 +// +// Creating an App: +// https://docs.github.com/en/apps/creating-github-apps/about-creating-github-apps/best-practices-for-creating-a-github-app +// +// Use a webhook secret to ensure request is coming from Github. HMAC: +// https://en.wikipedia.org/wiki/HMAC#Definition. A suitable implementation +// is provided by OpenSSL. + +// @@ Authenticating to use the API +// +// There are three types of authentication: +// +// 1) Authenticating as an app. Used to access parts of the API concerning +// the app itself such as getting the list of installations. (Need to +// authenticate as an app as part of authenticating as an app +// installation.) +// +// 2) Authenticating as an app installation (on a user or organisation +// account). Used to access resources belonging to the user/repository +// or organisation the app is installed in. +// +// 3) Authenticating as a user. Used to perform actions as the user. +// +// We need to authenticate as an app installation (2). +// +// How to authenticate as an app installation +// +// Reference: +// https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/authenticating-as-a-github-app-installation +// +// The final authentication token we need is an installation access token +// (IAT), valid for one hour, which we will pass in the `Authentication` +// header of our Github API requests: +// +// Authorization: Bearer <INSTALLATION_ACCESS_TOKEN> +// +// To generate an IAT: +// +// - Generate a JSON Web Token (JWT) +// +// The inputs are (one of) the application's private key(s) and the +// application ID, which goes into the "issuer" JWT field. Also the +// token's issued time and expiration time (10 minutes max). +// +// The best library for us seems to be libjwt at +// https://github.com/benmcollins/libjwt which is also widely packaged +// (most Linux distros and Homebrew). +// +// Doing it ourselves: +// +// Github requires the RS256 algorithm, which is RSA signing using +// SHA256. +// +// The message consists of a base64url-encoded JSON header and +// payload. (base64url differs from base64 by having different 62nd and +// 63rd alphabet characters (- and _ instead of ~ and . to make it +// filesystem-safe) and no padding because the padding character '=' is +// unsafe in URIs.) +// +// Header: +// +// { +// "alg": "RS256", +// "typ": "JWT" +// } +// +// Payload: +// +// { +// "iat": 1234567, +// "exp": 1234577, +// "iss": "<APP_ID>" +// } +// +// Where: +// iat := Issued At (NumericDate) +// exp := Expires At (NumericDate) +// iss := Issuer +// +// Signature: +// +// RSA_SHA256(PKEY, base64url($header) + "." + base64url($payload)) +// +// JWT: +// +// base64url($header) + "." + +// base64url($payload) + "." + +// base64url($signature) +// +// - Get the installation ID. This will be included in the webhook request +// in our case +// +// - Send a POST to /app/installations/<INSTALLATION_ID>/access_tokens +// which includes the JWT (`Authorization: Bearer <JWT>`). The response +// will include the IAT. Can pass the name of the repository included in +// the webhook request to restrict access, otherwise we get access to all +// repos covered by the installation if installed on an organisation for +// example. + using namespace std; using namespace butl; using namespace web; using namespace brep::cli; -brep::ci_github:: -ci_github (const ci_github& r) - : handler (r) +brep::ci_github::ci_github (const ci_github& r) + : handler (r), + options_ (r.initialized_ ? r.options_ : nullptr) { } @@ -27,35 +140,269 @@ init (scanner& s) } bool brep::ci_github:: -handle (request& /*rq*/, response& rs) +respond (response& rs, status_code status, const string& message) { - using namespace bpkg; - using namespace xhtml; + ostream& os (rs.content (status, "text/manifest;charset=utf-8")); - // using parser = manifest_parser; - // using parsing = manifest_parsing; - using serializer = manifest_serializer; - // using serialization = manifest_serialization; + os << message; + + return true; +} + +bool brep::ci_github:: +handle (request& rq, response& rs) +{ + using namespace bpkg; HANDLER_DIAG; - string request_id; // Will be set later. - auto respond_manifest = [&rs, &request_id] (status_code status, - const string& message) -> bool + // @@ TODO + if (false) + return respond (rs, 404, "CI request submission disabled"); + + enum { unknown, webhook } rq_type (unknown); // Request type. + + const std::optional<std::string>* ghe (nullptr); // Github event. + + // Determine the message type. + // + for (const name_value& h: rq.headers ()) { - serializer s (rs.content (status, "text/manifest;charset=utf-8"), - "response"); + if (icasecmp (h.name, "x-github-delivery") == 0) + { + // @@ TODO Check that delivery UUID has not been received before (replay + // attack). + } + else if (icasecmp (h.name, "content-type") == 0) + { + // @@ TODO Safe to assume an empty content-type would have been rejected + // already? + // + if (icasecmp (*h.value, "application/json") != 0) + return respond (rs, 400, "invalid content-type: " + *h.value); + } + else if (icasecmp (h.name, "x-github-event") == 0) + { + rq_type = webhook; + ghe = &h.value; + } + } + + switch (rq_type) + { + case webhook: + return handle_webhook (rq, *ghe, rs); + break; + default: + return respond (rs, 400, "unknown request type"); + break; + } +} + +// The "check_suite" object within a check_quite webhook request. +// +struct check_suite_obj +{ + uint64_t id; + string head_branch; + string head_sha; + string before; + string after; +}; + +static ostream& +operator<< (ostream& os, const check_suite_obj& cs) +{ + os << "id: " << cs.id << endl + << "head_branch: " << cs.head_branch << endl + << "head_sha: " << cs.head_sha << endl + << "before: " << cs.before << endl + << "after: " << cs.after << endl; + + return os; +} + +struct repository_obj +{ + string name; + string full_name; + string default_branch; +}; + +static ostream& +operator<< (ostream& os, const repository_obj& rep) +{ + os << "name: " << rep.name << endl + << "full_name: " << rep.full_name << endl + << "default_branch: " << rep.default_branch << endl; - s.next ("", "1"); // Start of manifest. - s.next ("status", to_string (status)); - s.next ("message", message); + return os; +} + +struct check_suite_req +{ + string action; + check_suite_obj check_suite; + repository_obj repository; +}; + +static ostream& +operator<< (ostream& os, const check_suite_req& cs) +{ + os << "action: " << cs.action << endl; + os << "<check_suite>" << endl << cs.check_suite; + os << "<repository>" << endl << cs.repository << endl; + + return os; +} + +static check_suite_obj +parse_check_suite_obj (json::parser& p) +{ + using event = json::event; - if (!request_id.empty ()) - s.next ("reference", request_id); + check_suite_obj r; - s.next ("", ""); // End of manifest. - return true; + // Skip unknown/uninteresting members. + // + while (p.next_expect (event::name, event::end_object)) + { + const string& n (p.name ()); + + if (n == "id") + r.id = p.next_expect_number<uint64_t> (); + else if (n == "head_branch") + r.head_branch = p.next_expect_string (); + else if (n == "head_sha") + r.head_sha = p.next_expect_string (); + else if (n == "before") + r.before = p.next_expect_string (); + else if (n == "after") + r.after = p.next_expect_string (); + else + p.next_expect_value_skip (); + } + + return r; +} + +static repository_obj +parse_repository_obj (json::parser& p) +{ + using event = json::event; + + repository_obj r; + + // Skip unknown/uninteresting members. + // + while (p.next_expect (event::name, event::end_object)) + { + const string& n (p.name ()); + + if (n == "name") + r.name = p.next_expect_string (); + else if (n == "full_name") + r.full_name = p.next_expect_string (); + else if (n == "default_branch") + r.default_branch = p.next_expect_string (); + else + p.next_expect_value_skip (); + } + + return r; +} + +static check_suite_req +parse_check_suite_webhook (json::parser& p) +{ + using event = json::event; + + check_suite_req r; + + r.action = p.next_expect_member_string ("action"); + + // Parse the check_suite object. + // + p.next_expect_name ("check_suite"); + p.next_expect (event::begin_object); + r.check_suite = parse_check_suite_obj (p); + + // Parse the repository object. + // + p.next_expect_name ("repository", true /* skip_unknown */); + p.next_expect (event::begin_object); + r.repository = parse_repository_obj (p); + + return r; +} + +bool +brep::ci_github::handle_webhook (request& rq, + const std::optional<std::string>& ghe, + response& rs) +{ + using event = json::event; + + if (!ghe) + return respond (rs, 400, "empty Github event type"); + + enum class event_type // Github event type. + { + check_suite, + pull_request }; - return respond_manifest (404, "XXX CI request submission disabled"); + optional<event_type> evt; + + if (ghe == "check_suite") + evt = event_type::check_suite; + else if (ghe == "pull_request") + evt = event_type::pull_request; + + if (!evt) + return respond (rs, 400, "unsupported event type: " + *ghe); + + switch (*evt) + { + case event_type::pull_request: + return respond (rs, 501, "pull request events not implemented yet"); + break; + + case event_type::check_suite: + // Parse the structured result JSON. + // + try + { + json::parser p (rq.content (64 * 1024), "check_suite webhook"); + + p.next_expect (event::begin_object); + check_suite_req cs (parse_check_suite_webhook (p)); + + // Note: "GitHub continues to add new event types and new actions to + // existing event types." + // + if (cs.action == "requested") + { + } + else if (cs.action == "rerequested") + { + } + else if (cs.action == "completed") + { + } + else + return respond (rs, 400, "unsupported action: " + cs.action); + + cout << "<check_suite webhook>" << endl << cs << endl; + + return true; + } + catch (const json::invalid_json_input& e) + { + return respond (rs, 400, "malformed JSON in request body"); + } + break; + } + + return false; } diff --git a/mod/mod-ci-github.hxx b/mod/mod-ci-github.hxx index a869878..c29a967 100644 --- a/mod/mod-ci-github.hxx +++ b/mod/mod-ci-github.hxx @@ -35,6 +35,14 @@ namespace brep virtual void init (cli::scanner&); + bool + respond (response&, status_code, const string& message); + + bool + handle_webhook (request&, + const std::optional<std::string>& github_event, + response&); + private: shared_ptr<options::ci> options_; }; |