diff --git a/Makefile.am b/Makefile.am index 5c3cb024..7696c8f5 100644 --- a/Makefile.am +++ b/Makefile.am @@ -207,6 +207,12 @@ goaccess_SOURCES = \ src/xmalloc.c \ src/xmalloc.h +if WITH_SSL +goaccess_SOURCES += \ + src/wsauth.c \ + src/wsauth.h +endif + if USE_SHA1 goaccess_SOURCES += \ src/sha1.c \ diff --git a/configure.ac b/configure.ac index ad869944..7932e991 100644 --- a/configure.ac +++ b/configure.ac @@ -74,6 +74,7 @@ if test "$openssl" = 'yes'; then AC_CHECK_LIB([crypto], [CRYPTO_free],,[AC_MSG_ERROR([crypto library missing])]) AC_CHECK_LIB([ssl], [SSL_CIPHER_standard_name], [AC_DEFINE([HAVE_CIPHER_STD_NAME], 1, [HAVE_CIPHER_STD_NAME])]) fi +AM_CONDITIONAL([WITH_SSL], [test "x$with_openssl" = "xyes"]) # GeoIP AC_ARG_ENABLE([geoip],[AS_HELP_STRING([--enable-geoip],[Enable GeoIP country lookup. Supported types: mmdb, legacy. Default is disabled])],[geoip="$enableval"],[geoip=no]) diff --git a/goaccess.1 b/goaccess.1 index ca7887d0..071e4db5 100644 --- a/goaccess.1 +++ b/goaccess.1 @@ -1,4 +1,4 @@ -.TH goaccess 1 "MAY 2024" GNU+Linux "User Manuals" +.TH goaccess 1 "MAR 2025" GNU+Linux "User Manuals" .SH NAME goaccess \- fast web log analyzer and interactive viewer. .SH SYNOPSIS @@ -460,6 +460,37 @@ Enable real-time HTML output. GoAccess uses its own WebSocket server to push the data from the server to the client. See http://gwsocket.io for more details how the WebSocket server works. .TP +\fB\-\-ws-auth= + +Enable WebSocket authentication using a JSON Web Token (JWT). Optionally, a secret key can be provided for verification. + +.IP +When this option is used, the HTML report will not bootstrap the initial parsed data. Instead, it will only display the report if authentication succeeds. +.IP +The system processes this option as follows: if the argument starts with "jwt:", the part after the colon is treated as either a file path (if it exists, the secret is read from the file) or a direct secret string. If only "jwt" is provided, the secret is sourced from the environment variable \fBGOACCESS_WSAUTH_SECRET\fR, or a default HS256-compatible secret is generated if the variable is unset. See http://gwsocket.io for more details on the underlying WebSocket server. + +.TP +\fB\-\-ws-auth-expire= + +Set the time after which the JWT expires. Defaults to 8 hours (28800 seconds) if not specified. + +.IP +Users can specify the expiration time in various formats. The value is converted to seconds for JWT expiration validation. Supported formats: + +.RS +.IP \(bu 4 +"3600" -> 3600 seconds +.IP \(bu 4 +"24h" -> 24 hours = 86,400 seconds +.IP \(bu 4 +"10m" -> 10 minutes = 600 seconds +.IP \(bu 4 +"10d" -> 10 days = 864,000 seconds +.RE + +.IP +The expiration time controls how long the JWT remains valid after issuance, ensuring secure WebSocket connections. +.TP \fB\-\-ws-url=<[scheme://]url[:port]> URL to which the WebSocket server responds. This is the URL supplied to the WebSocket constructor on the client side. diff --git a/resources/css/app.css b/resources/css/app.css index c2cbbd76..1927d41b 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -30,11 +30,26 @@ h3 { .expandable>td { cursor: pointer; } +.loading-container { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; +} .spinner { color: #999; - left: 50%; - position: absolute; - top: 50%; + margin-bottom: 10px; /* Space between spinner and text */ +} +.app-loading-status { + color: #999; + text-align: center; + text-shadow: 1px 1px 0 #FFF; + text-transform: uppercase; } .powered { bottom: 190px; diff --git a/resources/js/app.js b/resources/js/app.js index abc122b5..c0253170 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -66,9 +66,20 @@ window.GoAccess = window.GoAccess || { var ls = JSON.parse(localStorage.getItem('AppPrefs')); this.AppPrefs = GoAccess.Util.merge(this.AppPrefs, ls); } - if (Object.keys(this.AppWSConn).length) - this.setWebSocket(this.AppWSConn); + // Track if the app has been initialized + this.isAppInitialized = false; + + // If window.goaccessJWT is set and WebSocket is configured, set it up + if (window.goaccessJWT && Object.keys(this.AppWSConn).length) { + this.setWebSocket(this.AppWSConn); + } else { + // No JWT or no WebSocket: initialize immediately + GoAccess.App.initialize(); + if (Object.keys(this.AppWSConn).length) { + this.setWebSocket(this.AppWSConn); + } + } }, getPanelUI: function (panel) { @@ -110,31 +121,69 @@ window.GoAccess = window.GoAccess || { setWebSocket: function (wsConn) { var host = null, pingId = null, uri = null, defURI = null, str = null; + const messages = [ + 'Validating WebSocket tokens... Please wait.', + 'Authenticating WebSocket connection... Please wait.', + 'Verifying WebSocket credentials... Please wait.', + 'Authorizing WebSocket session... Please wait.' + ]; + let currentMessageIndex = 0; + let messageInterval; + + function displayNextMessage() { + if (currentMessageIndex < messages.length) { + document.querySelector('.app-loading-status > small').innerHTML = messages[currentMessageIndex]; + currentMessageIndex++; + } + } + + // Start message rotation + messageInterval = setInterval(displayNextMessage, 100); + defURI = window.location.hostname ? window.location.hostname + ':' + wsConn.port : "localhost" + ':' + wsConn.port; uri = wsConn.url && /^(wss?:\/\/)?[^\/]+:[0-9]{1,5}/.test(wsConn.url) ? wsConn.url : this.buildWSURI(wsConn); str = uri || defURI; str = !/^wss?:\/\//i.test(str) ? (window.location.protocol === "https:" ? 'wss://' : 'ws://') + str : str; + if (window.goaccessJWT) { + const separator = str.includes('?') ? '&' : '?'; + str = str + separator + 'token=' + encodeURIComponent(window.goaccessJWT); + } + var socket = new WebSocket(str); + socket.onopen = function (event) { + clearInterval(messageInterval); + document.querySelector('.app-loading-status > small').innerHTML = 'Authentication successful.'; + this.currDelay = this.wsDelay; this.retries = 0; - // attempt to keep connection alive (e.g., ping/pong) - if (wsConn.ping_interval) + if (wsConn.ping_interval) { pingId = setInterval(() => { socket.send('ping'); }, wsConn.ping_interval * 1E3); - + } GoAccess.Nav.WSOpen(str); }.bind(this); socket.onmessage = function (event) { this.AppState['updated'] = true; this.AppData = JSON.parse(event.data); + + if (window.goaccessJWT && !this.isAppInitialized) { + GoAccess.App.initialize(); + GoAccess.Nav.WSOpen(str); + this.isAppInitialized = true; + } + this.App.renderData(); }.bind(this); socket.onclose = function (event) { + clearInterval(messageInterval); + document.querySelector('.app-loading-status > small').innerHTML = 'Unable to authenticate WebSocket.'; + document.querySelector('.loading-container > .spinner').style.display = 'none'; + GoAccess.Nav.WSClose(); window.clearInterval(pingId); socket = null; @@ -395,7 +444,7 @@ GoAccess.OverallStats = { 'from': data.start_date, 'to': data.end_date, })); - $('#overall').setAttribute('aria-labelledby', 'overall-heading'); + $('#overall').setAttribute('aria-labelledby', 'overall-heading'); // Iterate over general data object for (var x in data) { @@ -703,11 +752,12 @@ GoAccess.Nav = { }, WSOpen: function (str) { + const baseUrl = str.split('?')[0].split('#')[0]; $$('.nav-ws-status', function (item) { item.classList.remove('fa-stop'); item.classList.add('fa-circle'); - item.setAttribute('aria-label', `${GoAccess.i18n.websocket_connected} (${str})`); - item.setAttribute('title', `${GoAccess.i18n.websocket_connected} (${str})`); + item.setAttribute('aria-label', `${GoAccess.i18n.websocket_connected} (${baseUrl})`); + item.setAttribute('title', `${GoAccess.i18n.websocket_connected} (${baseUrl})`); }); }, @@ -1926,16 +1976,20 @@ GoAccess.App = { GoAccess.Tables.reloadTables(); }, - initialize: function () { - this.setInitSort(); - this.setTpls(); + renderPanels: function () { GoAccess.Nav.initialize(); - this.initDom(); GoAccess.OverallStats.initialize(); GoAccess.Panels.initialize(); GoAccess.Charts.initialize(); GoAccess.Tables.initialize(); }, + + initialize: function () { + this.setInitSort(); + this.setTpls(); + this.initDom(); + this.renderPanels(); + }, }; // Adds the visibilitychange EventListener @@ -1961,6 +2015,5 @@ window.onload = function () { 'wsConnection': window.connection || null, 'prefs': window.html_prefs || {}, }); - GoAccess.App.initialize(); }; }()); diff --git a/src/base64.c b/src/base64.c index 029cddca..e8b9e7da 100644 --- a/src/base64.c +++ b/src/base64.c @@ -29,6 +29,7 @@ */ #include +#include #include "base64.h" #include "xmalloc.h" @@ -76,3 +77,87 @@ base64_encode (const void *buf, size_t size) { return str; } + +/* + * base64_decode + * + * Given a Base64 encoded string in 'data', this function decodes it into + * a newly allocated binary buffer. The length of the decoded data is stored + * in *out_len. The caller is responsible for freeing the returned buffer. + * + * Returns NULL on error (for example, if the data's length is not a multiple + * of 4). + */ +char * +base64_decode (const char *data, size_t *out_len) { + size_t decoded_len = 0, i = 0, j = 0, pad = 0, len = 0; + char *out = NULL; + uint32_t triple = 0; + + /* Create a lookup table for decoding. + * For valid Base64 characters 'A'-'Z', 'a'-'z', '0'-'9', '+', '/', + * we place their value; for '=' we simply return 0. + * All other characters are marked as 0x80 (an invalid marker). + */ + static const unsigned char dtable[256] = { + ['A'] = 0,['B'] = 1,['C'] = 2,['D'] = 3, + ['E'] = 4,['F'] = 5,['G'] = 6,['H'] = 7, + ['I'] = 8,['J'] = 9,['K'] = 10,['L'] = 11, + ['M'] = 12,['N'] = 13,['O'] = 14,['P'] = 15, + ['Q'] = 16,['R'] = 17,['S'] = 18,['T'] = 19, + ['U'] = 20,['V'] = 21,['W'] = 22,['X'] = 23, + ['Y'] = 24,['Z'] = 25, + ['a'] = 26,['b'] = 27,['c'] = 28,['d'] = 29, + ['e'] = 30,['f'] = 31,['g'] = 32,['h'] = 33, + ['i'] = 34,['j'] = 35,['k'] = 36,['l'] = 37, + ['m'] = 38,['n'] = 39,['o'] = 40,['p'] = 41, + ['q'] = 42,['r'] = 43,['s'] = 44,['t'] = 45, + ['u'] = 46,['v'] = 47,['w'] = 48,['x'] = 49, + ['y'] = 50,['z'] = 51, + ['0'] = 52,['1'] = 53,['2'] = 54,['3'] = 55, + ['4'] = 56,['5'] = 57,['6'] = 58,['7'] = 59, + ['8'] = 60,['9'] = 61, + ['+'] = 62,['/'] = 63, + ['='] = 0, + /* All other values are implicitly 0 (or you can mark them as invalid + by setting them to 0x80 if you prefer stricter checking). */ + }; + + len = strlen (data); + /* Validate length: Base64 encoded data must be a multiple of 4 */ + if (len % 4 != 0) + return NULL; + + /* Count padding characters at the end */ + if (len) { + if (data[len - 1] == '=') + pad++; + if (len > 1 && data[len - 2] == '=') + pad++; + } + + /* Calculate the length of the decoded data */ + decoded_len = (len / 4) * 3 - pad; + out = (char *) xmalloc (decoded_len + 1); /* +1 for a null terminator if needed */ + + for (i = 0, j = 0; i < len;) { + unsigned int sextet_a = data[i] == '=' ? 0 : dtable[(unsigned char) data[i]]; + unsigned int sextet_b = data[i + 1] == '=' ? 0 : dtable[(unsigned char) data[i + 1]]; + unsigned int sextet_c = data[i + 2] == '=' ? 0 : dtable[(unsigned char) data[i + 2]]; + unsigned int sextet_d = data[i + 3] == '=' ? 0 : dtable[(unsigned char) data[i + 3]]; + i += 4; + + triple = (sextet_a << 18) | (sextet_b << 12) | (sextet_c << 6) | sextet_d; + if (j < decoded_len) + out[j++] = (triple >> 16) & 0xFF; + if (j < decoded_len) + out[j++] = (triple >> 8) & 0xFF; + if (j < decoded_len) + out[j++] = triple & 0xFF; + } + + out[decoded_len] = '\0'; /* Null-terminate the output buffer */ + if (out_len) + *out_len = decoded_len; + return out; +} diff --git a/src/base64.h b/src/base64.h index 8470244e..47814143 100644 --- a/src/base64.h +++ b/src/base64.h @@ -33,5 +33,6 @@ #include char *base64_encode (const void *buf, size_t size); +char *base64_decode (const char *data, size_t *out_len); #endif // for #ifndef BASE64_H diff --git a/src/gwsocket.c b/src/gwsocket.c index 14380b83..b24f42c1 100644 --- a/src/gwsocket.c +++ b/src/gwsocket.c @@ -43,6 +43,7 @@ #include "json.h" #include "settings.h" #include "websocket.h" +#include "wsauth.h" #include "xmalloc.h" /* Allocate memory for a new GWSReader instance. @@ -356,6 +357,12 @@ set_ws_opts (void) { ws_set_config_sslcert (conf.sslcert); if (conf.sslkey) ws_set_config_sslkey (conf.sslkey); +#ifdef HAVE_LIBSSL + if (conf.ws_auth_secret) { + ws_set_config_auth_secret (conf.ws_auth_secret); + ws_set_config_auth_cb (verify_jwt_token); + } +#endif } /* Setup and start the WebSocket threads. */ diff --git a/src/options.c b/src/options.c index f1a07bcb..564c1d25 100644 --- a/src/options.c +++ b/src/options.c @@ -33,7 +33,9 @@ #endif #include +#include #include +#include #include #include #include @@ -47,6 +49,7 @@ #include "error.h" #include "labels.h" #include "util.h" +#include "wsauth.h" #include "xmalloc.h" @@ -153,6 +156,10 @@ static const struct option long_opts[] = { #endif {"time-format" , required_argument , 0 , 0 } , {"ws-url" , required_argument , 0 , 0 } , +#ifdef HAVE_LIBSSL + {"ws-auth" , required_argument , 0 , 0 } , + {"ws-auth-expire" , required_argument , 0 , 0 } , +#endif {"ping-interval" , required_argument , 0 , 0 } , #ifdef HAVE_GEOLOCATION {"geoip-database" , required_argument , 0 , 0 } , @@ -234,6 +241,12 @@ cmd_help (void) " --ssl-key= - Path to TLS/SSL private key.\n" " --user-name= - Run as the specified user.\n" " --ws-url= - URL to which the WebSocket server responds.\n" +#ifdef HAVE_LIBSSL + " --ws-auth= - Enables WebSocket authentication using a\n" + " JSON Web Token (JWT). Optionally, a secret key\n" + " can be provided for verification.\n" + " --ws-auth-expire= - Time after which the JWT expires.\n" +#endif " --ping-interval= - Enable WebSocket ping with specified\n" " interval in seconds.\n" "\n" @@ -365,6 +378,113 @@ set_array_opt (const char *oarg, const char *arr[], int *size, int max) { arr[(*size)++] = oarg; } +#ifdef HAVE_LIBSSL +/* + * parse_ws_auth_expire_option: + * Parses a time duration string and converts it to seconds. + * + * Supported formats: + * "3600" -> 3600 seconds + * "24h" -> 24 hours = 24 * 3600 seconds + * "10m" -> 10 minutes = 10 * 60 seconds + * "10d" -> 10 days = 10 * 86400 seconds + * + * Returns 0 on success, nonzero on failure. + */ +static int +parse_ws_auth_expire_option (const char *input) { + char *endptr = NULL; + double value = 0, multiplier = 0, total_seconds = 0; + + if (!input || *input == '\0') + return -1; // Empty or NULL string + + value = strtod (input, &endptr); + if (endptr == input) { + // No conversion could be performed + return -1; + } + + multiplier = 1.0; + /* Skip any whitespace after the number */ + while (isspace ((unsigned char) *endptr)) + endptr++; + + if (*endptr != '\0') { + /* Expect a single unit character; any extra characters are not allowed */ + char unit = *endptr; + endptr++; + while (isspace ((unsigned char) *endptr)) + endptr++; + + if (*endptr != '\0') { + // Extra unexpected characters + return -1; + } + + switch (unit) { + case 's': + case 'S': + multiplier = 1.0; + break; + case 'm': + case 'M': + multiplier = 60.0; + break; + case 'h': + case 'H': + multiplier = 3600.0; + break; + case 'd': + case 'D': + multiplier = 86400.0; + break; + default: + // Unknown unit + return -1; + } + } + + total_seconds = value * multiplier; + if (total_seconds < 0) + return -1; // Negative durations are not allowed + + /* Store the result in your global configuration */ + conf.ws_auth_expire = (long) total_seconds; + return 0; +} + +static int +parse_ws_auth_option (const char *optarg) { + /* Check if the option starts with "jwt:" */ + if (strncmp (optarg, "jwt:", 4) == 0) { + const char *secret_part = optarg + 4; + + /* Check if the secret is a file path */ + if (access (secret_part, F_OK) == 0) { + /* File exists: read the file content */ + conf.ws_auth_secret = read_secret_from_file (secret_part); + } else { + /* Not a file: use the secret directly */ + conf.ws_auth_secret = strdup (secret_part); + } + return 0; + } + /* Check for plain "jwt" option */ + if (strcmp (optarg, "jwt") == 0) { + /* Try to get secret from environment variable */ + const char *env_secret = getenv ("GOACCESS_WSAUTH_SECRET"); + if (env_secret != NULL) + conf.ws_auth_secret = strdup (env_secret); + else + conf.ws_auth_secret = generate_ws_auth_secret (); + return 0; + } + + return 1; +} +#endif + /* Parse command line long options. */ static void parse_long_opt (const char *name, const char *oarg) { @@ -540,6 +660,17 @@ parse_long_opt (const char *name, const char *oarg) { if (!strcmp ("ws-url", name)) conf.ws_url = oarg; +#ifdef HAVE_LIBSSL + /* WebSocket auth */ + if (!strcmp ("ws-auth", name) && parse_ws_auth_option (oarg) != 0) + FATAL ("Invalid --ws-auth option."); + + /* WebSocket auth JWT expires */ + if (!strcmp ("ws-auth-expire", name) && parse_ws_auth_expire_option (oarg) != 0) + FATAL ("Invalid --ws-auth-expire option."); + +#endif + /* WebSocket ping interval in seconds */ if (!strcmp ("ping-interval", name)) conf.ping_interval = oarg; diff --git a/src/output.c b/src/output.c index 3042e4ec..b97a5d6e 100644 --- a/src/output.c +++ b/src/output.c @@ -46,6 +46,7 @@ #include "settings.h" #include "ui.h" #include "util.h" +#include "wsauth.h" #include "xmalloc.h" #include "tpls.h" @@ -289,7 +290,10 @@ print_html_body (FILE * fp, const char *now) fprintf (fp, "" + "
" "" + "
" + "
" "
" "