diff --git a/docs/checks/actions.md b/docs/checks/actions.md new file mode 100644 index 000000000..a855bac1e --- /dev/null +++ b/docs/checks/actions.md @@ -0,0 +1,44 @@ + +# Actions Connection Check + +## What is this check for? + +Make sure the runner has access to actions service for GitHub.com or GitHub Enterprise Server + +- For GitHub.com + - The runner needs to access https://api.github.com for downloading actions. + - The runner needs to access https://vstoken.actions.githubusercontent.com/_apis/.../ for requesting an access token. + - The runner needs to access https://pipelines.actions.githubusercontent.com/_apis/.../ for receiving workflow jobs. +- For GitHub Enterprise Server + - The runner needs to access https://myGHES.com/api/v3 for downloading actions. + - The runner needs to access https://myGHES.com/_services/vstoken/_apis/.../ for requesting an access token. + - The runner needs to access https://myGHES.com/_services/pipelines/_apis/.../ for receiving workflow jobs. + +## What is checked? + +- DNS lookup for api.github.com or myGHES.com using dotnet +- Ping api.github.com or myGHES.com using dotnet +- Make HTTP GET to https://api.github.com or https://myGHES.com/api/v3 using dotnet, check response headers contains `X-GitHub-Request-Id` +--- +- DNS lookup for vstoken.actions.githubusercontent.com using dotnet +- Ping vstoken.actions.githubusercontent.com using dotnet +- Make HTTP GET to https://vstoken.actions.githubusercontent.com/_apis/health or https://myGHES.com/_services/vstoken/_apis/health using dotnet, check response headers contains `x-vss-e2eid` +--- +- DNS lookup for pipelines.actions.githubusercontent.com using dotnet +- Ping pipelines.actions.githubusercontent.com using dotnet +- Make HTTP GET to https://pipelines.actions.githubusercontent.com/_apis/health or https://myGHES.com/_services/pipelines/_apis/health using dotnet, check response headers contains `x-vss-e2eid` + +## How to fix the issue? + +### 1. Check the common network issue + + > Please check the [network doc](./network.md) + +### 2. SSL certificate related issue + + If you are seeing `System.Net.Http.HttpRequestException: The SSL connection could not be established, see inner exception.` in the log, it means the runner can't connect to Actions service due to SSL handshake failure. + > Please check the [SSL cert doc](./sslcert.md) + +## Still not working? + +Contact GitHub customer service or log an issue at https://github.com/actions/runner if you think it's a runner issue. \ No newline at end of file diff --git a/docs/checks/git.md b/docs/checks/git.md new file mode 100644 index 000000000..272b8160a --- /dev/null +++ b/docs/checks/git.md @@ -0,0 +1,34 @@ +# Git Connection Check + +## What is this check for? + +Make sure `git` can access GitHub.com or your GitHub Enterprise Server. + + +## What is checked? + +The test is done by executing +```bash +# For GitHub.com +git ls-remote --exit-code https://github.com/actions/checkout HEAD + +# For GitHub Enterprise Server +git ls-remote --exit-code https://ghes.me/actions/checkout HEAD +``` + +The test also set environment variable `GIT_TRACE=1` and `GIT_CURL_VERBOSE=1` before running `git ls-remote`, this will make `git` to produce debug log for better debug any potential issues. + +## How to fix the issue? + +### 1. Check the common network issue + + > Please check the [network doc](./network.md) + +### 2. SSL certificate related issue + + If you are seeing `SSL Certificate problem:` in the log, it means the `git` can't connect to the GitHub server due to SSL handshake failure. + > Please check the [SSL cert doc](./sslcert.md) + +## Still not working? + +Contact GitHub customer service or log an issue at https://github.com/actions/runner if you think it's a runner issue. \ No newline at end of file diff --git a/docs/checks/internet.md b/docs/checks/internet.md new file mode 100644 index 000000000..dc287a9ca --- /dev/null +++ b/docs/checks/internet.md @@ -0,0 +1,26 @@ +# Internet Connection Check + +## What is this check for? + +Make sure the runner has access to https://api.github.com + +The runner needs to access https://api.github.com to download any actions from the marketplace. + +Even the runner is configured to GitHub Enterprise Server, the runner can still download actions from GitHub.com with [GitHub Connect](https://docs.github.com/en/enterprise-server@2.22/admin/github-actions/enabling-automatic-access-to-githubcom-actions-using-github-connect) + + +## What is checked? + +- DNS lookup for api.github.com using dotnet +- Ping api.github.com using dotnet +- Make HTTP GET to https://api.github.com using dotnet, check response headers contains `X-GitHub-Request-Id` + +## How to fix the issue? + +### 1. Check the common network issue + + > Please check the [network doc](./network.md) + +## Still not working? + +Contact GitHub customer service or log an issue at https://github.com/actions/runner if you think it's a runner issue. \ No newline at end of file diff --git a/docs/checks/network.md b/docs/checks/network.md new file mode 100644 index 000000000..49738a37f --- /dev/null +++ b/docs/checks/network.md @@ -0,0 +1,29 @@ +## Common Network Related Issues + +### Common things that can cause the runner to not working properly + +- Bug in the runner or the dotnet framework that causes actions runner can't make Http request in a certain network environment. + +- Proxy/Firewall block certain HTTP method, like it block all POST and PUT calls which the runner will use to upload logs. + +- Proxy/Firewall only allows requests with certain user-agent to pass through and the actions runner user-agent is not in the allow list. + +- Proxy try to decrypt and exam HTTPS traffic for security purpose but cause the actions-runner to fail to finish SSL handshake due to the lack of trusting proxy's CA. + +- Firewall rules that block action runner from accessing certain hosts, ex: `*.github.com`, `*.actions.githubusercontent.com`, etc. + + +### Identify and solve these problems + +The key is to figure out where is the problem, the network environment, or the actions runner? + +Use a 3rd party tool to make the same requests as the runner did would be a good start point. + +- Use `nslookup` to check DNS +- Use `ping` to check Ping +- Use `curl -v` to check the network stack, good for verifying default certificate/proxy settings. +- Use `Invoke-WebRequest` from `pwsh` (`PowerShell Core`) to check the dotnet network stack, good for verifying bugs in the dotnet framework. + +If the 3rd party tool is also experiencing the same error as the runner does, then you might want to contact your network administrator for help. + +Otherwise, contact GitHub customer support or log an issue at https://github.com/actions/runner \ No newline at end of file diff --git a/docs/checks/nodejs.md b/docs/checks/nodejs.md new file mode 100644 index 000000000..b4c488de4 --- /dev/null +++ b/docs/checks/nodejs.md @@ -0,0 +1,30 @@ +# Node.js Connection Check + +## What is this check for? + +Make sure the built-in node.js has access to GitHub.com or GitHub Enterprise Server. + +The runner carries it's own copy of node.js executable under `/externals/node12/`. + +All javascript base Actions will get executed by the built-in `node` at `/externals/node12/`. + +> Not the `node` from `$PATH` + +## What is checked? + +- Make HTTPS GET to https://api.github.com or https://myGHES.com/api/v3 using node.js, make sure it gets 200 response code. + +## How to fix the issue? + +### 1. Check the common network issue + + > Please check the [network doc](./network.md) + +### 2. SSL certificate related issue + + If you are seeing `Https request failed due to SSL cert issue` in the log, it means the `node.js` can't connect to the GitHub server due to SSL handshake failure. + > Please check the [SSL cert doc](./sslcert.md) + +## Still not working? + +Contact GitHub customer service or log an issue at https://github.com/actions/runner if you think it's a runner issue. \ No newline at end of file diff --git a/docs/checks/sslcert.md b/docs/checks/sslcert.md new file mode 100644 index 000000000..dd508d8a3 --- /dev/null +++ b/docs/checks/sslcert.md @@ -0,0 +1,89 @@ +## SSL Certificate Related Issues + +You might run into an SSL certificate error when your GitHub Enterprise Server is using a self-signed SSL server certificate or a web proxy within your network is decrypting HTTPS traffic for a security audit. + +As long as your certificate is generated properly, most of the issues should be fixed after your trust the certificate properly on the runner machine. + +> Different OS might have extra requirements on SSL certificate, +> Ex: macOS requires `ExtendedKeyUsage` https://support.apple.com/en-us/HT210176 + +### Don't skip SSL cert validation + +> !!! DO NOT SKIP SSL CERT VALIDATION !!! +> !!! IT IS A BAD SECURITY PRACTICE !!! + +### Download SSL certificate chain + +Depends on how your SSL server certificate gets configured, you might need to download the whole certificate chain from a machine that has trusted the SSL certificate's CA. + +- Approach 1: Download certificate chain using a browser (Chrome, Firefox, IT), you can google for more example, [here is what I found](https://medium.com/@menakajain/export-download-ssl-certificate-from-server-site-url-bcfc41ea46a2) + +- Approach 2: Download certificate chain using OpenSSL, you can google for more example, [here is what I found](https://superuser.com/a/176721) + +- Approach 3: Ask your network administrator or the owner of the CA certificate to send you a copy of it + +### Trust CA certificate for the Runner + +The actions runner is a dotnet core application which will follow how dotnet load SSL CA certificates on each OS. + +You can get full details documentation at [here](https://docs.microsoft.com/en-us/dotnet/standard/security/cross-platform-cryptography#x509store) + +In short: +- Windows: Load from Windows certificate store. +- Linux: Load from OpenSSL CA cert bundle. +- macOS: Load from macOS KeyChain. + +To let the runner trusts your CA certificate, you will need to: +1. Save your SSL certificate chain which includes the root CA and all intermediate CAs into a `.pem` file. +2. Use `OpenSSL` to convert `.pem` file to a proper format for different OS, here is some [doc with sample commands](https://www.sslshopper.com/ssl-converter.html) +3. Trust CA on different OS: + - Windows: https://docs.microsoft.com/en-us/skype-sdk/sdn/articles/installing-the-trusted-root-certificate + - macOS: ![trust ca cert](./../res/macOStrustCA.gif) + - Linux: Refer to the distribution documentation + 1. RedHat: https://www.redhat.com/sysadmin/ca-certificates-cli + 2. Ubuntu: http://manpages.ubuntu.com/manpages/focal/man8/update-ca-certificates.8.html + 3. Google search: "trust ca certificate on [linux distribution]" + 4. If all approaches failed, set environment variable `SSL_CERT_FILE` to the CA bundle `.pem` file we get. + > To verity cert gets installed properly on Linux, you can try use `curl -v https://sitewithsslissue.com` and `pwsh -Command \"Invoke-WebRequest -Uri https://sitewithsslissue.com\"` + +### Trust CA certificate for Git CLI + +Git uses various CA bundle file depends on your operation system. +- Git packaged the CA bundle file within the Git installation on Windows +- Git use OpenSSL certificate CA bundle file on Linux and macOS + +You can check where Git check CA file by running: +```bash +export GIT_CURL_VERBOSE=1 +git ls-remote https://github.com/actions/runner HEAD +``` + +You should see something like: +``` +* Couldn't find host github.com in the .netrc file; using defaults +* Trying 140.82.114.4... +* TCP_NODELAY set +* Connected to github.com (140.82.114.4) port 443 (#0) +* ALPN, offering h2 +* ALPN, offering http/1.1 +* successfully set certificate verify locations: +* CAfile: /etc/ssl/cert.pem + CApath: none +* SSL connection using TLSv1.2 / ECDHE-RSA-AES128-GCM-SHA256 +``` +This tells me `/etc/ssl/cert.pem` is where it read trusted CA certificates. + +To let Git trusts your CA certificate, you will need to: +1. Save your SSL certificate chain which includes the root CA and all intermediate CAs into a `.pem` file. +2. Set `http.sslCAInfo` Git config or `GIT_SSL_CAINFO` environment variable to the full path of the `.pem` file [Git Doc](https://git-scm.com/docs/git-config#Documentation/git-config.txt-httpsslCAInfo) +> I would recommend using `http.sslCAInfo` since it can be scope to certain hosts that need the extra trusted CA. +> Ex: `git config --global http.https://myghes.com/.sslCAInfo /extra/ca/cert.pem` +> This will make Git use the `/extra/ca/cert.pem` only when communicates with `https://myghes.com` and keep using the default CA bundle with others. + +### Trust CA certificate for Node.js + +Node.js has compiled a snapshot of the Mozilla CA store that is fixed at each version of Node.js' release time. + +To let Node.js trusts your CA certificate, you will need to: +1. Save your SSL certificate chain which includes the root CA and all intermediate CAs into a `.pem` file. +2. Set environment variable `NODE_EXTRA_CA_CERTS` which point to the file. ex: `export NODE_EXTRA_CA_CERTS=/full/path/to/cacert.pem` or `set NODE_EXTRA_CA_CERTS=C:\full\path\to\cacert.pem` diff --git a/docs/res/macOStrustCA.gif b/docs/res/macOStrustCA.gif new file mode 100644 index 000000000..65620f256 Binary files /dev/null and b/docs/res/macOStrustCA.gif differ diff --git a/src/Misc/layoutbin/checkScripts/downloadCert.js b/src/Misc/layoutbin/checkScripts/downloadCert.js new file mode 100644 index 000000000..fa4e86a0f --- /dev/null +++ b/src/Misc/layoutbin/checkScripts/downloadCert.js @@ -0,0 +1,115 @@ +const https = require('https') +const fs = require('fs') +const http = require('http') +const hostname = process.env['HOSTNAME'] || '' +const port = process.env['PORT'] || '' +const path = process.env['PATH'] || '' +const pat = process.env['PAT'] || '' +const proxyHost = process.env['PROXYHOST'] || '' +const proxyPort = process.env['PROXYPORT'] || '' +const proxyUsername = process.env['PROXYUSERNAME'] || '' +const proxyPassword = process.env['PROXYPASSWORD'] || '' + +process.env['NODE_TLS_REJECT_UNAUTHORIZED'] = '0' + +if (proxyHost === '') { + const options = { + hostname: hostname, + port: port, + path: path, + method: 'GET', + headers: { + 'User-Agent': 'GitHubActionsRunnerCheck/1.0', + 'Authorization': `token ${pat}` + }, + } + const req = https.request(options, res => { + console.log(`statusCode: ${res.statusCode}`) + console.log(`headers: ${JSON.stringify(res.headers)}`) + let cert = socket.getPeerCertificate(true) + let certPEM = '' + let fingerprints = {} + while (cert != null && fingerprints[cert.fingerprint] != '1') { + fingerprints[cert.fingerprint] = '1' + certPEM = certPEM + '-----BEGIN CERTIFICATE-----\n' + let certEncoded = cert.raw.toString('base64') + for (let i = 0; i < certEncoded.length; i++) { + certPEM = certPEM + certEncoded[i] + if (i != certEncoded.length - 1 && (i + 1) % 64 == 0) { + certPEM = certPEM + '\n' + } + } + certPEM = certPEM + '\n-----END CERTIFICATE-----\n' + cert = cert.issuerCertificate + } + console.log(certPEM) + fs.writeFileSync('./download_ca_cert.pem', certPEM) + res.on('data', d => { + process.stdout.write(d) + }) + }) + req.on('error', error => { + console.error(error) + }) + req.end() +} +else { + const auth = 'Basic ' + Buffer.from(proxyUsername + ':' + proxyPassword).toString('base64') + + const options = { + host: proxyHost, + port: proxyPort, + method: 'CONNECT', + path: `${hostname}:${port}`, + } + + if (proxyUsername != '' || proxyPassword != '') { + options.headers = { + 'Proxy-Authorization': auth, + } + } + + http.request(options).on('connect', (res, socket) => { + if (res.statusCode != 200) { + throw new Error(`Proxy returns code: ${res.statusCode}`) + } + + https.get({ + host: hostname, + port: port, + socket: socket, + agent: false, + path: '/', + headers: { + 'User-Agent': 'GitHubActionsRunnerCheck/1.0', + 'Authorization': `token ${pat}` + } + }, (res) => { + let cert = res.socket.getPeerCertificate(true) + let certPEM = '' + let fingerprints = {} + while (cert != null && fingerprints[cert.fingerprint] != '1') { + fingerprints[cert.fingerprint] = '1' + certPEM = certPEM + '-----BEGIN CERTIFICATE-----\n' + let certEncoded = cert.raw.toString('base64') + for (let i = 0; i < certEncoded.length; i++) { + certPEM = certPEM + certEncoded[i] + if (i != certEncoded.length - 1 && (i + 1) % 64 == 0) { + certPEM = certPEM + '\n' + } + } + certPEM = certPEM + '\n-----END CERTIFICATE-----\n' + cert = cert.issuerCertificate + } + console.log(certPEM) + fs.writeFileSync('./download_ca_cert.pem', certPEM) + console.log(`statusCode: ${res.statusCode}`) + console.log(`headers: ${JSON.stringify(res.headers)}`) + res.on('data', d => { + process.stdout.write(d) + }) + }) + }).on('error', (err) => { + console.error('error', err) + }).end() +} \ No newline at end of file diff --git a/src/Misc/layoutbin/checkScripts/makeWebRequest.js b/src/Misc/layoutbin/checkScripts/makeWebRequest.js new file mode 100644 index 000000000..9f6e11762 --- /dev/null +++ b/src/Misc/layoutbin/checkScripts/makeWebRequest.js @@ -0,0 +1,75 @@ +const https = require('https') +const http = require('http') +const hostname = process.env['HOSTNAME'] || '' +const port = process.env['PORT'] || '' +const path = process.env['PATH'] || '' +const pat = process.env['PAT'] || '' +const proxyHost = process.env['PROXYHOST'] || '' +const proxyPort = process.env['PROXYPORT'] || '' +const proxyUsername = process.env['PROXYUSERNAME'] || '' +const proxyPassword = process.env['PROXYPASSWORD'] || '' + +if (proxyHost === '') { + const options = { + hostname: hostname, + port: port, + path: path, + method: 'GET', + headers: { + 'User-Agent': 'GitHubActionsRunnerCheck/1.0', + 'Authorization': `token ${pat}`, + } + } + const req = https.request(options, res => { + console.log(`statusCode: ${res.statusCode}`) + console.log(`headers: ${JSON.stringify(res.headers)}`) + + res.on('data', d => { + process.stdout.write(d) + }) + }) + req.on('error', error => { + console.error(error) + }) + req.end() +} +else { + const proxyAuth = 'Basic ' + Buffer.from(proxyUsername + ':' + proxyPassword).toString('base64') + const options = { + hostname: proxyHost, + port: proxyPort, + method: 'CONNECT', + path: `${hostname}:${port}` + } + + if (proxyUsername != '' || proxyPassword != '') { + options.headers = { + 'Proxy-Authorization': proxyAuth, + } + } + http.request(options).on('connect', (res, socket) => { + if (res.statusCode != 200) { + throw new Error(`Proxy returns code: ${res.statusCode}`) + } + https.get({ + host: hostname, + port: port, + socket: socket, + agent: false, + path: path, + headers: { + 'User-Agent': 'GitHubActionsRunnerCheck/1.0', + 'Authorization': `token ${pat}`, + } + }, (res) => { + console.log(`statusCode: ${res.statusCode}`) + console.log(`headers: ${JSON.stringify(res.headers)}`) + + res.on('data', d => { + process.stdout.write(d) + }) + }) + }).on('error', (err) => { + console.error('error', err) + }).end() +} \ No newline at end of file diff --git a/src/Runner.Common/Constants.cs b/src/Runner.Common/Constants.cs index 9ccb28f6b..e820725ca 100644 --- a/src/Runner.Common/Constants.cs +++ b/src/Runner.Common/Constants.cs @@ -121,6 +121,7 @@ namespace GitHub.Runner.Common //validFlags array as well present in the CommandSettings.cs public static class Flags { + public static readonly string Check = "check"; public static readonly string Commit = "commit"; public static readonly string Help = "help"; public static readonly string Replace = "replace"; diff --git a/src/Runner.Common/ExtensionManager.cs b/src/Runner.Common/ExtensionManager.cs index 9b7171c73..09a094c1c 100644 --- a/src/Runner.Common/ExtensionManager.cs +++ b/src/Runner.Common/ExtensionManager.cs @@ -60,6 +60,12 @@ namespace GitHub.Runner.Common Add(extensions, "GitHub.Runner.Worker.AddPathFileCommand, Runner.Worker"); Add(extensions, "GitHub.Runner.Worker.SetEnvFileCommand, Runner.Worker"); break; + case "GitHub.Runner.Listener.Check.ICheckExtension": + Add(extensions, "GitHub.Runner.Listener.Check.InternetCheck, Runner.Listener"); + Add(extensions, "GitHub.Runner.Listener.Check.ActionsCheck, Runner.Listener"); + Add(extensions, "GitHub.Runner.Listener.Check.GitCheck, Runner.Listener"); + Add(extensions, "GitHub.Runner.Listener.Check.NodeJsCheck, Runner.Listener"); + break; default: // This should never happen. throw new NotSupportedException($"Unexpected extension type: '{typeof(T).FullName}'"); diff --git a/src/Runner.Listener/Checks/ActionsCheck.cs b/src/Runner.Listener/Checks/ActionsCheck.cs new file mode 100644 index 000000000..931b7f2e9 --- /dev/null +++ b/src/Runner.Listener/Checks/ActionsCheck.cs @@ -0,0 +1,90 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using GitHub.Runner.Common; +using GitHub.Runner.Sdk; + +namespace GitHub.Runner.Listener.Check +{ + public sealed class ActionsCheck : RunnerService, ICheckExtension + { + private string _logFile = null; + + public int Order => 2; + + public string CheckName => "GitHub Actions Connection"; + + public string CheckDescription => "Make sure the actions runner have access to the GitHub Actions Service."; + + public string CheckLog => _logFile; + + public string HelpLink => "https://github.com/actions/runner/blob/main/docs/checks/actions.md"; + + public Type ExtensionType => typeof(ICheckExtension); + + public override void Initialize(IHostContext hostContext) + { + base.Initialize(hostContext); + _logFile = Path.Combine(HostContext.GetDirectory(WellKnownDirectory.Diag), StringUtil.Format("{0}_{1:yyyyMMdd-HHmmss}-utc.log", nameof(ActionsCheck), DateTime.UtcNow)); + } + + // runner access to actions service + public async Task RunCheck(string url, string pat) + { + await File.AppendAllLinesAsync(_logFile, HostContext.WarnLog()); + await File.AppendAllLinesAsync(_logFile, HostContext.CheckProxy()); + + var checkTasks = new List>(); + string githubApiUrl = null; + string actionsTokenServiceUrl = null; + string actionsPipelinesServiceUrl = null; + var urlBuilder = new UriBuilder(url); + if (UrlUtil.IsHostedServer(urlBuilder)) + { + urlBuilder.Host = $"api.{urlBuilder.Host}"; + urlBuilder.Path = ""; + githubApiUrl = urlBuilder.Uri.AbsoluteUri; + actionsTokenServiceUrl = "https://vstoken.actions.githubusercontent.com/_apis/health"; + actionsPipelinesServiceUrl = "https://pipelines.actions.githubusercontent.com/_apis/health"; + } + else + { + urlBuilder.Path = "api/v3"; + githubApiUrl = urlBuilder.Uri.AbsoluteUri; + urlBuilder.Path = "_services/vstoken/_apis/health"; + actionsTokenServiceUrl = urlBuilder.Uri.AbsoluteUri; + urlBuilder.Path = "_services/pipelines/_apis/health"; + actionsPipelinesServiceUrl = urlBuilder.Uri.AbsoluteUri; + } + + // check github api + checkTasks.Add(CheckUtil.CheckDns(githubApiUrl)); + checkTasks.Add(CheckUtil.CheckPing(githubApiUrl)); + checkTasks.Add(HostContext.CheckHttpsRequests(githubApiUrl, pat, expectedHeader: "X-GitHub-Request-Id")); + + // check actions token service + checkTasks.Add(CheckUtil.CheckDns(actionsTokenServiceUrl)); + checkTasks.Add(CheckUtil.CheckPing(actionsTokenServiceUrl)); + checkTasks.Add(HostContext.CheckHttpsRequests(actionsTokenServiceUrl, pat, expectedHeader: "x-vss-e2eid")); + + // check actions pipelines service + checkTasks.Add(CheckUtil.CheckDns(actionsPipelinesServiceUrl)); + checkTasks.Add(CheckUtil.CheckPing(actionsPipelinesServiceUrl)); + checkTasks.Add(HostContext.CheckHttpsRequests(actionsPipelinesServiceUrl, pat, expectedHeader: "x-vss-e2eid")); + + var result = true; + while (checkTasks.Count > 0) + { + var finishedCheckTask = await Task.WhenAny(checkTasks); + var finishedCheck = await finishedCheckTask; + result = result && finishedCheck.Pass; + await File.AppendAllLinesAsync(_logFile, finishedCheck.Logs); + checkTasks.Remove(finishedCheckTask); + } + + await Task.WhenAll(checkTasks); + return result; + } + } +} \ No newline at end of file diff --git a/src/Runner.Listener/Checks/CheckUtil.cs b/src/Runner.Listener/Checks/CheckUtil.cs new file mode 100644 index 000000000..8b4f1a564 --- /dev/null +++ b/src/Runner.Listener/Checks/CheckUtil.cs @@ -0,0 +1,351 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.Tracing; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Net.NetworkInformation; +using System.Threading; +using System.Threading.Tasks; +using GitHub.Runner.Common; +using GitHub.Runner.Sdk; +using GitHub.Services.Common; + +namespace GitHub.Runner.Listener.Check +{ + public static class CheckUtil + { + public static List WarnLog(this IHostContext hostContext) + { + var logs = new List(); + logs.Add($"{DateTime.UtcNow.ToString("O")} ***************************************************************************************************************"); + logs.Add($"{DateTime.UtcNow.ToString("O")} **** ****"); + logs.Add($"{DateTime.UtcNow.ToString("O")} **** !!! WARNING !!! "); + logs.Add($"{DateTime.UtcNow.ToString("O")} **** DO NOT share the log in public place! The log may contains secrets in plain text. "); + logs.Add($"{DateTime.UtcNow.ToString("O")} **** !!! WARNING !!! "); + logs.Add($"{DateTime.UtcNow.ToString("O")} **** ****"); + logs.Add($"{DateTime.UtcNow.ToString("O")} ***************************************************************************************************************"); + return logs; + } + + public static List CheckProxy(this IHostContext hostContext) + { + var logs = new List(); + if (!string.IsNullOrEmpty(hostContext.WebProxy.HttpProxyAddress) || + !string.IsNullOrEmpty(hostContext.WebProxy.HttpsProxyAddress)) + { + logs.Add($"{DateTime.UtcNow.ToString("O")} ***************************************************************************************************************"); + logs.Add($"{DateTime.UtcNow.ToString("O")} **** ****"); + logs.Add($"{DateTime.UtcNow.ToString("O")} **** Runner is behind web proxy {hostContext.WebProxy.HttpsProxyAddress ?? hostContext.WebProxy.HttpProxyAddress} "); + logs.Add($"{DateTime.UtcNow.ToString("O")} **** ****"); + logs.Add($"{DateTime.UtcNow.ToString("O")} ***************************************************************************************************************"); + } + + return logs; + } + + public static async Task CheckDns(string targetUrl) + { + var result = new CheckResult(); + var url = new Uri(targetUrl); + try + { + result.Logs.Add($"{DateTime.UtcNow.ToString("O")} ***************************************************************************************************************"); + result.Logs.Add($"{DateTime.UtcNow.ToString("O")} **** ****"); + result.Logs.Add($"{DateTime.UtcNow.ToString("O")} **** Try DNS lookup for {url.Host} "); + result.Logs.Add($"{DateTime.UtcNow.ToString("O")} **** ****"); + result.Logs.Add($"{DateTime.UtcNow.ToString("O")} ***************************************************************************************************************"); + IPHostEntry host = await Dns.GetHostEntryAsync(url.Host); + foreach (var address in host.AddressList) + { + result.Logs.Add($"{DateTime.UtcNow.ToString("O")} Resolved DNS for {url.Host} to '{address}'"); + } + + result.Pass = true; + } + catch (Exception ex) + { + result.Pass = false; + result.Logs.Add($"{DateTime.UtcNow.ToString("O")} ***************************************************************************************************************"); + result.Logs.Add($"{DateTime.UtcNow.ToString("O")} **** ****"); + result.Logs.Add($"{DateTime.UtcNow.ToString("O")} **** Resolved DNS for {url.Host} failed with error: {ex}"); + result.Logs.Add($"{DateTime.UtcNow.ToString("O")} **** ****"); + result.Logs.Add($"{DateTime.UtcNow.ToString("O")} ***************************************************************************************************************"); + } + + return result; + } + + public static async Task CheckPing(string targetUrl) + { + var result = new CheckResult(); + var url = new Uri(targetUrl); + try + { + result.Logs.Add($"{DateTime.UtcNow.ToString("O")} ***************************************************************************************************************"); + result.Logs.Add($"{DateTime.UtcNow.ToString("O")} **** ****"); + result.Logs.Add($"{DateTime.UtcNow.ToString("O")} **** Try ping {url.Host} "); + result.Logs.Add($"{DateTime.UtcNow.ToString("O")} **** ****"); + result.Logs.Add($"{DateTime.UtcNow.ToString("O")} ***************************************************************************************************************"); + using (var ping = new Ping()) + { + var reply = await ping.SendPingAsync(url.Host); + if (reply.Status == IPStatus.Success) + { + result.Pass = true; + result.Logs.Add($"{DateTime.UtcNow.ToString("O")} Ping {url.Host} ({reply.Address}) succeed within to '{reply.RoundtripTime} ms'"); + } + else + { + result.Pass = false; + result.Logs.Add($"{DateTime.UtcNow.ToString("O")} Ping {url.Host} ({reply.Address}) failed with '{reply.Status}'"); + } + } + } + catch (Exception ex) + { + result.Pass = false; + result.Logs.Add($"{DateTime.UtcNow.ToString("O")} ***************************************************************************************************************"); + result.Logs.Add($"{DateTime.UtcNow.ToString("O")} **** ****"); + result.Logs.Add($"{DateTime.UtcNow.ToString("O")} **** Ping api.github.com failed with error: {ex}"); + result.Logs.Add($"{DateTime.UtcNow.ToString("O")} **** ****"); + result.Logs.Add($"{DateTime.UtcNow.ToString("O")} ***************************************************************************************************************"); + } + + return result; + } + + public static async Task CheckHttpsRequests(this IHostContext hostContext, string url, string pat, string expectedHeader) + { + var result = new CheckResult(); + try + { + result.Logs.Add($"{DateTime.UtcNow.ToString("O")} ***************************************************************************************************************"); + result.Logs.Add($"{DateTime.UtcNow.ToString("O")} **** ****"); + result.Logs.Add($"{DateTime.UtcNow.ToString("O")} **** Send HTTPS Request to {url} "); + result.Logs.Add($"{DateTime.UtcNow.ToString("O")} **** ****"); + result.Logs.Add($"{DateTime.UtcNow.ToString("O")} ***************************************************************************************************************"); + using (var _ = new HttpEventSourceListener(result.Logs)) + using (var httpClientHandler = hostContext.CreateHttpClientHandler()) + using (var httpClient = new HttpClient(httpClientHandler)) + { + httpClient.DefaultRequestHeaders.UserAgent.AddRange(hostContext.UserAgents); + if (!string.IsNullOrEmpty(pat)) + { + httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("token", pat); + } + + var response = await httpClient.GetAsync(url); + + result.Logs.Add($"{DateTime.UtcNow.ToString("O")} Http status code: {response.StatusCode}"); + result.Logs.Add($"{DateTime.UtcNow.ToString("O")} Http response headers: {response.Headers}"); + + var responseContent = await response.Content.ReadAsStringAsync(); + result.Logs.Add($"{DateTime.UtcNow.ToString("O")} Http response body: {responseContent}"); + if (response.IsSuccessStatusCode) + { + if (response.Headers.Contains(expectedHeader)) + { + result.Pass = true; + result.Logs.Add($"{DateTime.UtcNow.ToString("O")} ***************************************************************************************************************"); + result.Logs.Add($"{DateTime.UtcNow.ToString("O")} Http request 'GET' to {url} succeed"); + result.Logs.Add($"{DateTime.UtcNow.ToString("O")} ***************************************************************************************************************"); + result.Logs.Add($"{DateTime.UtcNow.ToString("O")} "); + result.Logs.Add($"{DateTime.UtcNow.ToString("O")} "); + } + else + { + result.Pass = false; + result.Logs.Add($"{DateTime.UtcNow.ToString("O")} ***************************************************************************************************************"); + result.Logs.Add($"{DateTime.UtcNow.ToString("O")} Http request 'GET' to {url} succeed but doesn't have expected HTTP Header."); + result.Logs.Add($"{DateTime.UtcNow.ToString("O")} ***************************************************************************************************************"); + result.Logs.Add($"{DateTime.UtcNow.ToString("O")} "); + result.Logs.Add($"{DateTime.UtcNow.ToString("O")} "); + } + } + else + { + result.Pass = false; + result.Logs.Add($"{DateTime.UtcNow.ToString("O")} ***************************************************************************************************************"); + result.Logs.Add($"{DateTime.UtcNow.ToString("O")} Http request 'GET' to {url} failed with {response.StatusCode}"); + result.Logs.Add($"{DateTime.UtcNow.ToString("O")} ***************************************************************************************************************"); + result.Logs.Add($"{DateTime.UtcNow.ToString("O")} "); + result.Logs.Add($"{DateTime.UtcNow.ToString("O")} "); + } + } + } + catch (Exception ex) + { + result.Pass = false; + result.Logs.Add($"{DateTime.UtcNow.ToString("O")} ***************************************************************************************************************"); + result.Logs.Add($"{DateTime.UtcNow.ToString("O")} **** ****"); + result.Logs.Add($"{DateTime.UtcNow.ToString("O")} **** Https request 'GET' to {url} failed with error: {ex}"); + result.Logs.Add($"{DateTime.UtcNow.ToString("O")} **** ****"); + result.Logs.Add($"{DateTime.UtcNow.ToString("O")} ***************************************************************************************************************"); + } + + return result; + } + + public static async Task DownloadExtraCA(this IHostContext hostContext, string url, string pat) + { + var result = new CheckResult(); + try + { + result.Logs.Add($"{DateTime.UtcNow.ToString("O")} ***************************************************************************************************************"); + result.Logs.Add($"{DateTime.UtcNow.ToString("O")} **** ****"); + result.Logs.Add($"{DateTime.UtcNow.ToString("O")} **** Download SSL Certificate from {url} "); + result.Logs.Add($"{DateTime.UtcNow.ToString("O")} **** ****"); + result.Logs.Add($"{DateTime.UtcNow.ToString("O")} ***************************************************************************************************************"); + + var uri = new Uri(url); + var env = new Dictionary() + { + { "HOSTNAME", uri.Host }, + { "PORT", uri.IsDefaultPort ? (uri.Scheme.ToLowerInvariant() == "https" ? "443" : "80") : uri.Port.ToString() }, + { "PATH", uri.AbsolutePath }, + { "PAT", pat } + }; + + var proxy = hostContext.WebProxy.GetProxy(uri); + if (proxy != null) + { + env["PROXYHOST"] = proxy.Host; + env["PROXYPORT"] = proxy.IsDefaultPort ? (proxy.Scheme.ToLowerInvariant() == "https" ? "443" : "80") : proxy.Port.ToString(); + if (hostContext.WebProxy.HttpProxyUsername != null || + hostContext.WebProxy.HttpsProxyUsername != null) + { + env["PROXYUSERNAME"] = hostContext.WebProxy.HttpProxyUsername ?? hostContext.WebProxy.HttpsProxyUsername; + env["PROXYPASSWORD"] = hostContext.WebProxy.HttpProxyPassword ?? hostContext.WebProxy.HttpsProxyPassword; + } + else + { + env["PROXYUSERNAME"] = ""; + env["PROXYPASSWORD"] = ""; + } + } + else + { + env["PROXYHOST"] = ""; + env["PROXYPORT"] = ""; + env["PROXYUSERNAME"] = ""; + env["PROXYPASSWORD"] = ""; + } + + using (var processInvoker = hostContext.CreateService()) + { + processInvoker.OutputDataReceived += new EventHandler((sender, args) => + { + if (!string.IsNullOrEmpty(args.Data)) + { + result.Logs.Add($"{DateTime.UtcNow.ToString("O")} [STDOUT] {args.Data}"); + } + }); + + processInvoker.ErrorDataReceived += new EventHandler((sender, args) => + { + if (!string.IsNullOrEmpty(args.Data)) + { + result.Logs.Add($"{DateTime.UtcNow.ToString("O")} [STDERR] {args.Data}"); + } + }); + + var downloadCertScript = Path.Combine(hostContext.GetDirectory(WellKnownDirectory.Bin), "checkScripts", "downloadCert"); + var node12 = Path.Combine(hostContext.GetDirectory(WellKnownDirectory.Externals), "node12", "bin", $"node{IOUtil.ExeExtension}"); + result.Logs.Add($"{DateTime.UtcNow.ToString("O")} Run '{node12} \"{downloadCertScript}\"' "); + result.Logs.Add($"{DateTime.UtcNow.ToString("O")} {StringUtil.ConvertToJson(env)}"); + await processInvoker.ExecuteAsync( + hostContext.GetDirectory(WellKnownDirectory.Root), + node12, + $"\"{downloadCertScript}\"", + env, + true, + CancellationToken.None); + } + + result.Pass = true; + } + catch (Exception ex) + { + result.Pass = false; + result.Logs.Add($"{DateTime.UtcNow.ToString("O")} ***************************************************************************************************************"); + result.Logs.Add($"{DateTime.UtcNow.ToString("O")} **** ****"); + result.Logs.Add($"{DateTime.UtcNow.ToString("O")} **** Download SSL Certificate from '{url}' failed with error: {ex}"); + result.Logs.Add($"{DateTime.UtcNow.ToString("O")} **** ****"); + result.Logs.Add($"{DateTime.UtcNow.ToString("O")} ***************************************************************************************************************"); + } + + return result; + } + } + + // EventSource listener for dotnet debug trace for HTTP and SSL + public sealed class HttpEventSourceListener : EventListener + { + private readonly List _logs; + private readonly object _lock = new object(); + private readonly Dictionary> _ignoredEvent = new Dictionary> + { + { + "Private.InternalDiagnostics.System.Net.Http", + new HashSet + { + "Info", + "Associate" + } + }, + { + "Private.InternalDiagnostics.System.Net.Security", + new HashSet + { + "Info", + "SslStreamCtor", + "SecureChannelCtor", + "NoDelegateNoClientCert", + "CertsAfterFiltering", + "UsingCachedCredential", + "SspiSelectedCipherSuite" + } + } + }; + + public HttpEventSourceListener(List logs) + { + _logs = logs; + if (Environment.GetEnvironmentVariable("ACTIONS_RUNNER_TRACE_ALL_HTTP_EVENT") == "1") + { + _ignoredEvent.Clear(); + } + } + + protected override void OnEventSourceCreated(EventSource eventSource) + { + base.OnEventSourceCreated(eventSource); + + if (eventSource.Name == "Private.InternalDiagnostics.System.Net.Http" || + eventSource.Name == "Private.InternalDiagnostics.System.Net.Security") + { + EnableEvents(eventSource, EventLevel.Verbose, EventKeywords.All); + } + } + + protected override void OnEventWritten(EventWrittenEventArgs eventData) + { + base.OnEventWritten(eventData); + lock (_lock) + { + if (_ignoredEvent.TryGetValue(eventData.EventSource.Name, out var ignored) && + ignored.Contains(eventData.EventName)) + { + return; + } + + _logs.Add($"{DateTime.UtcNow.ToString("O")} [START {eventData.EventSource.Name} - {eventData.EventName}]"); + _logs.AddRange(eventData.Payload.Select(x => string.Join(Environment.NewLine, x.ToString().Split(Environment.NewLine).Select(y => $"{DateTime.UtcNow.ToString("O")} {y}")))); + _logs.Add($"{DateTime.UtcNow.ToString("O")} [END {eventData.EventSource.Name} - {eventData.EventName}]"); + } + } + } +} \ No newline at end of file diff --git a/src/Runner.Listener/Checks/GitCheck.cs b/src/Runner.Listener/Checks/GitCheck.cs new file mode 100644 index 000000000..b3ecd6f3c --- /dev/null +++ b/src/Runner.Listener/Checks/GitCheck.cs @@ -0,0 +1,171 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Threading; +using System.Threading.Tasks; +using GitHub.Runner.Common; +using GitHub.Runner.Sdk; + +namespace GitHub.Runner.Listener.Check +{ + public sealed class GitCheck : RunnerService, ICheckExtension + { + private string _logFile = null; + private string _gitPath = null; + + public int Order => 3; + + public string CheckName => "Git Certificate/Proxy Validation"; + + public string CheckDescription => "Make sure the git cli can access to GitHub.com or the GitHub Enterprise Server."; + + public string CheckLog => _logFile; + + public string HelpLink => "https://github.com/actions/runner/blob/main/docs/checks/git.md"; + + public Type ExtensionType => typeof(ICheckExtension); + + public override void Initialize(IHostContext hostContext) + { + base.Initialize(hostContext); + _logFile = Path.Combine(HostContext.GetDirectory(WellKnownDirectory.Diag), StringUtil.Format("{0}_{1:yyyyMMdd-HHmmss}-utc.log", nameof(GitCheck), DateTime.UtcNow)); + _gitPath = WhichUtil.Which("git"); + } + + // git access to ghes/gh + public async Task RunCheck(string url, string pat) + { + await File.AppendAllLinesAsync(_logFile, HostContext.WarnLog()); + await File.AppendAllLinesAsync(_logFile, HostContext.CheckProxy()); + + if (string.IsNullOrEmpty(_gitPath)) + { + await File.AppendAllLinesAsync(_logFile, new[] { $"{DateTime.UtcNow.ToString("O")} Can't verify git with GitHub.com or GitHub Enterprise Server since git is not installed." }); + return false; + } + + var checkGit = await CheckGit(url, pat); + var result = checkGit.Pass; + await File.AppendAllLinesAsync(_logFile, checkGit.Logs); + + // try fix SSL error by providing extra CA certificate. + if (checkGit.SslError) + { + await File.AppendAllLinesAsync(_logFile, new[] { $"{DateTime.UtcNow.ToString("O")} Try fix SSL error by providing extra CA certificate." }); + var downloadCert = await HostContext.DownloadExtraCA(url, pat); + await File.AppendAllLinesAsync(_logFile, downloadCert.Logs); + + if (downloadCert.Pass) + { + var recheckGit = await CheckGit(url, pat, extraCA: true); + await File.AppendAllLinesAsync(_logFile, recheckGit.Logs); + if (recheckGit.Pass) + { + await File.AppendAllLinesAsync(_logFile, new[] { $"{DateTime.UtcNow.ToString("O")} Fixed SSL error by providing extra CA certs." }); + } + } + } + + return result; + } + + private async Task CheckGit(string url, string pat, bool extraCA = false) + { + var result = new CheckResult(); + try + { + result.Logs.Add($"{DateTime.UtcNow.ToString("O")} ***************************************************************************************************************"); + result.Logs.Add($"{DateTime.UtcNow.ToString("O")} **** ****"); + result.Logs.Add($"{DateTime.UtcNow.ToString("O")} **** Validate server cert and proxy configuration with Git "); + result.Logs.Add($"{DateTime.UtcNow.ToString("O")} **** ****"); + result.Logs.Add($"{DateTime.UtcNow.ToString("O")} ***************************************************************************************************************"); + var repoUrlBuilder = new UriBuilder(url); + repoUrlBuilder.Path = "actions/checkout"; + repoUrlBuilder.UserName = "gh"; + repoUrlBuilder.Password = pat; + + var gitProxy = ""; + var proxy = HostContext.WebProxy.GetProxy(repoUrlBuilder.Uri); + if (proxy != null) + { + result.Logs.Add($"{DateTime.UtcNow.ToString("O")} Runner is behind http proxy '{proxy.AbsoluteUri}'"); + if (HostContext.WebProxy.HttpProxyUsername != null || + HostContext.WebProxy.HttpsProxyUsername != null) + { + var proxyUrlWithCred = UrlUtil.GetCredentialEmbeddedUrl( + proxy, + HostContext.WebProxy.HttpProxyUsername ?? HostContext.WebProxy.HttpsProxyUsername, + HostContext.WebProxy.HttpProxyPassword ?? HostContext.WebProxy.HttpsProxyPassword); + gitProxy = $"-c http.proxy={proxyUrlWithCred}"; + } + else + { + gitProxy = $"-c http.proxy={proxy.AbsoluteUri}"; + } + } + + using (var processInvoker = HostContext.CreateService()) + { + processInvoker.OutputDataReceived += new EventHandler((sender, args) => + { + if (!string.IsNullOrEmpty(args.Data)) + { + result.Logs.Add($"{DateTime.UtcNow.ToString("O")} {args.Data}"); + } + }); + + processInvoker.ErrorDataReceived += new EventHandler((sender, args) => + { + if (!string.IsNullOrEmpty(args.Data)) + { + result.Logs.Add($"{DateTime.UtcNow.ToString("O")} {args.Data}"); + } + }); + + var gitArgs = $"{gitProxy} ls-remote --exit-code {repoUrlBuilder.Uri.AbsoluteUri} HEAD"; + result.Logs.Add($"{DateTime.UtcNow.ToString("O")} Run 'git {gitArgs}' "); + + var env = new Dictionary + { + { "GIT_TRACE", "1" }, + { "GIT_CURL_VERBOSE", "1" } + }; + + if (extraCA) + { + env["GIT_SSL_CAINFO"] = Path.Combine(HostContext.GetDirectory(WellKnownDirectory.Root), "download_ca_cert.pem"); + } + + await processInvoker.ExecuteAsync( + HostContext.GetDirectory(WellKnownDirectory.Root), + _gitPath, + gitArgs, + env, + true, + CancellationToken.None); + } + + result.Pass = true; + } + catch (Exception ex) + { + result.Pass = false; + result.Logs.Add($"{DateTime.UtcNow.ToString("O")} ***************************************************************************************************************"); + result.Logs.Add($"{DateTime.UtcNow.ToString("O")} **** ****"); + result.Logs.Add($"{DateTime.UtcNow.ToString("O")} **** git ls-remote failed with error: {ex}"); + if (result.Logs.Any(x => x.Contains("SSL Certificate problem", StringComparison.OrdinalIgnoreCase))) + { + result.Logs.Add($"{DateTime.UtcNow.ToString("O")} **** git ls-remote failed due to SSL cert issue."); + result.SslError = true; + } + result.Logs.Add($"{DateTime.UtcNow.ToString("O")} **** ****"); + result.Logs.Add($"{DateTime.UtcNow.ToString("O")} ***************************************************************************************************************"); + + } + + return result; + } + } +} \ No newline at end of file diff --git a/src/Runner.Listener/Checks/ICheckExtension.cs b/src/Runner.Listener/Checks/ICheckExtension.cs new file mode 100644 index 000000000..d366b8095 --- /dev/null +++ b/src/Runner.Listener/Checks/ICheckExtension.cs @@ -0,0 +1,30 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using GitHub.Runner.Common; + +namespace GitHub.Runner.Listener.Check +{ + public interface ICheckExtension : IExtension + { + int Order { get; } + string CheckName { get; } + string CheckDescription { get; } + string CheckLog { get; } + string HelpLink { get; } + Task RunCheck(string url, string pat); + } + + public class CheckResult + { + public CheckResult() + { + Logs = new List(); + } + + public bool Pass { get; set; } + + public bool SslError { get; set; } + + public List Logs { get; set; } + } +} \ No newline at end of file diff --git a/src/Runner.Listener/Checks/InternetCheck.cs b/src/Runner.Listener/Checks/InternetCheck.cs new file mode 100644 index 000000000..e9e98c51b --- /dev/null +++ b/src/Runner.Listener/Checks/InternetCheck.cs @@ -0,0 +1,59 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using GitHub.Runner.Common; +using GitHub.Runner.Sdk; + +namespace GitHub.Runner.Listener.Check +{ + public sealed class InternetCheck : RunnerService, ICheckExtension + { + private string _logFile = null; + + public int Order => 1; + + public string CheckName => "Internet Connection"; + + public string CheckDescription => "Make sure the actions runner have access to public internet."; + + public string CheckLog => _logFile; + + public string HelpLink => "https://github.com/actions/runner/blob/main/docs/checks/internet.md"; + + public Type ExtensionType => typeof(ICheckExtension); + + public override void Initialize(IHostContext hostContext) + { + base.Initialize(hostContext); + _logFile = Path.Combine(HostContext.GetDirectory(WellKnownDirectory.Diag), StringUtil.Format("{0}_{1:yyyyMMdd-HHmmss}-utc.log", nameof(InternetCheck), DateTime.UtcNow)); + } + + // check runner access to api.github.com + public async Task RunCheck(string url, string pat) + { + await File.AppendAllLinesAsync(_logFile, HostContext.WarnLog()); + await File.AppendAllLinesAsync(_logFile, HostContext.CheckProxy()); + + var checkTasks = new List>(); + checkTasks.Add(CheckUtil.CheckDns("https://api.github.com")); + checkTasks.Add(CheckUtil.CheckPing("https://api.github.com")); + + // We don't need to pass a PAT since it might be a token for GHES. + checkTasks.Add(HostContext.CheckHttpsRequests("https://api.github.com", pat: null, expectedHeader: "X-GitHub-Request-Id")); + + var result = true; + while (checkTasks.Count > 0) + { + var finishedCheckTask = await Task.WhenAny(checkTasks); + var finishedCheck = await finishedCheckTask; + result = result && finishedCheck.Pass; + await File.AppendAllLinesAsync(_logFile, finishedCheck.Logs); + checkTasks.Remove(finishedCheckTask); + } + + await Task.WhenAll(checkTasks); + return result; + } + } +} \ No newline at end of file diff --git a/src/Runner.Listener/Checks/NodeJsCheck.cs b/src/Runner.Listener/Checks/NodeJsCheck.cs new file mode 100644 index 000000000..d74aa02d4 --- /dev/null +++ b/src/Runner.Listener/Checks/NodeJsCheck.cs @@ -0,0 +1,181 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Threading; +using System.Threading.Tasks; +using GitHub.Runner.Common; +using GitHub.Runner.Sdk; + +namespace GitHub.Runner.Listener.Check +{ + public sealed class NodeJsCheck : RunnerService, ICheckExtension + { + private string _logFile = null; + + public int Order => 4; + + public string CheckName => "Node.js Certificate/Proxy Validation"; + + public string CheckDescription => "Make sure the node.js have access to GitHub.com or the GitHub Enterprise Server."; + + public string CheckLog => _logFile; + + public string HelpLink => "https://github.com/actions/runner/blob/main/docs/checks/nodejs.md"; + + public Type ExtensionType => typeof(ICheckExtension); + + public override void Initialize(IHostContext hostContext) + { + base.Initialize(hostContext); + _logFile = Path.Combine(HostContext.GetDirectory(WellKnownDirectory.Diag), StringUtil.Format("{0}_{1:yyyyMMdd-HHmmss}-utc.log", nameof(NodeJsCheck), DateTime.UtcNow)); + } + + // node access to ghes/gh + public async Task RunCheck(string url, string pat) + { + await File.AppendAllLinesAsync(_logFile, HostContext.WarnLog()); + await File.AppendAllLinesAsync(_logFile, HostContext.CheckProxy()); + + // Request to github.com or ghes server + var urlBuilder = new UriBuilder(url); + if (UrlUtil.IsHostedServer(urlBuilder)) + { + urlBuilder.Host = $"api.{urlBuilder.Host}"; + urlBuilder.Path = ""; + } + else + { + urlBuilder.Path = "api/v3"; + } + + var checkNode = await CheckNodeJs(urlBuilder.Uri.AbsoluteUri, pat); + var result = checkNode.Pass; + await File.AppendAllLinesAsync(_logFile, checkNode.Logs); + + // try fix SSL error by providing extra CA certificate. + if (checkNode.SslError) + { + var downloadCert = await HostContext.DownloadExtraCA(urlBuilder.Uri.AbsoluteUri, pat); + await File.AppendAllLinesAsync(_logFile, downloadCert.Logs); + + if (downloadCert.Pass) + { + var recheckNode = await CheckNodeJs(urlBuilder.Uri.AbsoluteUri, pat, extraCA: true); + await File.AppendAllLinesAsync(_logFile, recheckNode.Logs); + if (recheckNode.Pass) + { + await File.AppendAllLinesAsync(_logFile, new[] { $"{DateTime.UtcNow.ToString("O")} Fixed SSL error by providing extra CA certs." }); + } + } + } + + return result; + } + + private async Task CheckNodeJs(string url, string pat, bool extraCA = false) + { + var result = new CheckResult(); + try + { + result.Logs.Add($"{DateTime.UtcNow.ToString("O")} ***************************************************************************************************************"); + result.Logs.Add($"{DateTime.UtcNow.ToString("O")} **** ****"); + result.Logs.Add($"{DateTime.UtcNow.ToString("O")} **** Make Http request to {url} using node.js "); + result.Logs.Add($"{DateTime.UtcNow.ToString("O")} **** ****"); + result.Logs.Add($"{DateTime.UtcNow.ToString("O")} ***************************************************************************************************************"); + + // Request to github.com or ghes server + Uri requestUrl = new Uri(url); + var env = new Dictionary() + { + { "HOSTNAME", requestUrl.Host }, + { "PORT", requestUrl.IsDefaultPort ? (requestUrl.Scheme.ToLowerInvariant() == "https" ? "443" : "80") : requestUrl.Port.ToString() }, + { "PATH", requestUrl.AbsolutePath }, + { "PAT", pat } + }; + + var proxy = HostContext.WebProxy.GetProxy(requestUrl); + if (proxy != null) + { + env["PROXYHOST"] = proxy.Host; + env["PROXYPORT"] = proxy.IsDefaultPort ? (proxy.Scheme.ToLowerInvariant() == "https" ? "443" : "80") : proxy.Port.ToString(); + if (HostContext.WebProxy.HttpProxyUsername != null || + HostContext.WebProxy.HttpsProxyUsername != null) + { + env["PROXYUSERNAME"] = HostContext.WebProxy.HttpProxyUsername ?? HostContext.WebProxy.HttpsProxyUsername; + env["PROXYPASSWORD"] = HostContext.WebProxy.HttpProxyPassword ?? HostContext.WebProxy.HttpsProxyPassword; + } + else + { + env["PROXYUSERNAME"] = ""; + env["PROXYPASSWORD"] = ""; + } + } + else + { + env["PROXYHOST"] = ""; + env["PROXYPORT"] = ""; + env["PROXYUSERNAME"] = ""; + env["PROXYPASSWORD"] = ""; + } + + if (extraCA) + { + env["NODE_EXTRA_CA_CERTS"] = Path.Combine(HostContext.GetDirectory(WellKnownDirectory.Root), "download_ca_cert.pem"); + } + + using (var processInvoker = HostContext.CreateService()) + { + processInvoker.OutputDataReceived += new EventHandler((sender, args) => + { + if (!string.IsNullOrEmpty(args.Data)) + { + result.Logs.Add($"{DateTime.UtcNow.ToString("O")} [STDOUT] {args.Data}"); + } + }); + + processInvoker.ErrorDataReceived += new EventHandler((sender, args) => + { + if (!string.IsNullOrEmpty(args.Data)) + { + result.Logs.Add($"{DateTime.UtcNow.ToString("O")} [STDERR] {args.Data}"); + } + }); + + var makeWebRequestScript = Path.Combine(HostContext.GetDirectory(WellKnownDirectory.Bin), "checkScripts", "makeWebRequest.js"); + var node12 = Path.Combine(HostContext.GetDirectory(WellKnownDirectory.Externals), "node12", "bin", $"node{IOUtil.ExeExtension}"); + result.Logs.Add($"{DateTime.UtcNow.ToString("O")} Run '{node12} \"{makeWebRequestScript}\"' "); + result.Logs.Add($"{DateTime.UtcNow.ToString("O")} {StringUtil.ConvertToJson(env)}"); + await processInvoker.ExecuteAsync( + HostContext.GetDirectory(WellKnownDirectory.Root), + node12, + $"\"{makeWebRequestScript}\"", + env, + true, + CancellationToken.None); + } + + result.Pass = true; + } + catch (Exception ex) + { + result.Pass = false; + result.Logs.Add($"{DateTime.UtcNow.ToString("O")} ***************************************************************************************************************"); + result.Logs.Add($"{DateTime.UtcNow.ToString("O")} **** ****"); + result.Logs.Add($"{DateTime.UtcNow.ToString("O")} **** Make https request to {url} using node.js failed with error: {ex}"); + if (result.Logs.Any(x => x.Contains("UNABLE_TO_VERIFY_LEAF_SIGNATURE") || + x.Contains("UNABLE_TO_GET_ISSUER_CERT_LOCALLY") || + x.Contains("SELF_SIGNED_CERT_IN_CHAIN"))) + { + result.Logs.Add($"{DateTime.UtcNow.ToString("O")} **** Https request failed due to SSL cert issue."); + result.SslError = true; + } + result.Logs.Add($"{DateTime.UtcNow.ToString("O")} **** ****"); + result.Logs.Add($"{DateTime.UtcNow.ToString("O")} ***************************************************************************************************************"); + } + + return result; + } + } +} \ No newline at end of file diff --git a/src/Runner.Listener/CommandSettings.cs b/src/Runner.Listener/CommandSettings.cs index 17c12da91..9b3aecd61 100644 --- a/src/Runner.Listener/CommandSettings.cs +++ b/src/Runner.Listener/CommandSettings.cs @@ -27,6 +27,7 @@ namespace GitHub.Runner.Listener private readonly string[] validFlags = { + Constants.Runner.CommandLine.Flags.Check, Constants.Runner.CommandLine.Flags.Commit, Constants.Runner.CommandLine.Flags.Help, Constants.Runner.CommandLine.Flags.Replace, @@ -60,6 +61,7 @@ namespace GitHub.Runner.Listener public bool Warmup => TestCommand(Constants.Runner.CommandLine.Commands.Warmup); // Flags. + public bool Check => TestFlag(Constants.Runner.CommandLine.Flags.Check); public bool Commit => TestFlag(Constants.Runner.CommandLine.Flags.Commit); public bool Help => TestFlag(Constants.Runner.CommandLine.Flags.Help); public bool Unattended => TestFlag(Constants.Runner.CommandLine.Flags.Unattended); @@ -188,9 +190,20 @@ namespace GitHub.Runner.Listener validator: Validators.NonEmptyValidator); } - public string GetGitHubPersonalAccessToken() + public string GetGitHubPersonalAccessToken(bool required = false) { - return GetArg(name: Constants.Runner.CommandLine.Args.PAT); + if (required) + { + return GetArgOrPrompt( + name: Constants.Runner.CommandLine.Args.PAT, + description: "What is your GitHub personal access token?", + defaultValue: string.Empty, + validator: Validators.NonEmptyValidator); + } + else + { + return GetArg(name: Constants.Runner.CommandLine.Args.PAT); + } } public string GetRunnerRegisterToken() diff --git a/src/Runner.Listener/Configuration/ConfigurationManager.cs b/src/Runner.Listener/Configuration/ConfigurationManager.cs index 4831f2874..7d93a3ad1 100644 --- a/src/Runner.Listener/Configuration/ConfigurationManager.cs +++ b/src/Runner.Listener/Configuration/ConfigurationManager.cs @@ -117,7 +117,7 @@ namespace GitHub.Runner.Listener.Configuration try { // Determine the service deployment type based on connection data. (Hosted/OnPremises) - runnerSettings.IsHostedServer = runnerSettings.GitHubUrl == null || IsHostedServer(new UriBuilder(runnerSettings.GitHubUrl)); + runnerSettings.IsHostedServer = runnerSettings.GitHubUrl == null || UrlUtil.IsHostedServer(new UriBuilder(runnerSettings.GitHubUrl)); // Warn if the Actions server url and GHES server url has different Host if (!runnerSettings.IsHostedServer) @@ -508,13 +508,6 @@ namespace GitHub.Runner.Listener.Configuration return agent; } - private bool IsHostedServer(UriBuilder gitHubUrl) - { - return string.Equals(gitHubUrl.Host, "github.com", StringComparison.OrdinalIgnoreCase) || - string.Equals(gitHubUrl.Host, "www.github.com", StringComparison.OrdinalIgnoreCase) || - string.Equals(gitHubUrl.Host, "github.localhost", StringComparison.OrdinalIgnoreCase); - } - private async Task GetRunnerTokenAsync(CommandSettings command, string githubUrl, string tokenType) { var githubPAT = command.GetGitHubPersonalAccessToken(); @@ -551,7 +544,7 @@ namespace GitHub.Runner.Listener.Configuration if (path.Length == 1) { // org runner - if (IsHostedServer(gitHubUrlBuilder)) + if (UrlUtil.IsHostedServer(gitHubUrlBuilder)) { githubApiUrl = $"{gitHubUrlBuilder.Scheme}://api.{gitHubUrlBuilder.Host}/orgs/{path[0]}/actions/runners/{tokenType}-token"; } @@ -569,7 +562,7 @@ namespace GitHub.Runner.Listener.Configuration repoScope = ""; } - if (IsHostedServer(gitHubUrlBuilder)) + if (UrlUtil.IsHostedServer(gitHubUrlBuilder)) { githubApiUrl = $"{gitHubUrlBuilder.Scheme}://api.{gitHubUrlBuilder.Host}/{repoScope}{path[0]}/{path[1]}/actions/runners/{tokenType}-token"; } @@ -615,7 +608,7 @@ namespace GitHub.Runner.Listener.Configuration { var githubApiUrl = ""; var gitHubUrlBuilder = new UriBuilder(githubUrl); - if (IsHostedServer(gitHubUrlBuilder)) + if (UrlUtil.IsHostedServer(gitHubUrlBuilder)) { githubApiUrl = $"{gitHubUrlBuilder.Scheme}://api.{gitHubUrlBuilder.Host}/actions/runner-registration"; } diff --git a/src/Runner.Listener/Runner.cs b/src/Runner.Listener/Runner.cs index 4ae3407d3..63a10280f 100644 --- a/src/Runner.Listener/Runner.cs +++ b/src/Runner.Listener/Runner.cs @@ -1,6 +1,5 @@ using GitHub.DistributedTask.WebApi; using GitHub.Runner.Listener.Configuration; -using GitHub.Runner.Common.Util; using System; using System.Threading; using System.Threading.Tasks; @@ -11,6 +10,8 @@ using System.Reflection; using System.Runtime.CompilerServices; using GitHub.Runner.Common; using GitHub.Runner.Sdk; +using System.Linq; +using GitHub.Runner.Listener.Check; namespace GitHub.Runner.Listener { @@ -72,6 +73,46 @@ namespace GitHub.Runner.Listener return Constants.Runner.ReturnCode.Success; } + if (command.Check) + { + var url = command.GetUrl(); + var pat = command.GetGitHubPersonalAccessToken(required: true); + var checkExtensions = HostContext.GetService().GetExtensions(); + var sortedChecks = checkExtensions.OrderBy(x => x.Order); + foreach (var check in sortedChecks) + { + _term.WriteLine($"**********************************************************************************************************************"); + _term.WriteLine($"** Check: {check.CheckName}"); + _term.WriteLine($"** Description: {check.CheckDescription}"); + _term.WriteLine($"**********************************************************************************************************************"); + var result = await check.RunCheck(url, pat); + if (!result) + { + _term.WriteLine($"** **"); + _term.WriteLine($"** F A I L **"); + _term.WriteLine($"** **"); + _term.WriteLine($"**********************************************************************************************************************"); + _term.WriteLine($"** Log: {check.CheckLog}"); + _term.WriteLine($"** Help Doc: {check.HelpLink}"); + _term.WriteLine($"**********************************************************************************************************************"); + } + else + { + _term.WriteLine($"** **"); + _term.WriteLine($"** P A S S **"); + _term.WriteLine($"** **"); + _term.WriteLine($"**********************************************************************************************************************"); + _term.WriteLine($"** Log: {check.CheckLog}"); + _term.WriteLine($"**********************************************************************************************************************"); + } + + _term.WriteLine(); + _term.WriteLine(); + } + + return Constants.Runner.ReturnCode.Success; + } + // Configure runner prompt for args if not supplied // Unattended configure mode will not prompt for args if not supplied and error on any missing or invalid value. if (command.Configure) diff --git a/src/Runner.Sdk/Util/UrlUtil.cs b/src/Runner.Sdk/Util/UrlUtil.cs index 30b4f82fe..02e35a8f8 100644 --- a/src/Runner.Sdk/Util/UrlUtil.cs +++ b/src/Runner.Sdk/Util/UrlUtil.cs @@ -4,6 +4,13 @@ namespace GitHub.Runner.Sdk { public static class UrlUtil { + public static bool IsHostedServer(UriBuilder gitHubUrl) + { + return string.Equals(gitHubUrl.Host, "github.com", StringComparison.OrdinalIgnoreCase) || + string.Equals(gitHubUrl.Host, "www.github.com", StringComparison.OrdinalIgnoreCase) || + string.Equals(gitHubUrl.Host, "github.localhost", StringComparison.OrdinalIgnoreCase); + } + public static Uri GetCredentialEmbeddedUrl(Uri baseUrl, string username, string password) { ArgUtil.NotNull(baseUrl, nameof(baseUrl)); diff --git a/src/Test/L0/ServiceInterfacesL0.cs b/src/Test/L0/ServiceInterfacesL0.cs index b3535e40a..4faacbd40 100644 --- a/src/Test/L0/ServiceInterfacesL0.cs +++ b/src/Test/L0/ServiceInterfacesL0.cs @@ -1,4 +1,5 @@ using GitHub.Runner.Listener; +using GitHub.Runner.Listener.Check; using GitHub.Runner.Listener.Configuration; using GitHub.Runner.Worker; using GitHub.Runner.Worker.Handlers; @@ -21,7 +22,8 @@ namespace GitHub.Runner.Common.Tests // Otherwise, the interface needs to whitelisted. var whitelist = new[] { - typeof(ICredentialProvider) + typeof(ICredentialProvider), + typeof(ICheckExtension), }; Validate( assembly: typeof(IMessageListener).GetTypeInfo().Assembly, @@ -85,7 +87,8 @@ namespace GitHub.Runner.Common.Tests continue; } - if (interfaceTypeInfo.FullName.Contains("IConverter")){ + if (interfaceTypeInfo.FullName.Contains("IConverter")) + { continue; }