Skip to content

Commit 0002ae0

Browse files
committed
jax_fingerprint: Address user arg slot exhaustion
When multiple jax_fingerprint.so instances are loaded (e.g., one per fingerprinting method), each instance was reserving its own user arg slot. ATS has a limited number of slots (~4 per type), causing methods like JA3 and JA4 to fail silently when slots were exhausted. Solution: Share a single user arg slot per type (TS_USER_ARGS_VCONN or TS_USER_ARGS_TXN) across all jax_fingerprint instances. A ContextMap stores JAxContext instances keyed by method name.
1 parent ebb5e34 commit 0002ae0

6 files changed

Lines changed: 343 additions & 14 deletions

File tree

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
/** @file
2+
*
3+
* Shared context map for jax_fingerprint plugin instances.
4+
*
5+
* When multiple jax_fingerprint.so instances are loaded (e.g., one per
6+
* fingerprinting method), they share a single user arg slot. This map
7+
* stores the JAxContext for each method, keyed by method name.
8+
*
9+
* @section license License
10+
*
11+
* Licensed to the Apache Software Foundation (ASF) under one
12+
* or more contributor license agreements. See the NOTICE file
13+
* distributed with this work for additional information
14+
* regarding copyright ownership. The ASF licenses this file
15+
* to you under the Apache License, Version 2.0 (the
16+
* "License"); you may not use this file except in compliance
17+
* with the License. You may obtain a copy of the License at
18+
*
19+
* http://www.apache.org/licenses/LICENSE-2.0
20+
*
21+
* Unless required by applicable law or agreed to in writing, software
22+
* distributed under the License is distributed on an "AS IS" BASIS,
23+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
24+
* See the License for the specific language governing permissions and
25+
* limitations under the License.
26+
*/
27+
28+
#pragma once
29+
30+
#include "config.h"
31+
#include "context.h"
32+
33+
#include <string>
34+
#include <string_view>
35+
#include <unordered_map>
36+
37+
/**
38+
* @brief Container holding JAxContext instances for multiple methods.
39+
*
40+
* ATS has a limited number of user arg slots (~4 per type). When loading
41+
* many jax_fingerprint instances, we share a single slot and store all
42+
* contexts in this map, keyed by method name.
43+
*/
44+
class ContextMap
45+
{
46+
public:
47+
~ContextMap()
48+
{
49+
for (auto &pair : m_contexts) {
50+
delete pair.second;
51+
}
52+
}
53+
54+
/**
55+
* @brief Store a context for a method.
56+
* @param[in] method_name The method name (e.g., "JA3", "JA4").
57+
* @param[in] ctx The context to store. Ownership is transferred.
58+
*/
59+
void
60+
set(std::string_view method_name, JAxContext *ctx)
61+
{
62+
auto it = m_contexts.find(method_name);
63+
if (it != m_contexts.end()) {
64+
delete it->second;
65+
it->second = ctx;
66+
} else {
67+
m_contexts.emplace(std::string{method_name}, ctx);
68+
}
69+
}
70+
71+
/**
72+
* @brief Retrieve a context for a method.
73+
* @param[in] method_name The method name.
74+
* @return The context, or nullptr if not found.
75+
*/
76+
JAxContext *
77+
get(std::string_view method_name)
78+
{
79+
auto it = m_contexts.find(method_name);
80+
return it != m_contexts.end() ? it->second : nullptr;
81+
}
82+
83+
/**
84+
* @brief Remove a context for a method.
85+
* @param[in] method_name The method name.
86+
*/
87+
void
88+
remove(std::string_view method_name)
89+
{
90+
auto it = m_contexts.find(method_name);
91+
if (it != m_contexts.end()) {
92+
delete it->second;
93+
m_contexts.erase(it);
94+
}
95+
}
96+
97+
/**
98+
* @brief Check if the map is empty.
99+
* @return True if no contexts are stored.
100+
*/
101+
bool
102+
empty() const
103+
{
104+
return m_contexts.empty();
105+
}
106+
107+
private:
108+
std::unordered_map<std::string, JAxContext *, StringHash, std::equal_to<>> m_contexts;
109+
};

plugins/experimental/jax_fingerprint/plugin.cc

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -282,8 +282,7 @@ handle_http_txn_close(void *edata, PluginConfig &config)
282282
{
283283
TSHttpTxn txnp = static_cast<TSHttpTxn>(edata);
284284

285-
delete get_user_arg(txnp, config);
286-
set_user_arg(txnp, config, nullptr);
285+
cleanup_user_arg(txnp, config);
287286

288287
TSHttpTxnReenable(txnp, TS_EVENT_HTTP_CONTINUE);
289288
return TS_SUCCESS;
@@ -294,8 +293,7 @@ handle_vconn_close(void *edata, PluginConfig &config)
294293
{
295294
TSVConn vconn = static_cast<TSVConn>(edata);
296295

297-
delete get_user_arg(vconn, config);
298-
set_user_arg(vconn, config, nullptr);
296+
cleanup_user_arg(vconn, config);
299297

300298
TSVConnReenable(vconn);
301299
return TS_SUCCESS;

plugins/experimental/jax_fingerprint/userarg.cc

Lines changed: 66 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -22,32 +22,88 @@
2222
#include "plugin.h"
2323
#include "config.h"
2424
#include "userarg.h"
25+
#include "context_map.h"
26+
27+
namespace
28+
{
29+
30+
// Shared user arg indices for all jax_fingerprint instances. ATS has limited
31+
// slots (~4 per type), so we share one slot per type and use a ContextMap.
32+
int vconn_user_arg_index = -1;
33+
int txn_user_arg_index = -1;
34+
bool vconn_slot_reserved = false;
35+
bool txn_slot_reserved = false;
36+
37+
} // anonymous namespace
2538

2639
int
2740
reserve_user_arg(PluginConfig &config)
2841
{
29-
std::string name = PLUGIN_NAME;
30-
name += config.method.name;
31-
3242
TSUserArgType type;
43+
int *shared_index;
44+
bool *reserved_flag;
45+
3346
if (config.method.type == Method::Type::CONNECTION_BASED) {
34-
type = TS_USER_ARGS_VCONN;
47+
type = TS_USER_ARGS_VCONN;
48+
shared_index = &vconn_user_arg_index;
49+
reserved_flag = &vconn_slot_reserved;
3550
} else {
36-
type = TS_USER_ARGS_TXN;
51+
type = TS_USER_ARGS_TXN;
52+
shared_index = &txn_user_arg_index;
53+
reserved_flag = &txn_slot_reserved;
54+
}
55+
56+
// Only reserve the slot once per type; subsequent calls reuse it.
57+
if (!*reserved_flag) {
58+
int ret = TSUserArgIndexReserve(type, PLUGIN_NAME, "shared JAx context map", shared_index);
59+
if (ret == TS_SUCCESS) {
60+
*reserved_flag = true;
61+
Dbg(dbg_ctl, "Reserved shared user_arg slot: type=%d, index=%d", static_cast<int>(type), *shared_index);
62+
} else {
63+
Dbg(dbg_ctl, "Failed to reserve shared user_arg slot: type=%d", static_cast<int>(type));
64+
return ret;
65+
}
3766
}
38-
int ret = TSUserArgIndexReserve(type, name.c_str(), "used to pass JAx context between hooks", &config.user_arg_index);
39-
Dbg(dbg_ctl, "user_arg_name: %s, user_arg_index: %d", name.c_str(), config.user_arg_index);
40-
return ret;
67+
68+
config.user_arg_index = *shared_index;
69+
Dbg(dbg_ctl, "Using shared user_arg: method=%.*s, index=%d", static_cast<int>(config.method.name.size()),
70+
config.method.name.data(), config.user_arg_index);
71+
return TS_SUCCESS;
4172
}
4273

4374
void
4475
set_user_arg(void *container, PluginConfig &config, JAxContext *ctx)
4576
{
46-
TSUserArgSet(container, config.user_arg_index, static_cast<void *>(ctx));
77+
ContextMap *map = static_cast<ContextMap *>(TSUserArgGet(container, config.user_arg_index));
78+
if (map == nullptr) {
79+
map = new ContextMap();
80+
TSUserArgSet(container, config.user_arg_index, static_cast<void *>(map));
81+
}
82+
map->set(config.method.name, ctx);
4783
}
4884

4985
JAxContext *
5086
get_user_arg(void *container, PluginConfig &config)
5187
{
52-
return static_cast<JAxContext *>(TSUserArgGet(container, config.user_arg_index));
88+
ContextMap *map = static_cast<ContextMap *>(TSUserArgGet(container, config.user_arg_index));
89+
if (map == nullptr) {
90+
return nullptr;
91+
}
92+
return map->get(config.method.name);
93+
}
94+
95+
void
96+
cleanup_user_arg(void *container, PluginConfig &config)
97+
{
98+
ContextMap *map = static_cast<ContextMap *>(TSUserArgGet(container, config.user_arg_index));
99+
if (map != nullptr) {
100+
// Remove this plugin's context from the map.
101+
map->remove(config.method.name);
102+
103+
// If the map is now empty, delete it and clear the user arg.
104+
if (map->empty()) {
105+
delete map;
106+
TSUserArgSet(container, config.user_arg_index, nullptr);
107+
}
108+
}
53109
}

plugins/experimental/jax_fingerprint/userarg.h

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,5 @@
3030
int reserve_user_arg(PluginConfig &config);
3131
void set_user_arg(void *container, PluginConfig &config, JAxContext *ctx);
3232
JAxContext *get_user_arg(void *container, PluginConfig &config);
33+
void cleanup_user_arg(void *container, PluginConfig &config);
34+
void cleanup_user_arg(void *container, PluginConfig &config);

tests/gold_tests/pluginTest/jax_fingerprint/jax_fingerprint.test.py

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,3 +337,100 @@ def _configure_client(self, tr: 'TestRun') -> None:
337337
# Remap plugin sets headers on both routes, but only the SNI-allowed
338338
# connection has a vconn context, so only that request gets headers.
339339
JaxFingerprintTest('Hybrid JA4 servernames', 'JA4', 'hybrid', servernames='jax.server.test')
340+
341+
# ======================================================================
342+
# All Methods Test - Verify shared context map works with multiple methods
343+
# ======================================================================
344+
345+
346+
class AllMethodsTest:
347+
'''Test multiple fingerprint methods loaded simultaneously.
348+
349+
When multiple jax_fingerprint instances are loaded, they share user arg
350+
slots via a ContextMap. This test verifies that all methods produce
351+
fingerprints when sharing the SSL_CLIENT_HELLO_HOOK.
352+
'''
353+
354+
_dns_counter: int = 0
355+
_server_counter: int = 0
356+
_ts_counter: int = 0
357+
_client_counter: int = 0
358+
359+
def __init__(self, name: str) -> None:
360+
'''Configure test with multiple methods loaded.'''
361+
self._name = name
362+
self._replay_file = 'jax_fingerprint_all_methods.replay.yaml'
363+
364+
tr = Test.AddTestRun(name)
365+
self._configure_dns(tr)
366+
self._configure_server(tr)
367+
self._configure_trafficserver()
368+
self._configure_client(tr)
369+
370+
def _configure_dns(self, tr: 'TestRun') -> None:
371+
'''Configure a nameserver for the test.'''
372+
name = f'dns_all{AllMethodsTest._dns_counter}'
373+
self._dns = tr.MakeDNServer(name, default='127.0.0.1')
374+
AllMethodsTest._dns_counter += 1
375+
376+
def _configure_server(self, tr: 'TestRun') -> None:
377+
'''Configure the origin (verifier) server.'''
378+
name = f'server_all{AllMethodsTest._server_counter}'
379+
self._server = tr.AddVerifierServerProcess(name, self._replay_file)
380+
AllMethodsTest._server_counter += 1
381+
382+
# Verify all headers were forwarded to the origin.
383+
for header in ['x-ja3', 'x-ja4', 'x-ja4h']:
384+
self._server.Streams.All += Testers.ContainsExpression(
385+
rf'{header}:', f'Verify {header} header was forwarded.', reflags=re.IGNORECASE)
386+
387+
def _configure_trafficserver(self) -> None:
388+
'''Configure Traffic Server with multiple methods.'''
389+
name = f'ts_all{AllMethodsTest._ts_counter}'
390+
self._ts = Test.MakeATSProcess(name, enable_cache=False, enable_tls=True)
391+
AllMethodsTest._ts_counter += 1
392+
393+
self._ts.addDefaultSSLFiles()
394+
self._ts.Disk.ssl_multicert_config.AddLine('dest_ip=* ssl_cert_name=server.pem ssl_key_name=server.key')
395+
396+
server_port = self._server.Variables.https_port
397+
398+
self._ts.Disk.records_config.update(
399+
{
400+
'proxy.config.ssl.server.cert.path': self._ts.Variables.SSLDir,
401+
'proxy.config.ssl.server.private_key.path': self._ts.Variables.SSLDir,
402+
'proxy.config.ssl.client.verify.server.policy': 'PERMISSIVE',
403+
'proxy.config.dns.nameservers': f"127.0.0.1:{self._dns.Variables.Port}",
404+
'proxy.config.dns.resolv_conf': 'NULL',
405+
'proxy.config.proxy_name': 'test.proxy.test',
406+
'proxy.config.diags.debug.enabled': 1,
407+
'proxy.config.diags.debug.tags': 'jax_fingerprint',
408+
})
409+
410+
# Load multiple methods - all share the same user arg slot via ContextMap.
411+
self._ts.Disk.plugin_config.AddLines(
412+
[
413+
'jax_fingerprint.so --method JA3 --header x-ja3 --standalone',
414+
'jax_fingerprint.so --method JA4 --header x-ja4 --standalone',
415+
'jax_fingerprint.so --method JA4H --header x-ja4h --standalone',
416+
])
417+
418+
self._ts.Disk.remap_config.AddLine(f'map https://jax.server.test https://jax.backend.test:{server_port}')
419+
420+
def _configure_client(self, tr: 'TestRun') -> None:
421+
'''Configure the verifier client.'''
422+
name = f'client_all{AllMethodsTest._client_counter}'
423+
p = tr.AddVerifierClientProcess(
424+
name, self._replay_file, http_ports=[self._ts.Variables.port], https_ports=[self._ts.Variables.ssl_port])
425+
AllMethodsTest._client_counter += 1
426+
427+
p.StartBefore(self._dns)
428+
p.StartBefore(self._server)
429+
p.StartBefore(self._ts)
430+
tr.StillRunningAfter = self._ts
431+
432+
433+
# --- All Methods Test --------------------------------------------------------
434+
435+
# Multiple methods loaded simultaneously, verifying shared context map works.
436+
AllMethodsTest('Multiple methods loaded simultaneously')

0 commit comments

Comments
 (0)