Skip to content

Commit 00e92db

Browse files
committed
allow/denny list, darkmode switch
1 parent 5b3b924 commit 00e92db

10 files changed

Lines changed: 573 additions & 28 deletions

File tree

README.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ https://github.com/cortexm/ser2tcp
1919
- serial port send received data to all connected clients
2020
- non-blocking send with configurable timeout and buffer limit
2121
- serial signal control (RTS, DTR, CTS, DSR, RI, CD) via escape protocol or WebSocket JSON
22+
- IP filtering with allow/deny lists (CIDR notation supported)
2223
- built-in HTTP server with REST API for status monitoring
2324
- web interface for viewing configured ports and connections
2425
- web terminal clients (xterm.js VT100 terminal and raw colored view)
@@ -218,6 +219,33 @@ For `ssl` protocol, add `ssl` object with certificate paths:
218219

219220
If `ca_certs` is specified, clients must provide a valid certificate signed by the CA.
220221

222+
#### IP filtering
223+
224+
Restrict client connections by IP address using `allow` and/or `deny` lists:
225+
226+
```json
227+
{
228+
"address": "0.0.0.0",
229+
"port": 10001,
230+
"protocol": "tcp",
231+
"allow": ["192.168.1.0/24", "10.0.0.5"],
232+
"deny": ["192.168.1.100"]
233+
}
234+
```
235+
236+
| Parameter | Description |
237+
|-----------|-------------|
238+
| `allow` | List of allowed IP addresses/networks (CIDR notation supported) |
239+
| `deny` | List of denied IP addresses/networks (CIDR notation supported) |
240+
241+
Filter logic:
242+
- **No config**: all IPs allowed
243+
- **Only `deny`**: all IPs allowed except those in deny list
244+
- **Only `allow`**: only IPs in allow list are allowed
245+
- **Both**: deny takes precedence, then allow list is checked
246+
247+
Works on TCP, TELNET, SSL, WebSocket and HTTP servers. Not applicable to Unix socket (no IP addresses). Rejected connections are logged.
248+
221249
##### Creating self-signed certificates
222250

223251
Generate CA and server certificate for testing:
@@ -315,6 +343,19 @@ HTTPS with SSL:
315343
}
316344
```
317345

346+
With IP filtering:
347+
348+
```json
349+
{
350+
"http": [{
351+
"address": "0.0.0.0",
352+
"port": 8080,
353+
"allow": ["192.168.0.0/16"],
354+
"deny": ["192.168.1.100"]
355+
}]
356+
}
357+
```
358+
318359
#### API endpoints
319360

320361
| Method | Path | Auth | Description |

ser2tcp/html/app.js

Lines changed: 98 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,57 @@ const el = (tag, text, cls) => {
66
return e;
77
};
88

9+
// Theme switcher
10+
function applyTheme(theme) {
11+
if (theme === 'auto') {
12+
document.documentElement.removeAttribute('data-theme');
13+
} else {
14+
document.documentElement.setAttribute('data-theme', theme);
15+
}
16+
// Update icon visibility
17+
['light', 'dark', 'auto'].forEach(t => {
18+
const icon = $('theme-icon-' + t);
19+
if (icon) icon.classList.toggle('active', t === theme);
20+
});
21+
// Update dropdown active state
22+
const dropdown = $('theme-dropdown');
23+
if (dropdown) {
24+
dropdown.querySelectorAll('button').forEach(btn => {
25+
btn.classList.toggle('active', btn.dataset.theme === theme);
26+
});
27+
}
28+
}
29+
30+
function initTheme() {
31+
const saved = localStorage.getItem('ser2tcp_theme') || 'auto';
32+
const btn = $('theme-btn');
33+
const dropdown = $('theme-dropdown');
34+
if (btn && dropdown) {
35+
btn.onclick = e => {
36+
e.stopPropagation();
37+
dropdown.classList.toggle('open');
38+
};
39+
dropdown.querySelectorAll('button').forEach(b => {
40+
b.onclick = () => {
41+
const theme = b.dataset.theme;
42+
localStorage.setItem('ser2tcp_theme', theme);
43+
applyTheme(theme);
44+
dropdown.classList.remove('open');
45+
};
46+
});
47+
document.addEventListener('click', () => dropdown.classList.remove('open'));
48+
}
49+
applyTheme(saved);
50+
}
51+
52+
// Apply theme immediately to avoid flash
53+
(function() {
54+
const saved = localStorage.getItem('ser2tcp_theme');
55+
if (saved && saved !== 'auto') {
56+
document.documentElement.setAttribute('data-theme', saved);
57+
}
58+
})();
59+
960
// Hash password with SHA-256 and random salt (same format as server)
1061
async function hashPassword(password) {
1162
const salt = Array.from(crypto.getRandomValues(new Uint8Array(16)))
@@ -118,7 +169,9 @@ function switchTab(tab, data) {
118169
t => t.classList.toggle('hidden', t.id !== 'tab-' + tab));
119170
if (tab === 'ports') loadPorts(data);
120171
else if (tab === 'users') loadUsers();
121-
if (location.hash !== '#' + tab) history.pushState(null, '', '#' + tab);
172+
const newPath = tab === 'ports' ? location.pathname : '#' + tab;
173+
if (location.hash !== (tab === 'ports' ? '' : '#' + tab))
174+
history.pushState(null, '', newPath);
122175
}
123176

124177
// --- Ports ---
@@ -277,26 +330,19 @@ function renderPortCard(port, index) {
277330
const urlEl = el('div', wsUrl, 'port-config-detail');
278331
urlEl.style.cursor = 'pointer';
279332
urlEl.title = 'Click to copy';
280-
urlEl.onclick = () => {
333+
urlEl.onclick = e => {
334+
e.stopPropagation();
281335
navigator.clipboard.writeText(wsUrl);
282336
urlEl.textContent = 'Copied!';
283337
setTimeout(() => { urlEl.textContent = wsUrl; }, 1000);
284338
};
285339
li.appendChild(urlEl);
286340
if (s.data !== false) {
287-
const termLink = document.createElement('a');
288-
termLink.href = '/xterm/' + s.endpoint;
289-
termLink.className = 'detect-link';
290-
termLink.textContent = 'Terminal';
291-
termLink.target = '_blank';
292341
const linksDiv = el('div', null, 'ws-links');
293-
linksDiv.appendChild(termLink);
294-
const rawLink = document.createElement('a');
295-
rawLink.href = '/raw/' + s.endpoint;
296-
rawLink.className = 'detect-link';
297-
rawLink.textContent = 'Raw';
298-
rawLink.target = '_blank';
299-
linksDiv.appendChild(rawLink);
342+
linksDiv.innerHTML = '<a href="/xterm/' + s.endpoint
343+
+ '" class="detect-link" target="_blank" rel="noopener">Terminal</a>'
344+
+ '<a href="/raw/' + s.endpoint
345+
+ '" class="detect-link" target="_blank" rel="noopener">Raw</a>';
300346
li.appendChild(linksDiv);
301347
}
302348
if (s.data === false)
@@ -730,7 +776,8 @@ function renderServerBox(srv, index, total) {
730776
const box = el('div', null, 'server-box');
731777
box.dataset.serverIndex = index;
732778

733-
const removeBtn = el('button', '\u00d7', 'btn-remove');
779+
const removeBtn = el('button', null, 'btn-remove');
780+
removeBtn.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2m3 0v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6h14"/></svg>';
734781
removeBtn.disabled = total <= 1;
735782
removeBtn.onclick = () => removeServerBox(box);
736783
box.appendChild(removeBtn);
@@ -912,6 +959,29 @@ function renderServerBox(srv, index, total) {
912959
updateCtlVisibility();
913960
box.appendChild(ctlDiv);
914961

962+
// IP filter section
963+
const ipDiv = el('div');
964+
ipDiv.className = 'srv-ip-filter';
965+
const ipAllowRow = el('div', null, 'field-row');
966+
ipAllowRow.appendChild(el('label', 'Allow IPs:'));
967+
const ipAllowInput = document.createElement('input');
968+
ipAllowInput.type = 'text';
969+
ipAllowInput.className = 'srv-allow';
970+
ipAllowInput.placeholder = '192.168.1.0/24, 10.0.0.5';
971+
ipAllowInput.value = (srv.allow || []).join(', ');
972+
ipAllowRow.appendChild(ipAllowInput);
973+
ipDiv.appendChild(ipAllowRow);
974+
const ipDenyRow = el('div', null, 'field-row');
975+
ipDenyRow.appendChild(el('label', 'Deny IPs:'));
976+
const ipDenyInput = document.createElement('input');
977+
ipDenyInput.type = 'text';
978+
ipDenyInput.className = 'srv-deny';
979+
ipDenyInput.placeholder = '192.168.1.100';
980+
ipDenyInput.value = (srv.deny || []).join(', ');
981+
ipDenyRow.appendChild(ipDenyInput);
982+
ipDiv.appendChild(ipDenyRow);
983+
box.appendChild(ipDiv);
984+
915985
// Update visibility based on protocol
916986
const updateProtoFields = () => {
917987
const proto = protoSel.value;
@@ -925,6 +995,7 @@ function renderServerBox(srv, index, total) {
925995
addrLabel.textContent = isSocket ? 'Path:' : 'Address:';
926996
sslDiv.classList.toggle('hidden', !isSsl);
927997
ctlDiv.classList.toggle('hidden', isTelnet);
998+
ipDiv.classList.toggle('hidden', isSocket);
928999
// Update control description
9291000
ctlDesc.textContent = '';
9301001
if (isWs) {
@@ -1095,6 +1166,17 @@ function collectConfig() {
10951166
if (pollMs) ctl.poll_interval = pollMs / 1000;
10961167
srv.control = ctl;
10971168
}
1169+
// IP filter
1170+
if (proto !== 'socket') {
1171+
const allowStr = box.querySelector('.srv-allow').value.trim();
1172+
if (allowStr) {
1173+
srv.allow = allowStr.split(',').map(s => s.trim()).filter(s => s);
1174+
}
1175+
const denyStr = box.querySelector('.srv-deny').value.trim();
1176+
if (denyStr) {
1177+
srv.deny = denyStr.split(',').map(s => s.trim()).filter(s => s);
1178+
}
1179+
}
10981180
config.servers.push(srv);
10991181
});
11001182

@@ -1222,6 +1304,7 @@ async function addUser() {
12221304

12231305
// --- Init ---
12241306
function init() {
1307+
initTheme();
12251308
$('login-btn').addEventListener('click', doLogin);
12261309
$('login-pass').addEventListener('keydown',
12271310
e => { if (e.key === 'Enter') doLogin(); });

ser2tcp/html/index.html

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,24 @@
1717
</nav>
1818
</div>
1919
<div class="topbar-right">
20+
<div class="theme-menu" id="theme-menu">
21+
<button class="theme-btn" id="theme-btn" title="Theme">
22+
<svg class="theme-icon" id="theme-icon-light" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
23+
<circle cx="12" cy="12" r="5"/><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/>
24+
</svg>
25+
<svg class="theme-icon" id="theme-icon-dark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
26+
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
27+
</svg>
28+
<svg class="theme-icon" id="theme-icon-auto" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
29+
<path d="M12 2a10 10 0 0 1 0 20" fill="none"/><path d="M12 2a10 10 0 0 0 0 20" fill="currentColor"/>
30+
</svg>
31+
</button>
32+
<div class="theme-dropdown" id="theme-dropdown">
33+
<button data-theme="auto" class="theme-opt-auto"><svg viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path d="M12 2a10 10 0 0 1 0 20" fill="none"/><path d="M12 2a10 10 0 0 0 0 20" fill="currentColor"/></svg>Auto</button>
34+
<button data-theme="light" class="theme-opt-light"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="5"/><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/></svg>Light</button>
35+
<button data-theme="dark" class="theme-opt-dark"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>Dark</button>
36+
</div>
37+
</div>
2038
<div class="user-info hidden" id="user-info">
2139
<span id="user-name"></span>
2240
<button id="logout-btn">Logout</button>

ser2tcp/html/style.css

Lines changed: 64 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
:root {
1+
:root, [data-theme="light"] {
22
--bg: #f5f5f5;
33
--bg-card: #fff;
44
--bg-input: #fff;
@@ -29,8 +29,39 @@
2929
--toolbar-bg: #f0f0f0;
3030
}
3131

32+
[data-theme="dark"] {
33+
--bg: #1a1a1a;
34+
--bg-card: #242424;
35+
--bg-input: #1a1a1a;
36+
--bg-input-focus: #111;
37+
--text-input-focus: #eee;
38+
--bg-server: #2c2c2c;
39+
--border: #484848;
40+
--border-light: #3a3a3a;
41+
--text: #ddd;
42+
--text-secondary: #eee;
43+
--text-muted: #777;
44+
--accent: #5aadff;
45+
--accent-hover: #7dc0ff;
46+
--danger: #f66;
47+
--success: #5d5;
48+
--shadow: rgba(0,0,0,0.3);
49+
--edit-border: #5aadff;
50+
--badge-bg: #333;
51+
--badge-text: #aaa;
52+
--nav-bg: transparent;
53+
--dialog-bg: #2a2a2a;
54+
--dialog-overlay: rgba(0,0,0,0.6);
55+
--signal-off-bg: #333;
56+
--signal-off-text: #666;
57+
--control-border: #3a3a3a;
58+
--input-disabled-bg: #222;
59+
--input-disabled-text: #555;
60+
--toolbar-bg: #242424;
61+
}
62+
3263
@media (prefers-color-scheme: dark) {
33-
:root {
64+
:root:not([data-theme="light"]) {
3465
--bg: #1a1a1a;
3566
--bg-card: #242424;
3667
--bg-input: #1a1a1a;
@@ -108,6 +139,7 @@ input, select { padding: 0.4em 0.6em; border: 1px solid var(--border);
108139
input:focus, select:focus { outline: none; border-color: var(--accent);
109140
box-shadow: 0 0 0 2px rgba(74,158,255,0.2);
110141
background: var(--bg-input-focus); color: var(--text-input-focus); }
142+
input::placeholder { color: var(--text-muted); opacity: 0.6; }
111143
button { padding: 0.4em 1em; border: none; border-radius: 6px;
112144
cursor: pointer; color: #fff; font-size: 0.9em; }
113145
.btn-primary { background: var(--accent); }
@@ -132,6 +164,27 @@ button { padding: 0.4em 1em; border: none; border-radius: 6px;
132164
.dot-off { color: var(--text-muted); }
133165
.dot-err { color: var(--danger); }
134166
.toolbar { margin: 0.8em 0; padding: 0.6em 0; }
167+
.theme-menu { position: relative; margin-right: 0.8em; }
168+
.theme-btn { background: none; border: none; color: var(--text-secondary);
169+
cursor: pointer; padding: 0.3em; display: flex; align-items: center; }
170+
.theme-btn:hover { color: var(--accent); }
171+
.theme-icon { width: 1.2em; height: 1.2em; display: none; }
172+
.theme-icon.active { display: block; }
173+
.theme-dropdown { position: absolute; top: 100%; right: 0; margin-top: 0.3em;
174+
background: var(--bg-card); border: 1px solid var(--border);
175+
border-radius: 6px; box-shadow: 0 4px 12px var(--shadow);
176+
display: none; min-width: 5em; z-index: 100; overflow: hidden; }
177+
.theme-dropdown.open { display: block; }
178+
.theme-dropdown button { display: flex; align-items: center; gap: 0.5em;
179+
width: 100%; padding: 0.5em 0.8em; background: none; border: none;
180+
text-align: left; cursor: pointer; color: var(--text); font-size: 0.85em; }
181+
.theme-dropdown button:hover { background: var(--bg-server); }
182+
.theme-dropdown button.active { font-weight: 600; }
183+
.theme-dropdown button svg { width: 1em; height: 1em; flex-shrink: 0; }
184+
.theme-opt-light { color: #d90 !important; }
185+
.theme-opt-dark { color: #68f !important; }
186+
.theme-dropdown button.active.theme-opt-light { color: #b70 !important; }
187+
.theme-dropdown button.active.theme-opt-dark { color: #46d !important; }
135188
.user-info { display: flex; align-items: center; gap: 0.8em;
136189
color: var(--text-secondary); font-size: 0.85em; }
137190
.user-info button { background: none; border: 1px solid var(--border);
@@ -166,9 +219,12 @@ button { padding: 0.4em 1em; border: none; border-radius: 6px;
166219
border-radius: 6px; border: 1px solid var(--control-border);
167220
position: relative; }
168221
.server-box .btn-remove { position: absolute; top: 0.5em; right: 0.5em;
169-
background: none; color: var(--danger); font-size: 1.2em; padding: 0 0.4em;
170-
border: none; }
171-
.server-box .btn-remove:disabled { color: var(--text-muted); cursor: default; }
222+
background: none; color: var(--text-muted); padding: 0.2em;
223+
border: none; display: flex; align-items: center; }
224+
.server-box .btn-remove:hover { color: var(--danger); }
225+
.server-box .btn-remove:disabled { color: var(--border); cursor: default; }
226+
.server-box .btn-remove:disabled:hover { color: var(--border); }
227+
.server-box .btn-remove svg { width: 1.1em; height: 1.1em; }
172228
.edit-actions { display: flex; gap: 0.5em; margin-top: 1em; }
173229
.edit-actions .spacer { flex: 1; }
174230
.btn-edit { position: absolute; top: 1em; right: 1em; background: none;
@@ -197,7 +253,9 @@ button { padding: 0.4em 1em; border: none; border-radius: 6px;
197253
.ctl-signal-label { min-width: auto !important; display: inline-flex;
198254
align-items: center; gap: 0.2em; font-size: 0.9em; }
199255
.srv-control-fields { margin-top: 0.5em; padding-top: 0.5em;
200-
border-top: 1px dashed var(--control-border); }
256+
border-top: 1px dashed var(--border); }
257+
.srv-ip-filter { margin-top: 0.5em; padding-top: 0.5em;
258+
border-top: 1px dashed var(--border); }
201259
.ctl-poll-interval { max-width: 8em; }
202260
.signal-indicators { display: flex; gap: 0.3em; margin: 0.4em 0; }
203261
.signal-badge { display: inline-block; padding: 0.2em 0.5em; border-radius: 3px;

0 commit comments

Comments
 (0)