mirror of
https://github.com/warmcat/libwebsockets.git
synced 2025-03-09 00:00:04 +01:00
http: auth digest
This commit is contained in:
parent
42cb5ffbbe
commit
fdfde2ce0b
10 changed files with 428 additions and 2 deletions
|
@ -179,6 +179,10 @@ if (LWS_WITH_SECURE_STREAMS_AUTH_SIGV4)
|
|||
set(LWS_WITH_GENCRYPTO 1)
|
||||
endif()
|
||||
|
||||
if (LWS_WITH_HTTP_DIGEST_AUTH)
|
||||
set(LWS_WITH_GENCRYPTO 1)
|
||||
endif()
|
||||
|
||||
if (APPLE)
|
||||
set(LWS_ROLE_DBUS 0)
|
||||
endif()
|
||||
|
|
|
@ -159,6 +159,7 @@ option(LWS_WITH_SYS_ASYNC_DNS "Nonblocking internal IPv4 + IPv6 DNS resolver" OF
|
|||
option(LWS_WITH_SYS_NTPCLIENT "Build in tiny ntpclient good for tls date validation and run via lws_system" OFF)
|
||||
option(LWS_WITH_SYS_DHCP_CLIENT "Build in tiny DHCP client" OFF)
|
||||
option(LWS_WITH_HTTP_BASIC_AUTH "Support Basic Auth" ON)
|
||||
option(LWS_WITH_HTTP_DIGEST_AUTH "Support Digest Auth (caution deprecated crypto)" ON)
|
||||
option(LWS_WITH_HTTP_UNCOMMON_HEADERS "Include less common http header support" ON)
|
||||
option(LWS_WITH_SYS_STATE "lws_system state support" ON)
|
||||
option(LWS_WITH_SYS_SMD "Lws System Message Distribution" ON)
|
||||
|
|
|
@ -179,6 +179,7 @@
|
|||
#cmakedefine LWS_WITH_GZINFLATE
|
||||
#cmakedefine LWS_WITH_HTTP2
|
||||
#cmakedefine LWS_WITH_HTTP_BASIC_AUTH
|
||||
#cmakedefine LWS_WITH_HTTP_DIGEST_AUTH
|
||||
#cmakedefine LWS_WITH_HTTP_BROTLI
|
||||
#cmakedefine LWS_HTTP_HEADERS_ALL
|
||||
#cmakedefine LWS_WITH_HTTP_PROXY
|
||||
|
|
|
@ -244,6 +244,8 @@ struct lws_client_connect_info {
|
|||
* context template to take a copy of for this wsi. Used to isolate
|
||||
* wsi-specific logs into their own stream or file.
|
||||
*/
|
||||
const char *auth_username;
|
||||
const char *auth_password;
|
||||
|
||||
/* Add new things just above here ---^
|
||||
* This is part of the ABI, don't needlessly break compatibility
|
||||
|
|
|
@ -361,6 +361,8 @@ lws_client_connect_via_info(const struct lws_client_connect_info *i)
|
|||
lws_snprintf(buf_localport, sizeof(buf_localport), "%u", i->local_port);
|
||||
cisin[CIS_LOCALPORT] = buf_localport;
|
||||
cisin[CIS_ALPN] = i->alpn;
|
||||
cisin[CIS_USERNAME] = i->auth_username;
|
||||
cisin[CIS_PASSWORD] = i->auth_password;
|
||||
|
||||
if (lws_client_stash_create(wsi, cisin))
|
||||
goto bail;
|
||||
|
|
|
@ -187,6 +187,12 @@ lws_client_connect_2_dnsreq(struct lws *wsi)
|
|||
goto solo;
|
||||
}
|
||||
|
||||
if (wsi->keepalive_rejected) {
|
||||
lwsl_notice("defeating pipelining due to no "
|
||||
"keepalive on server\n");
|
||||
goto solo;
|
||||
}
|
||||
|
||||
/* only pipeline things we associate with being a stream */
|
||||
|
||||
if (meth && strcmp(meth, "RAW") && strcmp(meth, "GET") &&
|
||||
|
|
|
@ -137,6 +137,13 @@ __lws_reset_wsi(struct lws *wsi)
|
|||
lws_buflist_destroy_all_segments(&wsi->http.buflist_post_body);
|
||||
#endif
|
||||
|
||||
#if defined(LWS_WITH_HTTP_DIGEST_AUTH)
|
||||
if (wsi->http.digest_auth_hdr) {
|
||||
lws_free(wsi->http.digest_auth_hdr);
|
||||
wsi->http.digest_auth_hdr = NULL;
|
||||
}
|
||||
#endif
|
||||
|
||||
#if defined(LWS_WITH_SERVER)
|
||||
lws_dll2_remove(&wsi->listen_list);
|
||||
#endif
|
||||
|
|
|
@ -222,7 +222,8 @@ enum {
|
|||
CIS_IFACE,
|
||||
CIS_ALPN,
|
||||
CIS_LOCALPORT,
|
||||
|
||||
CIS_USERNAME,
|
||||
CIS_PASSWORD,
|
||||
|
||||
CIS_COUNT
|
||||
};
|
||||
|
|
|
@ -579,6 +579,368 @@ lws_http_client_http_response(struct lws *wsi)
|
|||
}
|
||||
#endif
|
||||
|
||||
|
||||
#if defined(LWS_WITH_HTTP_DIGEST_AUTH)
|
||||
|
||||
static const char *digest_toks[] = {
|
||||
"Digest", // 1 << 0
|
||||
"username", // 1 << 1
|
||||
"realm", // 1 << 2
|
||||
"nonce", // 1 << 3
|
||||
"uri", // 1 << 4 optional
|
||||
"response", // 1 << 5
|
||||
"opaque", // 1 << 6
|
||||
"qop", // 1 << 7
|
||||
"algorithm" // 1 << 8
|
||||
"nc", // 1 << 9
|
||||
"cnonce", // 1 << 10
|
||||
"domain", // 1 << 11
|
||||
};
|
||||
|
||||
#define PEND_NAME_EQ -1
|
||||
#define PEND_DELIM -2
|
||||
|
||||
enum lws_check_basic_auth_results
|
||||
lws_http_digest_auth(struct lws* wsi)
|
||||
{
|
||||
uint8_t nonce[128], response[LWS_GENHASH_LARGEST], qop[32];
|
||||
int seen = 0, n, pend = -1, skipping = 0;
|
||||
struct lws_tokenize ts;
|
||||
char resp_username[32];
|
||||
lws_tokenize_elem e;
|
||||
char realm[64];
|
||||
char b64[512];
|
||||
int m, ml, fi;
|
||||
|
||||
/* Did he send auth? */
|
||||
ml = lws_hdr_total_length(wsi, WSI_TOKEN_HTTP_WWW_AUTHENTICATE);
|
||||
if (!ml)
|
||||
return LCBA_FAILED_AUTH;
|
||||
|
||||
/* Disallow fragmentation monkey business */
|
||||
|
||||
fi = wsi->http.ah->frag_index[WSI_TOKEN_HTTP_WWW_AUTHENTICATE];
|
||||
if (wsi->http.ah->frags[fi].nfrag) {
|
||||
lwsl_wsi_err(wsi, "fragmented http auth header not allowed\n");
|
||||
return LCBA_FAILED_AUTH;
|
||||
}
|
||||
|
||||
m = lws_hdr_copy(wsi, b64, sizeof(b64), WSI_TOKEN_HTTP_WWW_AUTHENTICATE);
|
||||
if (m < 7) {
|
||||
lwsl_wsi_err(wsi, "HTTP auth length bad\n");
|
||||
return LCBA_END_TRANSACTION;
|
||||
}
|
||||
|
||||
/*
|
||||
* We are expecting AUTHORIZATION to have something like this
|
||||
*
|
||||
* Authorization: Digest
|
||||
* username="Mufasa",
|
||||
* realm="testrealm@host.com",
|
||||
* nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093",
|
||||
* uri="/dir/index.html",
|
||||
* response="e966c932a9242554e42c8ee200cec7f6",
|
||||
* opaque="5ccc069c403ebaf9f0171e9517f40e41"
|
||||
*
|
||||
* but the order, whitespace etc is quite open. uri is optional
|
||||
*/
|
||||
|
||||
ts.start = b64;
|
||||
ts.len = (size_t)m;
|
||||
ts.flags = LWS_TOKENIZE_F_MINUS_NONTERM | LWS_TOKENIZE_F_NO_INTEGERS |
|
||||
LWS_TOKENIZE_F_RFC7230_DELIMS;
|
||||
|
||||
do {
|
||||
e = lws_tokenize(&ts);
|
||||
switch (e) {
|
||||
case LWS_TOKZE_TOKEN:
|
||||
if (pend == 8) {
|
||||
/* algorithm name */
|
||||
|
||||
if (strncasecmp(ts.token, "MD5", ts.token_len)) {
|
||||
lwsl_wsi_err(wsi, "wrong alg %.*s\n",
|
||||
(int)ts.token_len,
|
||||
ts.token);
|
||||
return LCBA_END_TRANSACTION;
|
||||
}
|
||||
pend = PEND_DELIM;
|
||||
break;
|
||||
}
|
||||
if (strncasecmp(ts.token, "Digest", ts.token_len)) {
|
||||
skipping = 1;
|
||||
seen |= 1 << 0;
|
||||
break;
|
||||
}
|
||||
if (seen) /* we must be first and one time */
|
||||
return LCBA_END_TRANSACTION;
|
||||
|
||||
seen |= 1 << 15;
|
||||
pend = PEND_NAME_EQ;
|
||||
break;
|
||||
|
||||
case LWS_TOKZE_TOKEN_NAME_EQUALS:
|
||||
if (skipping)
|
||||
break;
|
||||
if (!(seen & (1 << 15)) || pend != -1)
|
||||
/* no auth type token or disordered */
|
||||
return LCBA_END_TRANSACTION;
|
||||
|
||||
for (n = 0; n < (int)LWS_ARRAY_SIZE(digest_toks); n++)
|
||||
if (!strncmp(ts.token, digest_toks[n], ts.token_len))
|
||||
break;
|
||||
|
||||
if (n == LWS_ARRAY_SIZE(digest_toks)) {
|
||||
lwsl_wsi_notice(wsi, "c: '%.*s'\n",
|
||||
(int)ts.token_len,
|
||||
ts.token);
|
||||
|
||||
return LCBA_END_TRANSACTION;
|
||||
}
|
||||
|
||||
if (seen & (1 << n) || !(seen & (1 << 15)))
|
||||
/* dup or no auth type token */
|
||||
return LCBA_END_TRANSACTION;
|
||||
|
||||
seen |= 1 << n;
|
||||
pend = n;
|
||||
break;
|
||||
|
||||
case LWS_TOKZE_QUOTED_STRING:
|
||||
if (skipping)
|
||||
break;
|
||||
if (pend < 0)
|
||||
return LCBA_END_TRANSACTION;
|
||||
|
||||
switch (pend) {
|
||||
case 1: /* username */
|
||||
if (ts.token_len >= (int)sizeof(resp_username))
|
||||
return LCBA_END_TRANSACTION;
|
||||
|
||||
strncpy(resp_username, ts.token, ts.token_len);
|
||||
break;
|
||||
case 2: /* realm */
|
||||
if (ts.token_len >= (int)sizeof(realm))
|
||||
return LCBA_END_TRANSACTION;
|
||||
|
||||
strncpy(realm, ts.token, ts.token_len);
|
||||
realm[ts.token_len] = 0;
|
||||
break;
|
||||
case 3: /* nonce */
|
||||
if (ts.token_len >= (int)sizeof(nonce))
|
||||
return LCBA_END_TRANSACTION;
|
||||
|
||||
strncpy((char *)nonce, ts.token, ts.token_len);
|
||||
nonce[ts.token_len] = 0;
|
||||
break;
|
||||
case 4: /* uri */
|
||||
break;
|
||||
case 5: /* response */
|
||||
if (ts.token_len !=
|
||||
lws_genhash_size(LWS_GENHASH_TYPE_MD5) * 2)
|
||||
return LCBA_END_TRANSACTION;
|
||||
|
||||
if (lws_hex_len_to_byte_array(ts.token, ts.token_len,
|
||||
response,
|
||||
sizeof(response)) < 0)
|
||||
return LCBA_END_TRANSACTION;
|
||||
break;
|
||||
case 6: /* opaque */
|
||||
break;
|
||||
case 7: /* qop */
|
||||
if (strncmp(ts.token, "auth", ts.token_len))
|
||||
return LCBA_END_TRANSACTION;
|
||||
|
||||
strncpy((char *)qop, ts.token, ts.token_len);
|
||||
qop[ts.token_len] = 0;
|
||||
break;
|
||||
}
|
||||
pend = PEND_DELIM;
|
||||
break;
|
||||
|
||||
case LWS_TOKZE_DELIMITER:
|
||||
if (*ts.token == ',') {
|
||||
if (skipping)
|
||||
break;
|
||||
if (pend != PEND_DELIM)
|
||||
return LCBA_END_TRANSACTION;
|
||||
|
||||
pend = PEND_NAME_EQ;
|
||||
break;
|
||||
}
|
||||
if (*ts.token == ';') {
|
||||
if (skipping) {
|
||||
/* try again with this one */
|
||||
skipping = 0;
|
||||
break;
|
||||
}
|
||||
/* it's the end */
|
||||
e = LWS_TOKZE_ENDED;
|
||||
break;
|
||||
}
|
||||
break;
|
||||
|
||||
case LWS_TOKZE_ENDED:
|
||||
break;
|
||||
|
||||
default:
|
||||
lwsl_wsi_notice(wsi, "unexpected token %d\n", e);
|
||||
return LCBA_END_TRANSACTION;
|
||||
}
|
||||
|
||||
} while (e > 0);
|
||||
|
||||
if (e != LWS_TOKZE_ENDED)
|
||||
return LCBA_END_TRANSACTION;
|
||||
|
||||
/* we got all the parts we care about? */
|
||||
|
||||
// Realm, nonce
|
||||
if ((seen & 0xc) != 0xc) {
|
||||
lwsl_wsi_err(wsi,
|
||||
"%s: Not all digest auth tokens found! "
|
||||
"m: 0x%x\nServer sent: %s",
|
||||
__func__, seen & 0x81ef, b64);
|
||||
|
||||
return LCBA_END_TRANSACTION;
|
||||
}
|
||||
|
||||
lwsl_wsi_info(wsi, "HTTP digest auth realm %s nonce %s\n", realm, nonce);
|
||||
|
||||
if (wsi->stash && wsi->stash->cis[CIS_METHOD] &&
|
||||
wsi->stash->cis[CIS_PATH]) {
|
||||
char *username = wsi->stash->cis[CIS_USERNAME];
|
||||
char *password = wsi->stash->cis[CIS_PASSWORD];
|
||||
uint8_t digest[LWS_GENHASH_LARGEST * 2 + 1];
|
||||
char *uri = wsi->stash->cis[CIS_PATH];
|
||||
char a1[LWS_GENHASH_LARGEST * 2 + 1];
|
||||
char a2[LWS_GENHASH_LARGEST * 2 + 1];
|
||||
char nc[sizeof(int) * 2 + 1];
|
||||
struct lws_genhash_ctx hc;
|
||||
int ncount = 1, ssl;
|
||||
const char *a, *p;
|
||||
struct lws *nwsi;
|
||||
char cnonce[128];
|
||||
size_t l;
|
||||
|
||||
if (!wsi->http.digest_auth_hdr) {
|
||||
l = sizeof(a1) + sizeof(a2) + sizeof(nonce) +
|
||||
(sizeof(ncount) *2) + sizeof(response) +
|
||||
sizeof(cnonce) + sizeof(qop) + strlen(uri) +
|
||||
strlen(username) + strlen(password) +
|
||||
strlen(realm) + 111;
|
||||
|
||||
wsi->http.digest_auth_hdr = lws_malloc(l, __func__);
|
||||
if (!wsi->http.digest_auth_hdr)
|
||||
return -1;
|
||||
}
|
||||
|
||||
n = lws_snprintf(wsi->http.digest_auth_hdr, l, "%s:%s:%s",
|
||||
username, realm, password);
|
||||
|
||||
if (lws_genhash_init(&hc, LWS_GENHASH_TYPE_MD5) ||
|
||||
lws_genhash_update(&hc,
|
||||
wsi->http.digest_auth_hdr,
|
||||
(size_t)n) ||
|
||||
lws_genhash_destroy(&hc, digest)) {
|
||||
lws_genhash_destroy(&hc, NULL);
|
||||
|
||||
goto bail;
|
||||
}
|
||||
|
||||
lws_hex_from_byte_array(digest,
|
||||
lws_genhash_size(LWS_GENHASH_TYPE_MD5),
|
||||
a1, sizeof(a1));
|
||||
lwsl_debug("A1: %s:%s:%s = %s\n", username, realm, password, a1);
|
||||
|
||||
n = lws_snprintf(wsi->http.digest_auth_hdr, l, "%s:%s",
|
||||
wsi->stash->cis[CIS_METHOD], uri);
|
||||
|
||||
if (lws_genhash_init(&hc, LWS_GENHASH_TYPE_MD5) ||
|
||||
lws_genhash_update(&hc,
|
||||
wsi->http.digest_auth_hdr,
|
||||
(size_t)n) ||
|
||||
lws_genhash_destroy(&hc, digest)) {
|
||||
lws_genhash_destroy(&hc, NULL);
|
||||
lwsl_err("%s: hash failed\n", __func__);
|
||||
|
||||
goto bail;
|
||||
}
|
||||
lws_hex_from_byte_array(digest,
|
||||
lws_genhash_size(LWS_GENHASH_TYPE_MD5),
|
||||
a2, sizeof(a2));
|
||||
lwsl_debug("A2: %s:%s = %s\n", wsi->stash->cis[CIS_METHOD],
|
||||
uri, a2);
|
||||
|
||||
lws_hex_random(lws_get_context(wsi), cnonce, sizeof(cnonce));
|
||||
lws_hex_from_byte_array((const uint8_t *)&ncount,
|
||||
sizeof(ncount), nc, sizeof(nc));
|
||||
|
||||
n = lws_snprintf(wsi->http.digest_auth_hdr, l, "%s:%s:%08x:%s:%s:%s", a1,
|
||||
nonce, ncount, cnonce, qop, a2);
|
||||
|
||||
lwsl_wsi_debug(wsi, "digest response: %s\n", wsi->http.digest_auth_hdr);
|
||||
|
||||
|
||||
if (lws_genhash_init(&hc, LWS_GENHASH_TYPE_MD5) ||
|
||||
lws_genhash_update(&hc, wsi->http.digest_auth_hdr, (size_t)n) ||
|
||||
lws_genhash_destroy(&hc, digest)) {
|
||||
lws_genhash_destroy(&hc, NULL);
|
||||
lwsl_wsi_err(wsi, "hash failed\n");
|
||||
|
||||
goto bail;
|
||||
}
|
||||
lws_hex_from_byte_array(digest,
|
||||
lws_genhash_size(LWS_GENHASH_TYPE_MD5),
|
||||
(char *)response,
|
||||
lws_genhash_size(LWS_GENHASH_TYPE_MD5) * 2 + 1);
|
||||
|
||||
n = lws_snprintf(wsi->http.digest_auth_hdr, l,
|
||||
"Digest username=\"%s\", realm=\"%s\", "
|
||||
"nonce=\"%s\", uri=\"%s\", qop=%s, nc=%08x, "
|
||||
"cnonce=\"%s\", response=\"%s\", "
|
||||
"algorithm=\"MD5\"",
|
||||
username, realm, nonce, uri, qop, ncount,
|
||||
cnonce, response);
|
||||
|
||||
lwsl_hexdump(wsi->http.digest_auth_hdr, l);
|
||||
|
||||
if (lws_hdr_simple_create(wsi, WSI_TOKEN_HTTP_AUTHORIZATION,
|
||||
wsi->http.digest_auth_hdr)) {
|
||||
lwsl_wsi_err(wsi, "Failed to add Digest auth header");
|
||||
goto bail;
|
||||
}
|
||||
|
||||
nwsi = lws_get_network_wsi(wsi);
|
||||
ssl = nwsi->tls.use_ssl & LCCSCF_USE_SSL;
|
||||
|
||||
a = wsi->stash->cis[CIS_ADDRESS];
|
||||
p = &wsi->stash->cis[CIS_PATH][1];
|
||||
|
||||
/*
|
||||
* This prevents connection pipelining when two
|
||||
* HTTP connection use the same tcp socket.
|
||||
*/
|
||||
wsi->keepalive_rejected = 1;
|
||||
|
||||
if (!lws_client_reset(&wsi, ssl, a, wsi->c_port, p, a, 1)) {
|
||||
lwsl_wsi_err(wsi, "Failed to reset WSI for Digest auth");
|
||||
|
||||
goto bail;
|
||||
}
|
||||
|
||||
wsi->client_pipeline = 0;
|
||||
}
|
||||
|
||||
return 0;
|
||||
|
||||
bail:
|
||||
lws_free(wsi->http.digest_auth_hdr);
|
||||
wsi->http.digest_auth_hdr = NULL;
|
||||
|
||||
return -1;
|
||||
}
|
||||
#endif
|
||||
|
||||
#if defined(LWS_ROLE_H1) || defined(LWS_ROLE_H2)
|
||||
|
||||
int
|
||||
|
@ -605,6 +967,7 @@ lws_client_interpret_server_handshake(struct lws *wsi)
|
|||
wsi->conmon.ciu_txn_resp = (lws_conmon_interval_us_t)
|
||||
(lws_now_usecs() - wsi->conmon_datum);
|
||||
#endif
|
||||
// lws_free_set_NULL(wsi->stash);
|
||||
|
||||
ah = wsi->http.ah;
|
||||
if (!wsi->do_ws) {
|
||||
|
@ -692,6 +1055,32 @@ lws_client_interpret_server_handshake(struct lws *wsi)
|
|||
}
|
||||
#endif
|
||||
n = atoi(p);
|
||||
|
||||
#if defined(LWS_WITH_HTTP_DIGEST_AUTH)
|
||||
if (n == 401 && lws_hdr_simple_ptr(wsi, WSI_TOKEN_HTTP_WWW_AUTHENTICATE)) {
|
||||
if (!(wsi->stash && wsi->stash->cis[CIS_USERNAME] &&
|
||||
wsi->stash->cis[CIS_PASSWORD])) {
|
||||
lwsl_err(
|
||||
"Digest auth requested by server but no credentials provided "
|
||||
"by user\n");
|
||||
return LCBA_FAILED_AUTH;
|
||||
}
|
||||
|
||||
if (0 != lws_http_digest_auth(wsi)) {
|
||||
if (wsi)
|
||||
goto bail3;
|
||||
return 1;
|
||||
}
|
||||
|
||||
opaque = wsi->a.opaque_user_data;
|
||||
lws_close_free_wsi(wsi, LWS_CLOSE_STATUS_NOSTATUS, "digest_auth_step2");
|
||||
wsi->a.opaque_user_data = opaque;
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
ah = wsi->http.ah;
|
||||
#endif
|
||||
if (ah)
|
||||
ah->http_response = (unsigned int)n;
|
||||
|
||||
|
@ -1250,6 +1639,15 @@ lws_generate_client_handshake(struct lws *wsi, char *pkt)
|
|||
}
|
||||
#endif
|
||||
|
||||
#if defined(LWS_WITH_HTTP_DIGEST_AUTH)
|
||||
if (wsi->http.digest_auth_hdr) {
|
||||
p += lws_snprintf(p, 1024, "Authorization: %s\x0d\x0a",
|
||||
wsi->http.digest_auth_hdr);
|
||||
lws_free(wsi->http.digest_auth_hdr);
|
||||
wsi->http.digest_auth_hdr = NULL;
|
||||
}
|
||||
#endif
|
||||
|
||||
#if defined(LWS_ROLE_WS)
|
||||
if (wsi->do_ws) {
|
||||
const char *conn1 = "";
|
||||
|
@ -1636,7 +2034,7 @@ lws_client_reset(struct lws **pwsi, int ssl, const char *address, int port,
|
|||
cisin[CIS_ALPN] = wsi->alpn;
|
||||
#endif
|
||||
|
||||
if (lws_client_stash_create(wsi, cisin))
|
||||
if (!wsi->stash && lws_client_stash_create(wsi, cisin))
|
||||
return NULL;
|
||||
|
||||
if (!port) {
|
||||
|
|
|
@ -279,6 +279,10 @@ struct _lws_http_mode_related {
|
|||
unsigned int multipart:1;
|
||||
unsigned int cgi_transaction_complete:1;
|
||||
unsigned int multipart_issue_boundary:1;
|
||||
|
||||
char auth_username[64];
|
||||
char auth_password[64];
|
||||
char *digest_auth_hdr;
|
||||
};
|
||||
|
||||
|
||||
|
|
Loading…
Add table
Reference in a new issue