aboutsummaryrefslogtreecommitdiff
path: root/mod
diff options
context:
space:
mode:
authorFrancois Kritzinger <francois@codesynthesis.com>2024-02-05 09:48:43 +0200
committerFrancois Kritzinger <francois@codesynthesis.com>2024-12-10 10:37:15 +0200
commit1c994eadb89bdafdbeb7e16adcf4f0a55c497942 (patch)
treedafbfb3eb61fe72aebacca2580a3f7f548f32caa /mod
parent5da989c5c240bd0fb6ecea265c0c742f5e58b55e (diff)
Initial setup and handle webhook requests
Diffstat (limited to 'mod')
-rw-r--r--mod/mod-ci-github.cxx392
-rw-r--r--mod/mod-ci-github.hxx43
-rw-r--r--mod/mod-repository-root.cxx18
-rw-r--r--mod/mod-repository-root.hxx2
4 files changed, 454 insertions, 1 deletions
diff --git a/mod/mod-ci-github.cxx b/mod/mod-ci-github.cxx
new file mode 100644
index 0000000..0a28b63
--- /dev/null
+++ b/mod/mod-ci-github.cxx
@@ -0,0 +1,392 @@
+// file : mod/mod-ci-github.cxx -*- C++ -*-
+// license : MIT; see accompanying LICENSE file
+
+#include <mod/mod-ci-github.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),
+ options_ (r.initialized_ ? r.options_ : nullptr)
+{
+}
+
+void brep::ci_github::
+init (scanner& s)
+{
+ options_ = make_shared<options::ci> (
+ s, unknown_mode::fail, unknown_mode::fail);
+}
+
+// The "check_suite" object within a check_quite webhook request.
+//
+struct check_suite
+{
+ uint64_t id;
+ string head_branch;
+ string head_sha;
+ string before;
+ string after;
+
+ explicit
+ check_suite (json::parser&);
+
+ check_suite () = default;
+};
+
+struct repository
+{
+ string name;
+ string full_name;
+ string default_branch;
+
+ explicit repository (json::parser&);
+
+ repository () = default;
+};
+
+struct check_suite_event
+{
+ string action;
+ ::check_suite check_suite;
+ ::repository repository;
+
+ explicit check_suite_event (json::parser&);
+
+ check_suite_event () = default;
+};
+
+static ostream&
+operator<< (ostream&, const check_suite&);
+
+static ostream&
+operator<< (ostream&, const repository&);
+
+static ostream&
+operator<< (ostream&, const check_suite_event&);
+
+bool brep::ci_github::
+handle (request& rq, response& rs)
+{
+ using namespace bpkg;
+
+ HANDLER_DIAG;
+
+ // @@ TODO
+ if (false)
+ throw invalid_request (404, "CI request submission disabled");
+
+ // Process headers.
+ //
+ string event;
+ {
+ bool content_type (false);
+
+ for (const name_value& h: rq.headers ())
+ {
+ 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)
+ {
+ if (!h.value)
+ throw invalid_request (400, "missing content-type value");
+
+ if (icasecmp (*h.value, "application/json") != 0)
+ {
+ throw invalid_request (400,
+ "invalid content-type value: '" + *h.value +
+ '\'');
+ }
+
+ content_type = true;
+ }
+ else if (icasecmp (h.name, "x-github-event") == 0)
+ {
+ if (!h.value)
+ throw invalid_request (400, "missing x-github-event value");
+
+ event = *h.value;
+ }
+ }
+
+ if (!content_type)
+ throw invalid_request (400, "missing content-type header");
+
+ if (event.empty ())
+ throw invalid_request (400, "missing x-github-event header");
+ }
+
+ // There is an event (specified in the x-github-event header) and each event
+ // contains a bunch of actions (specified in the JSON request body).
+ //
+ // Note: "GitHub continues to add new event types and new actions to
+ // existing event types." As a result we ignore known actions that we are
+ // not interested in and log and ignore unknown actions. The thinking here
+ // is that we want be "notified" of new actions at which point we can decide
+ // whether to ignore them or to handle.
+ //
+ try
+ {
+ if (event == "check_suite")
+ {
+ json::parser p (rq.content (64 * 1024), "check_suite webhook");
+
+ check_suite_event cs (p);
+
+ // @@ TODO: log and ignore unknown.
+ //
+ if (cs.action == "requested")
+ {
+ }
+ else if (cs.action == "rerequested")
+ {
+ // Someone manually requested to re-run the check runs in this check
+ // suite.
+ }
+ else if (cs.action == "completed")
+ {
+ // GitHub thinks that "all the check runs in this check suite have
+ // completed and a conclusion is available". Looks like this one we
+ // ignore?
+ }
+ else
+ throw invalid_request (400, "unsupported action: " + cs.action);
+
+ cout << "<check_suite webhook>" << endl << cs << endl;
+
+ return true;
+ }
+ else if (event == "pull_request")
+ {
+ throw invalid_request (501, "pull request events not implemented yet");
+ }
+ else
+ throw invalid_request (400, "unexpected event: '" + event + "'");
+ }
+ catch (const json::invalid_json_input& e)
+ {
+ // @@ TODO: should we write more detailed diagnostics to log? Maybe we
+ // should do this for all unsuccessful calls to respond().
+ //
+ // @@ TMP These exceptions end up in the apache error log.
+ //
+ throw invalid_request (400, "malformed JSON in request body");
+ }
+}
+
+using event = json::event;
+
+// check_suite
+//
+check_suite::check_suite (json::parser& p)
+{
+ p.next_expect (event::begin_object);
+
+ // Skip unknown/uninteresting members.
+ //
+ while (p.next_expect (event::name, event::end_object))
+ {
+ const string& n (p.name ());
+
+ if (n == "id") id = p.next_expect_number<uint64_t> ();
+ else if (n == "head_branch") head_branch = p.next_expect_string ();
+ else if (n == "head_sha") head_sha = p.next_expect_string ();
+ else if (n == "before") before = p.next_expect_string ();
+ else if (n == "after") after = p.next_expect_string ();
+ else p.next_expect_value_skip ();
+ }
+}
+
+static ostream&
+operator<< (ostream& os, const check_suite& 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;
+}
+
+// repository
+//
+repository::repository (json::parser& p)
+{
+ p.next_expect (event::begin_object);
+
+ // Skip unknown/uninteresting members.
+ //
+ while (p.next_expect (event::name, event::end_object))
+ {
+ const string& n (p.name ());
+
+ if (n == "name") name = p.next_expect_string ();
+ else if (n == "full_name") full_name = p.next_expect_string ();
+ else if (n == "default_branch") default_branch = p.next_expect_string ();
+ else p.next_expect_value_skip ();
+ }
+}
+
+static ostream&
+operator<< (ostream& os, const repository& rep)
+{
+ os << "name: " << rep.name << endl
+ << "full_name: " << rep.full_name << endl
+ << "default_branch: " << rep.default_branch << endl;
+
+ return os;
+}
+
+// check_suite_event
+//
+check_suite_event::check_suite_event (json::parser& p)
+{
+ p.next_expect (event::begin_object);
+
+ // Skip unknown/uninteresting members.
+ //
+ while (p.next_expect (event::name, event::end_object))
+ {
+ const string& n (p.name ());
+
+ if (n == "action") action = p.next_expect_string ();
+ else if (n == "check_suite") check_suite = ::check_suite (p);
+ else if (n == "repository") repository = ::repository (p);
+ else p.next_expect_value_skip ();
+ }
+}
+
+static ostream&
+operator<< (ostream& os, const check_suite_event& cs)
+{
+ os << "action: " << cs.action << endl;
+ os << "<check_suite>" << endl << cs.check_suite;
+ os << "<repository>" << endl << cs.repository << endl;
+
+ return os;
+}
diff --git a/mod/mod-ci-github.hxx b/mod/mod-ci-github.hxx
new file mode 100644
index 0000000..a869878
--- /dev/null
+++ b/mod/mod-ci-github.hxx
@@ -0,0 +1,43 @@
+// file : mod/mod-ci-github.hxx -*- C++ -*-
+// license : MIT; see accompanying LICENSE file
+
+#ifndef MOD_MOD_CI_GITHUB_HXX
+#define MOD_MOD_CI_GITHUB_HXX
+
+#include <web/xhtml/fragment.hxx>
+
+#include <libbrep/types.hxx>
+#include <libbrep/utility.hxx>
+
+#include <mod/module.hxx>
+#include <mod/module-options.hxx>
+
+namespace brep
+{
+ class ci_github: public handler
+ {
+ public:
+ ci_github () = default;
+
+ // Create a shallow copy (handling instance) if initialized and a deep
+ // copy (context exemplar) otherwise.
+ //
+ explicit
+ ci_github (const ci_github&);
+
+ virtual bool
+ handle (request&, response&);
+
+ virtual const cli::options&
+ cli_options () const {return options::ci::description ();}
+
+ private:
+ virtual void
+ init (cli::scanner&);
+
+ private:
+ shared_ptr<options::ci> options_;
+ };
+}
+
+#endif // MOD_MOD_CI_GITHUB_HXX
diff --git a/mod/mod-repository-root.cxx b/mod/mod-repository-root.cxx
index 165302d..ee2e9ce 100644
--- a/mod/mod-repository-root.cxx
+++ b/mod/mod-repository-root.cxx
@@ -15,6 +15,7 @@
#include <mod/module-options.hxx>
#include <mod/mod-ci.hxx>
+#include <mod/mod-ci-github.hxx>
#include <mod/mod-submit.hxx>
#include <mod/mod-upload.hxx>
#include <mod/mod-builds.hxx>
@@ -136,6 +137,7 @@ namespace brep
ci_ (make_shared<ci> ()),
#endif
ci_cancel_ (make_shared<ci_cancel> ()),
+ ci_github_ (make_shared<ci_github> ()),
upload_ (make_shared<upload> ())
{
}
@@ -212,6 +214,10 @@ namespace brep
r.initialized_
? r.ci_cancel_
: make_shared<ci_cancel> (*r.ci_cancel_)),
+ ci_github_ (
+ r.initialized_
+ ? r.ci_github_
+ : make_shared<ci_github> (*r.ci_github_)),
upload_ (
r.initialized_
? r.upload_
@@ -244,6 +250,7 @@ namespace brep
append (r, submit_->options ());
append (r, ci_->options ());
append (r, ci_cancel_->options ());
+ append (r, ci_github_->options ());
append (r, upload_->options ());
return r;
}
@@ -292,6 +299,7 @@ namespace brep
sub_init (*submit_, "submit");
sub_init (*ci_, "ci");
sub_init (*ci_cancel_, "ci-cancel");
+ sub_init (*ci_github_, "ci_github");
sub_init (*upload_, "upload");
// Parse own configuration options.
@@ -319,7 +327,8 @@ namespace brep
"build-configs",
"about",
"submit",
- "ci"});
+ "ci",
+ "ci-github"});
if (find (vs.begin (), vs.end (), v) == vs.end ())
fail << what << " value '" << v << "' is invalid";
@@ -508,6 +517,13 @@ namespace brep
return handle ("ci-cancel", param);
}
+ else if (func == "ci-github")
+ {
+ if (handler_ == nullptr)
+ handler_.reset (new ci_github (*ci_github_));
+
+ return handle ("ci_github", param);
+ }
else if (func == "upload")
{
if (handler_ == nullptr)
diff --git a/mod/mod-repository-root.hxx b/mod/mod-repository-root.hxx
index 5a57403..38f6adc 100644
--- a/mod/mod-repository-root.hxx
+++ b/mod/mod-repository-root.hxx
@@ -27,6 +27,7 @@ namespace brep
class submit;
class ci;
class ci_cancel;
+ class ci_github;
class upload;
class repository_root: public handler
@@ -78,6 +79,7 @@ namespace brep
shared_ptr<submit> submit_;
shared_ptr<ci> ci_;
shared_ptr<ci_cancel> ci_cancel_;
+ shared_ptr<ci_github> ci_github_;
shared_ptr<upload> upload_;
shared_ptr<options::repository_root> options_;