Skip to content

Commit 95ca855

Browse files
committed
Added WebSocket authentication options --ws-auth and --ws-auth-expire.
This commit introduces two new command-line options to enhance WebSocket security in GoAccess: --ws-auth=<jwt[:secret]>: Enables JWT-based authentication for WebSocket connections. Supports an optional secret (as a string or file path) for token verification. Without a secret, it falls back to the GOACCESS_WSAUTH_SECRET environment variable or generates an HS256-compatible secret. When enabled, the HTML report delays bootstrapping initial data until authentication succeeds. --ws-auth-expire=<secs>: Sets the JWT expiration time, defaulting to 3600 seconds (1 hour). Supports flexible formats like "24h", "10m", or "10d" for user convenience. These options strengthen real-time HTML output security by ensuring only authenticated clients access the WebSocket feed. Closes #2794, #1133, #2411
1 parent 8056805 commit 95ca855

File tree

19 files changed

+930
-46
lines changed

19 files changed

+930
-46
lines changed

Makefile.am

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,12 @@ goaccess_SOURCES = \
207207
src/xmalloc.c \
208208
src/xmalloc.h
209209

210+
if WITH_SSL
211+
goaccess_SOURCES += \
212+
src/wsauth.c \
213+
src/wsauth.h
214+
endif
215+
210216
if USE_SHA1
211217
goaccess_SOURCES += \
212218
src/sha1.c \

configure.ac

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ if test "$openssl" = 'yes'; then
7474
AC_CHECK_LIB([crypto], [CRYPTO_free],,[AC_MSG_ERROR([crypto library missing])])
7575
AC_CHECK_LIB([ssl], [SSL_CIPHER_standard_name], [AC_DEFINE([HAVE_CIPHER_STD_NAME], 1, [HAVE_CIPHER_STD_NAME])])
7676
fi
77+
AM_CONDITIONAL([WITH_SSL], [test "x$with_openssl" = "xyes"])
7778

7879
# GeoIP
7980
AC_ARG_ENABLE([geoip],[AS_HELP_STRING([--enable-geoip],[Enable GeoIP country lookup. Supported types: mmdb, legacy. Default is disabled])],[geoip="$enableval"],[geoip=no])

goaccess.1

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
.TH goaccess 1 "MAY 2024" GNU+Linux "User Manuals"
1+
.TH goaccess 1 "MAR 2025" GNU+Linux "User Manuals"
22
.SH NAME
33
goaccess \- fast web log analyzer and interactive viewer.
44
.SH SYNOPSIS
@@ -460,6 +460,37 @@ Enable real-time HTML output.
460460
GoAccess uses its own WebSocket server to push the data from the server to the
461461
client. See http://gwsocket.io for more details how the WebSocket server works.
462462
.TP
463+
\fB\-\-ws-auth=<jwt[:secret]>
464+
465+
Enable WebSocket authentication using a JSON Web Token (JWT). Optionally, a secret key can be provided for verification.
466+
467+
.IP
468+
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.
469+
.IP
470+
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.
471+
472+
.TP
473+
\fB\-\-ws-auth-expire=<secs>
474+
475+
Set the time after which the JWT expires. Defaults to 8 hours (28800 seconds) if not specified.
476+
477+
.IP
478+
Users can specify the expiration time in various formats. The value is converted to seconds for JWT expiration validation. Supported formats:
479+
480+
.RS
481+
.IP \(bu 4
482+
"3600" -> 3600 seconds
483+
.IP \(bu 4
484+
"24h" -> 24 hours = 86,400 seconds
485+
.IP \(bu 4
486+
"10m" -> 10 minutes = 600 seconds
487+
.IP \(bu 4
488+
"10d" -> 10 days = 864,000 seconds
489+
.RE
490+
491+
.IP
492+
The expiration time controls how long the JWT remains valid after issuance, ensuring secure WebSocket connections.
493+
.TP
463494
\fB\-\-ws-url=<[scheme://]url[:port]>
464495
URL to which the WebSocket server responds. This is the URL supplied to the
465496
WebSocket constructor on the client side.

resources/css/app.css

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,11 +30,26 @@ h3 {
3030
.expandable>td {
3131
cursor: pointer;
3232
}
33+
.loading-container {
34+
position: fixed;
35+
top: 0;
36+
left: 0;
37+
width: 100%;
38+
height: 100%;
39+
display: flex;
40+
flex-direction: column;
41+
justify-content: center;
42+
align-items: center;
43+
}
3344
.spinner {
3445
color: #999;
35-
left: 50%;
36-
position: absolute;
37-
top: 50%;
46+
margin-bottom: 10px; /* Space between spinner and text */
47+
}
48+
.app-loading-status {
49+
color: #999;
50+
text-align: center;
51+
text-shadow: 1px 1px 0 #FFF;
52+
text-transform: uppercase;
3853
}
3954
.powered {
4055
bottom: 190px;

resources/js/app.js

Lines changed: 66 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -66,9 +66,20 @@ window.GoAccess = window.GoAccess || {
6666
var ls = JSON.parse(localStorage.getItem('AppPrefs'));
6767
this.AppPrefs = GoAccess.Util.merge(this.AppPrefs, ls);
6868
}
69-
if (Object.keys(this.AppWSConn).length)
70-
this.setWebSocket(this.AppWSConn);
7169

70+
// Track if the app has been initialized
71+
this.isAppInitialized = false;
72+
73+
// If window.goaccessJWT is set and WebSocket is configured, set it up
74+
if (window.goaccessJWT && Object.keys(this.AppWSConn).length) {
75+
this.setWebSocket(this.AppWSConn);
76+
} else {
77+
// No JWT or no WebSocket: initialize immediately
78+
GoAccess.App.initialize();
79+
if (Object.keys(this.AppWSConn).length) {
80+
this.setWebSocket(this.AppWSConn);
81+
}
82+
}
7283
},
7384

7485
getPanelUI: function (panel) {
@@ -110,31 +121,69 @@ window.GoAccess = window.GoAccess || {
110121
setWebSocket: function (wsConn) {
111122
var host = null, pingId = null, uri = null, defURI = null, str = null;
112123

124+
const messages = [
125+
'Validating WebSocket tokens... Please wait.',
126+
'Authenticating WebSocket connection... Please wait.',
127+
'Verifying WebSocket credentials... Please wait.',
128+
'Authorizing WebSocket session... Please wait.'
129+
];
130+
let currentMessageIndex = 0;
131+
let messageInterval;
132+
133+
function displayNextMessage() {
134+
if (currentMessageIndex < messages.length) {
135+
document.querySelector('.app-loading-status > small').innerHTML = messages[currentMessageIndex];
136+
currentMessageIndex++;
137+
}
138+
}
139+
140+
// Start message rotation
141+
messageInterval = setInterval(displayNextMessage, 100);
142+
113143
defURI = window.location.hostname ? window.location.hostname + ':' + wsConn.port : "localhost" + ':' + wsConn.port;
114144
uri = wsConn.url && /^(wss?:\/\/)?[^\/]+:[0-9]{1,5}/.test(wsConn.url) ? wsConn.url : this.buildWSURI(wsConn);
115145

116146
str = uri || defURI;
117147
str = !/^wss?:\/\//i.test(str) ? (window.location.protocol === "https:" ? 'wss://' : 'ws://') + str : str;
118148

149+
if (window.goaccessJWT) {
150+
const separator = str.includes('?') ? '&' : '?';
151+
str = str + separator + 'token=' + encodeURIComponent(window.goaccessJWT);
152+
}
153+
119154
var socket = new WebSocket(str);
155+
120156
socket.onopen = function (event) {
157+
clearInterval(messageInterval);
158+
document.querySelector('.app-loading-status > small').innerHTML = 'Authentication successful.';
159+
121160
this.currDelay = this.wsDelay;
122161
this.retries = 0;
123162

124-
// attempt to keep connection alive (e.g., ping/pong)
125-
if (wsConn.ping_interval)
163+
if (wsConn.ping_interval) {
126164
pingId = setInterval(() => { socket.send('ping'); }, wsConn.ping_interval * 1E3);
127-
165+
}
128166
GoAccess.Nav.WSOpen(str);
129167
}.bind(this);
130168

131169
socket.onmessage = function (event) {
132170
this.AppState['updated'] = true;
133171
this.AppData = JSON.parse(event.data);
172+
173+
if (window.goaccessJWT && !this.isAppInitialized) {
174+
GoAccess.App.initialize();
175+
GoAccess.Nav.WSOpen(str);
176+
this.isAppInitialized = true;
177+
}
178+
134179
this.App.renderData();
135180
}.bind(this);
136181

137182
socket.onclose = function (event) {
183+
clearInterval(messageInterval);
184+
document.querySelector('.app-loading-status > small').innerHTML = 'Unable to authenticate WebSocket.';
185+
document.querySelector('.loading-container > .spinner').style.display = 'none';
186+
138187
GoAccess.Nav.WSClose();
139188
window.clearInterval(pingId);
140189
socket = null;
@@ -395,7 +444,7 @@ GoAccess.OverallStats = {
395444
'from': data.start_date,
396445
'to': data.end_date,
397446
}));
398-
$('#overall').setAttribute('aria-labelledby', 'overall-heading');
447+
$('#overall').setAttribute('aria-labelledby', 'overall-heading');
399448

400449
// Iterate over general data object
401450
for (var x in data) {
@@ -703,11 +752,12 @@ GoAccess.Nav = {
703752
},
704753

705754
WSOpen: function (str) {
755+
const baseUrl = str.split('?')[0].split('#')[0];
706756
$$('.nav-ws-status', function (item) {
707757
item.classList.remove('fa-stop');
708758
item.classList.add('fa-circle');
709-
item.setAttribute('aria-label', `${GoAccess.i18n.websocket_connected} (${str})`);
710-
item.setAttribute('title', `${GoAccess.i18n.websocket_connected} (${str})`);
759+
item.setAttribute('aria-label', `${GoAccess.i18n.websocket_connected} (${baseUrl})`);
760+
item.setAttribute('title', `${GoAccess.i18n.websocket_connected} (${baseUrl})`);
711761
});
712762
},
713763

@@ -1926,16 +1976,20 @@ GoAccess.App = {
19261976
GoAccess.Tables.reloadTables();
19271977
},
19281978

1929-
initialize: function () {
1930-
this.setInitSort();
1931-
this.setTpls();
1979+
renderPanels: function () {
19321980
GoAccess.Nav.initialize();
1933-
this.initDom();
19341981
GoAccess.OverallStats.initialize();
19351982
GoAccess.Panels.initialize();
19361983
GoAccess.Charts.initialize();
19371984
GoAccess.Tables.initialize();
19381985
},
1986+
1987+
initialize: function () {
1988+
this.setInitSort();
1989+
this.setTpls();
1990+
this.initDom();
1991+
this.renderPanels();
1992+
},
19391993
};
19401994

19411995
// Adds the visibilitychange EventListener
@@ -1961,6 +2015,5 @@ window.onload = function () {
19612015
'wsConnection': window.connection || null,
19622016
'prefs': window.html_prefs || {},
19632017
});
1964-
GoAccess.App.initialize();
19652018
};
19662019
}());

src/base64.c

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
*/
3030

3131
#include <string.h>
32+
#include <inttypes.h>
3233

3334
#include "base64.h"
3435
#include "xmalloc.h"
@@ -76,3 +77,87 @@ base64_encode (const void *buf, size_t size) {
7677

7778
return str;
7879
}
80+
81+
/*
82+
* base64_decode
83+
*
84+
* Given a Base64 encoded string in 'data', this function decodes it into
85+
* a newly allocated binary buffer. The length of the decoded data is stored
86+
* in *out_len. The caller is responsible for freeing the returned buffer.
87+
*
88+
* Returns NULL on error (for example, if the data's length is not a multiple
89+
* of 4).
90+
*/
91+
char *
92+
base64_decode (const char *data, size_t *out_len) {
93+
size_t decoded_len = 0, i = 0, j = 0, pad = 0, len = 0;
94+
char *out = NULL;
95+
uint32_t triple = 0;
96+
97+
/* Create a lookup table for decoding.
98+
* For valid Base64 characters 'A'-'Z', 'a'-'z', '0'-'9', '+', '/',
99+
* we place their value; for '=' we simply return 0.
100+
* All other characters are marked as 0x80 (an invalid marker).
101+
*/
102+
static const unsigned char dtable[256] = {
103+
['A'] = 0,['B'] = 1,['C'] = 2,['D'] = 3,
104+
['E'] = 4,['F'] = 5,['G'] = 6,['H'] = 7,
105+
['I'] = 8,['J'] = 9,['K'] = 10,['L'] = 11,
106+
['M'] = 12,['N'] = 13,['O'] = 14,['P'] = 15,
107+
['Q'] = 16,['R'] = 17,['S'] = 18,['T'] = 19,
108+
['U'] = 20,['V'] = 21,['W'] = 22,['X'] = 23,
109+
['Y'] = 24,['Z'] = 25,
110+
['a'] = 26,['b'] = 27,['c'] = 28,['d'] = 29,
111+
['e'] = 30,['f'] = 31,['g'] = 32,['h'] = 33,
112+
['i'] = 34,['j'] = 35,['k'] = 36,['l'] = 37,
113+
['m'] = 38,['n'] = 39,['o'] = 40,['p'] = 41,
114+
['q'] = 42,['r'] = 43,['s'] = 44,['t'] = 45,
115+
['u'] = 46,['v'] = 47,['w'] = 48,['x'] = 49,
116+
['y'] = 50,['z'] = 51,
117+
['0'] = 52,['1'] = 53,['2'] = 54,['3'] = 55,
118+
['4'] = 56,['5'] = 57,['6'] = 58,['7'] = 59,
119+
['8'] = 60,['9'] = 61,
120+
['+'] = 62,['/'] = 63,
121+
['='] = 0,
122+
/* All other values are implicitly 0 (or you can mark them as invalid
123+
by setting them to 0x80 if you prefer stricter checking). */
124+
};
125+
126+
len = strlen (data);
127+
/* Validate length: Base64 encoded data must be a multiple of 4 */
128+
if (len % 4 != 0)
129+
return NULL;
130+
131+
/* Count padding characters at the end */
132+
if (len) {
133+
if (data[len - 1] == '=')
134+
pad++;
135+
if (len > 1 && data[len - 2] == '=')
136+
pad++;
137+
}
138+
139+
/* Calculate the length of the decoded data */
140+
decoded_len = (len / 4) * 3 - pad;
141+
out = (char *) xmalloc (decoded_len + 1); /* +1 for a null terminator if needed */
142+
143+
for (i = 0, j = 0; i < len;) {
144+
unsigned int sextet_a = data[i] == '=' ? 0 : dtable[(unsigned char) data[i]];
145+
unsigned int sextet_b = data[i + 1] == '=' ? 0 : dtable[(unsigned char) data[i + 1]];
146+
unsigned int sextet_c = data[i + 2] == '=' ? 0 : dtable[(unsigned char) data[i + 2]];
147+
unsigned int sextet_d = data[i + 3] == '=' ? 0 : dtable[(unsigned char) data[i + 3]];
148+
i += 4;
149+
150+
triple = (sextet_a << 18) | (sextet_b << 12) | (sextet_c << 6) | sextet_d;
151+
if (j < decoded_len)
152+
out[j++] = (triple >> 16) & 0xFF;
153+
if (j < decoded_len)
154+
out[j++] = (triple >> 8) & 0xFF;
155+
if (j < decoded_len)
156+
out[j++] = triple & 0xFF;
157+
}
158+
159+
out[decoded_len] = '\0'; /* Null-terminate the output buffer */
160+
if (out_len)
161+
*out_len = decoded_len;
162+
return out;
163+
}

src/base64.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,5 +33,6 @@
3333
#include <stddef.h>
3434

3535
char *base64_encode (const void *buf, size_t size);
36+
char *base64_decode (const char *data, size_t *out_len);
3637

3738
#endif // for #ifndef BASE64_H

src/gwsocket.c

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
#include "json.h"
4444
#include "settings.h"
4545
#include "websocket.h"
46+
#include "wsauth.h"
4647
#include "xmalloc.h"
4748

4849
/* Allocate memory for a new GWSReader instance.
@@ -356,6 +357,12 @@ set_ws_opts (void) {
356357
ws_set_config_sslcert (conf.sslcert);
357358
if (conf.sslkey)
358359
ws_set_config_sslkey (conf.sslkey);
360+
#ifdef HAVE_LIBSSL
361+
if (conf.ws_auth_secret) {
362+
ws_set_config_auth_secret (conf.ws_auth_secret);
363+
ws_set_config_auth_cb (verify_jwt_token);
364+
}
365+
#endif
359366
}
360367

361368
/* Setup and start the WebSocket threads. */

0 commit comments

Comments
 (0)