You've already forked caddy-opnsense-blocker
Cache IP intelligence and add allowed filter
This commit is contained in:
@@ -9,7 +9,7 @@
|
|||||||
- Persistent local state in SQLite.
|
- Persistent local state in SQLite.
|
||||||
- Local-only web UI with summary cards, a sortable “Recent IPs” view for the last 24 hours, bot badges, and a full request history for each selected address.
|
- Local-only web UI with summary cards, a sortable “Recent IPs” view for the last 24 hours, bot badges, and a full request history for each selected address.
|
||||||
- On-demand IP investigation with persistent caching for bot verification, reverse DNS, RDAP, and Spamhaus lookups.
|
- On-demand IP investigation with persistent caching for bot verification, reverse DNS, RDAP, and Spamhaus lookups.
|
||||||
- Background IP investigation workers so cached intelligence appears without blocking page loads.
|
- Background IP investigation workers so missing cached intelligence appears without blocking page loads.
|
||||||
- Manual block, unblock, and clear-override actions with OPNsense-aware UI state.
|
- Manual block, unblock, and clear-override actions with OPNsense-aware UI state.
|
||||||
- OPNsense alias backend with automatic alias creation.
|
- OPNsense alias backend with automatic alias creation.
|
||||||
- Concurrent polling across multiple log files.
|
- Concurrent polling across multiple log files.
|
||||||
@@ -64,7 +64,7 @@ Important points:
|
|||||||
- Each source references exactly one profile.
|
- Each source references exactly one profile.
|
||||||
- `initial_position: end` means “start following new lines only” on first boot.
|
- `initial_position: end` means “start following new lines only” on first boot.
|
||||||
- The `investigation` section controls how long IP enrichment is cached and whether on-demand Spamhaus lookups are enabled.
|
- The `investigation` section controls how long IP enrichment is cached and whether on-demand Spamhaus lookups are enabled.
|
||||||
- The investigation worker can refresh recent IP intelligence in the background so the dashboard stays fast while bot badges and cached intel keep filling in.
|
- The investigation worker fills missing cached intelligence in the background so the dashboard stays fast while bot badges and cached intel keep filling in. Opening an IP details page reuses the cache; the `Refresh investigation` button is the manual override when you explicitly want a new lookup.
|
||||||
- The web UI should stay bound to a local address such as `127.0.0.1:9080`.
|
- The web UI should stay bound to a local address such as `127.0.0.1:9080`.
|
||||||
|
|
||||||
## Web UI and API
|
## Web UI and API
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ investigation:
|
|||||||
spamhaus_enabled: true
|
spamhaus_enabled: true
|
||||||
background_workers: 2
|
background_workers: 2
|
||||||
background_poll_interval: 30s
|
background_poll_interval: 30s
|
||||||
background_lookback: 24h
|
background_lookback: 0s
|
||||||
background_batch_size: 256
|
background_batch_size: 256
|
||||||
|
|
||||||
opnsense:
|
opnsense:
|
||||||
|
|||||||
@@ -191,9 +191,6 @@ func (c *Config) applyDefaults() error {
|
|||||||
if c.Investigation.BackgroundPollInterval.Duration == 0 {
|
if c.Investigation.BackgroundPollInterval.Duration == 0 {
|
||||||
c.Investigation.BackgroundPollInterval.Duration = 30 * time.Second
|
c.Investigation.BackgroundPollInterval.Duration = 30 * time.Second
|
||||||
}
|
}
|
||||||
if c.Investigation.BackgroundLookback.Duration == 0 {
|
|
||||||
c.Investigation.BackgroundLookback.Duration = 24 * time.Hour
|
|
||||||
}
|
|
||||||
if c.Investigation.BackgroundBatchSize == 0 {
|
if c.Investigation.BackgroundBatchSize == 0 {
|
||||||
c.Investigation.BackgroundBatchSize = 256
|
c.Investigation.BackgroundBatchSize = 256
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,6 +73,9 @@ sources:
|
|||||||
if got, want := cfg.OPNsense.APISecret, "test-secret"; got != want {
|
if got, want := cfg.OPNsense.APISecret, "test-secret"; got != want {
|
||||||
t.Fatalf("unexpected api secret: got %q want %q", got, want)
|
t.Fatalf("unexpected api secret: got %q want %q", got, want)
|
||||||
}
|
}
|
||||||
|
if got := cfg.Investigation.BackgroundLookback.Duration; got != 0 {
|
||||||
|
t.Fatalf("unexpected background lookback default: got %s want 0", got)
|
||||||
|
}
|
||||||
profile := cfg.Profiles["main"]
|
profile := cfg.Profiles["main"]
|
||||||
if !profile.IsAllowedPostPath("/search") {
|
if !profile.IsAllowedPostPath("/search") {
|
||||||
t.Fatalf("expected /search to be normalized as an allowed POST path")
|
t.Fatalf("expected /search to be normalized as an allowed POST path")
|
||||||
|
|||||||
@@ -112,7 +112,6 @@ func (s *Service) ListRecentIPs(ctx context.Context, since time.Time, limit int)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
staleSince := time.Now().UTC().Add(-s.cfg.Investigation.RefreshAfter.Duration)
|
|
||||||
for index := range items {
|
for index := range items {
|
||||||
state := model.IPState{
|
state := model.IPState{
|
||||||
IP: items[index].IP,
|
IP: items[index].IP,
|
||||||
@@ -121,9 +120,6 @@ func (s *Service) ListRecentIPs(ctx context.Context, since time.Time, limit int)
|
|||||||
}
|
}
|
||||||
if investigation, ok := investigations[items[index].IP]; ok {
|
if investigation, ok := investigations[items[index].IP]; ok {
|
||||||
items[index].Bot = investigation.Bot
|
items[index].Bot = investigation.Bot
|
||||||
if investigation.UpdatedAt.Before(staleSince) {
|
|
||||||
s.enqueueInvestigation(items[index].IP)
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
s.enqueueInvestigation(items[index].IP)
|
s.enqueueInvestigation(items[index].IP)
|
||||||
}
|
}
|
||||||
@@ -316,7 +312,7 @@ func (s *Service) processRecord(ctx context.Context, source config.SourceConfig,
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) runInvestigationScheduler(ctx context.Context) {
|
func (s *Service) runInvestigationScheduler(ctx context.Context) {
|
||||||
s.enqueueRecentInvestigations(ctx)
|
s.enqueueMissingInvestigations(ctx)
|
||||||
ticker := time.NewTicker(s.cfg.Investigation.BackgroundPollInterval.Duration)
|
ticker := time.NewTicker(s.cfg.Investigation.BackgroundPollInterval.Duration)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
for {
|
for {
|
||||||
@@ -324,7 +320,7 @@ func (s *Service) runInvestigationScheduler(ctx context.Context) {
|
|||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
return
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
s.enqueueRecentInvestigations(ctx)
|
s.enqueueMissingInvestigations(ctx)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -348,27 +344,21 @@ func (s *Service) runInvestigationWorker(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) enqueueRecentInvestigations(ctx context.Context) {
|
func (s *Service) enqueueMissingInvestigations(ctx context.Context) {
|
||||||
if s.investigationQueue == nil {
|
if s.investigationQueue == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
since := time.Now().UTC().Add(-s.cfg.Investigation.BackgroundLookback.Duration)
|
since := time.Time{}
|
||||||
items, err := s.store.ListRecentIPRows(ctx, since, s.cfg.Investigation.BackgroundBatchSize)
|
if s.cfg.Investigation.BackgroundLookback.Duration > 0 {
|
||||||
|
since = time.Now().UTC().Add(-s.cfg.Investigation.BackgroundLookback.Duration)
|
||||||
|
}
|
||||||
|
items, err := s.store.ListIPsWithoutInvestigation(ctx, since, s.cfg.Investigation.BackgroundBatchSize)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.logger.Printf("list recent IPs for investigation: %v", err)
|
s.logger.Printf("list IPs without investigation: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
investigations, err := s.store.GetInvestigationsForIPs(ctx, recentRowIPs(items))
|
|
||||||
if err != nil {
|
|
||||||
s.logger.Printf("list investigations for recent IPs: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
staleSince := time.Now().UTC().Add(-s.cfg.Investigation.RefreshAfter.Duration)
|
|
||||||
for _, item := range items {
|
for _, item := range items {
|
||||||
investigation, found := investigations[item.IP]
|
s.enqueueInvestigation(item)
|
||||||
if !found || investigation.UpdatedAt.Before(staleSince) {
|
|
||||||
s.enqueueInvestigation(item.IP)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -412,7 +402,7 @@ func (s *Service) refreshInvestigation(ctx context.Context, ip string, force boo
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
shouldRefresh := force || !found || time.Since(investigation.UpdatedAt) >= s.cfg.Investigation.RefreshAfter.Duration
|
shouldRefresh := force || !found
|
||||||
if !shouldRefresh {
|
if !shouldRefresh {
|
||||||
return &investigation, nil
|
return &investigation, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -240,6 +240,87 @@ sources:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestServiceDoesNotRefreshCachedInvestigationAutomatically(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
configPath := filepath.Join(tempDir, "config.yaml")
|
||||||
|
payload := fmt.Sprintf(`storage:
|
||||||
|
path: %s/blocker.db
|
||||||
|
investigation:
|
||||||
|
enabled: true
|
||||||
|
refresh_after: 1ns
|
||||||
|
timeout: 500ms
|
||||||
|
background_workers: 1
|
||||||
|
background_poll_interval: 50ms
|
||||||
|
profiles:
|
||||||
|
main:
|
||||||
|
auto_block: false
|
||||||
|
sources:
|
||||||
|
- name: main
|
||||||
|
path: %s/access.log
|
||||||
|
profile: main
|
||||||
|
`, tempDir, tempDir)
|
||||||
|
if err := os.WriteFile(configPath, []byte(payload), 0o600); err != nil {
|
||||||
|
t.Fatalf("write config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := config.Load(configPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load config: %v", err)
|
||||||
|
}
|
||||||
|
database, err := store.Open(cfg.Storage.Path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open store: %v", err)
|
||||||
|
}
|
||||||
|
defer database.Close()
|
||||||
|
investigator := &fakeInvestigator{}
|
||||||
|
svc := New(cfg, database, nil, investigator, log.New(os.Stderr, "", 0))
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
event := &model.Event{
|
||||||
|
SourceName: "main",
|
||||||
|
ProfileName: "main",
|
||||||
|
OccurredAt: time.Now().UTC(),
|
||||||
|
RemoteIP: "198.51.100.10",
|
||||||
|
ClientIP: "203.0.113.44",
|
||||||
|
Host: "example.test",
|
||||||
|
Method: "GET",
|
||||||
|
URI: "/cached",
|
||||||
|
Path: "/cached",
|
||||||
|
Status: 404,
|
||||||
|
UserAgent: "test-agent/1.0",
|
||||||
|
Decision: model.DecisionActionReview,
|
||||||
|
DecisionReason: "review",
|
||||||
|
DecisionReasons: []string{"review"},
|
||||||
|
RawJSON: `{"status":404}`,
|
||||||
|
}
|
||||||
|
if err := database.RecordEvent(ctx, event); err != nil {
|
||||||
|
t.Fatalf("record event: %v", err)
|
||||||
|
}
|
||||||
|
if err := database.SaveInvestigation(ctx, model.IPInvestigation{
|
||||||
|
IP: "203.0.113.44",
|
||||||
|
UpdatedAt: time.Now().UTC().Add(-48 * time.Hour),
|
||||||
|
Bot: &model.BotMatch{
|
||||||
|
Name: "CachedBot",
|
||||||
|
Verified: true,
|
||||||
|
},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("save investigation: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
loaded, err := svc.refreshInvestigation(ctx, "203.0.113.44", false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("refresh investigation: %v", err)
|
||||||
|
}
|
||||||
|
if loaded == nil || loaded.Bot == nil || loaded.Bot.Name != "CachedBot" {
|
||||||
|
t.Fatalf("expected cached investigation, got %+v", loaded)
|
||||||
|
}
|
||||||
|
if investigator.callCount() != 0 {
|
||||||
|
t.Fatalf("expected cached investigation to skip external refresh, got %d calls", investigator.callCount())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type fakeOPNsenseServer struct {
|
type fakeOPNsenseServer struct {
|
||||||
*httptest.Server
|
*httptest.Server
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
|
|||||||
@@ -545,6 +545,43 @@ func (s *Store) ListRecentIPRows(ctx context.Context, since time.Time, limit int
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Store) ListIPsWithoutInvestigation(ctx context.Context, since time.Time, limit int) ([]string, error) {
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 200
|
||||||
|
}
|
||||||
|
query := `
|
||||||
|
SELECT s.ip
|
||||||
|
FROM ip_state s
|
||||||
|
LEFT JOIN ip_investigations i ON i.ip = s.ip
|
||||||
|
WHERE i.ip IS NULL`
|
||||||
|
args := make([]any, 0, 2)
|
||||||
|
if !since.IsZero() {
|
||||||
|
query += ` AND s.last_seen_at >= ?`
|
||||||
|
args = append(args, formatTime(since))
|
||||||
|
}
|
||||||
|
query += ` ORDER BY s.last_seen_at DESC, s.ip ASC LIMIT ?`
|
||||||
|
args = append(args, limit)
|
||||||
|
|
||||||
|
rows, err := s.db.QueryContext(ctx, query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list ips without investigation: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
items := make([]string, 0, limit)
|
||||||
|
for rows.Next() {
|
||||||
|
var ip string
|
||||||
|
if err := rows.Scan(&ip); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan ip without investigation: %w", err)
|
||||||
|
}
|
||||||
|
items = append(items, ip)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("iterate ips without investigation: %w", err)
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Store) GetIPDetails(ctx context.Context, ip string, eventLimit, decisionLimit, actionLimit int) (model.IPDetails, error) {
|
func (s *Store) GetIPDetails(ctx context.Context, ip string, eventLimit, decisionLimit, actionLimit int) (model.IPDetails, error) {
|
||||||
state, _, err := s.GetIPState(ctx, ip)
|
state, _, err := s.GetIPState(ctx, ip)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -153,4 +153,11 @@ func TestStoreRecordsEventsAndState(t *testing.T) {
|
|||||||
if len(userAgents) != 1 || userAgents[0] != event.UserAgent {
|
if len(userAgents) != 1 || userAgents[0] != event.UserAgent {
|
||||||
t.Fatalf("unexpected user agents: %#v", userAgents)
|
t.Fatalf("unexpected user agents: %#v", userAgents)
|
||||||
}
|
}
|
||||||
|
missingInvestigationIPs, err := db.ListIPsWithoutInvestigation(ctx, time.Time{}, 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list ips without investigation: %v", err)
|
||||||
|
}
|
||||||
|
if len(missingInvestigationIPs) != 0 {
|
||||||
|
t.Fatalf("expected no IPs without investigation, got %#v", missingInvestigationIPs)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -398,6 +398,7 @@ const overviewHTML = `<!doctype html>
|
|||||||
<h2>Recent IPs</h2>
|
<h2>Recent IPs</h2>
|
||||||
<div class="toolbar-right">
|
<div class="toolbar-right">
|
||||||
<label class="toggle"><input id="show-bots-toggle" type="checkbox" checked onchange="toggleKnownBots()">Show known bots</label>
|
<label class="toggle"><input id="show-bots-toggle" type="checkbox" checked onchange="toggleKnownBots()">Show known bots</label>
|
||||||
|
<label class="toggle"><input id="show-allowed-toggle" type="checkbox" checked onchange="toggleAllowed()">Show allowed</label>
|
||||||
<div class="meta">Last 24 hours · click a column to sort</div>
|
<div class="meta">Last 24 hours · click a column to sort</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -421,6 +422,7 @@ const overviewHTML = `<!doctype html>
|
|||||||
const recentHours = 24;
|
const recentHours = 24;
|
||||||
const storageKeys = {
|
const storageKeys = {
|
||||||
showKnownBots: 'caddy-opnsense-blocker.overview.showKnownBots',
|
showKnownBots: 'caddy-opnsense-blocker.overview.showKnownBots',
|
||||||
|
showAllowed: 'caddy-opnsense-blocker.overview.showAllowed',
|
||||||
sortKey: 'caddy-opnsense-blocker.overview.sortKey',
|
sortKey: 'caddy-opnsense-blocker.overview.sortKey',
|
||||||
sortDirection: 'caddy-opnsense-blocker.overview.sortDirection',
|
sortDirection: 'caddy-opnsense-blocker.overview.sortDirection',
|
||||||
};
|
};
|
||||||
@@ -436,6 +438,7 @@ const overviewHTML = `<!doctype html>
|
|||||||
let currentItems = [];
|
let currentItems = [];
|
||||||
let currentSort = loadSortPreference();
|
let currentSort = loadSortPreference();
|
||||||
let showKnownBots = loadShowKnownBotsPreference();
|
let showKnownBots = loadShowKnownBotsPreference();
|
||||||
|
let showAllowed = loadShowAllowedPreference();
|
||||||
|
|
||||||
function renderStats(data) {
|
function renderStats(data) {
|
||||||
const stats = [
|
const stats = [
|
||||||
@@ -477,6 +480,25 @@ const overviewHTML = `<!doctype html>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function loadShowAllowedPreference() {
|
||||||
|
try {
|
||||||
|
const rawValue = window.localStorage.getItem(storageKeys.showAllowed);
|
||||||
|
if (rawValue === null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return rawValue !== 'false';
|
||||||
|
} catch (_) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveShowAllowedPreference(value) {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(storageKeys.showAllowed, value ? 'true' : 'false');
|
||||||
|
} catch (_) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function loadSortPreference() {
|
function loadSortPreference() {
|
||||||
const fallback = { key: 'events', direction: 'desc' };
|
const fallback = { key: 'events', direction: 'desc' };
|
||||||
try {
|
try {
|
||||||
@@ -563,9 +585,13 @@ const overviewHTML = `<!doctype html>
|
|||||||
}
|
}
|
||||||
|
|
||||||
function updateSortButtons() {
|
function updateSortButtons() {
|
||||||
const toggle = document.getElementById('show-bots-toggle');
|
const botsToggle = document.getElementById('show-bots-toggle');
|
||||||
if (toggle) {
|
if (botsToggle) {
|
||||||
toggle.checked = showKnownBots;
|
botsToggle.checked = showKnownBots;
|
||||||
|
}
|
||||||
|
const allowedToggle = document.getElementById('show-allowed-toggle');
|
||||||
|
if (allowedToggle) {
|
||||||
|
allowedToggle.checked = showAllowed;
|
||||||
}
|
}
|
||||||
document.querySelectorAll('button[data-sort]').forEach(button => {
|
document.querySelectorAll('button[data-sort]').forEach(button => {
|
||||||
const key = button.dataset.sort;
|
const key = button.dataset.sort;
|
||||||
@@ -619,7 +645,7 @@ const overviewHTML = `<!doctype html>
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderIPs(items) {
|
function renderIPs(items) {
|
||||||
const filteredItems = items.filter(item => showKnownBots || !item.bot);
|
const filteredItems = items.filter(item => (showKnownBots || !item.bot) && (showAllowed || item.state !== 'allowed'));
|
||||||
const rows = sortItems(filteredItems).map(item => [
|
const rows = sortItems(filteredItems).map(item => [
|
||||||
'<tr>',
|
'<tr>',
|
||||||
' <td class="mono"><div class="ip-cell">' + renderBotChip(item.bot) + '<a href="/ips/' + encodeURIComponent(item.ip) + '">' + escapeHtml(item.ip) + '</a></div></td>',
|
' <td class="mono"><div class="ip-cell">' + renderBotChip(item.bot) + '<a href="/ips/' + encodeURIComponent(item.ip) + '">' + escapeHtml(item.ip) + '</a></div></td>',
|
||||||
@@ -631,7 +657,14 @@ const overviewHTML = `<!doctype html>
|
|||||||
' <td>' + renderActions(item) + '</td>',
|
' <td>' + renderActions(item) + '</td>',
|
||||||
'</tr>'
|
'</tr>'
|
||||||
].join(''));
|
].join(''));
|
||||||
const emptyMessage = showKnownBots ? 'No IPs seen in the last 24 hours.' : 'No non-bot IPs seen in the last 24 hours.';
|
let emptyMessage = 'No IPs seen in the last 24 hours.';
|
||||||
|
if (!showKnownBots && !showAllowed) {
|
||||||
|
emptyMessage = 'No non-bot, non-allowed IPs seen in the last 24 hours.';
|
||||||
|
} else if (!showKnownBots) {
|
||||||
|
emptyMessage = 'No non-bot IPs seen in the last 24 hours.';
|
||||||
|
} else if (!showAllowed) {
|
||||||
|
emptyMessage = 'No non-allowed IPs seen in the last 24 hours.';
|
||||||
|
}
|
||||||
document.getElementById('ips-body').innerHTML = rows.length > 0 ? rows.join('') : '<tr><td colspan="7" class="muted">' + escapeHtml(emptyMessage) + '</td></tr>';
|
document.getElementById('ips-body').innerHTML = rows.length > 0 ? rows.join('') : '<tr><td colspan="7" class="muted">' + escapeHtml(emptyMessage) + '</td></tr>';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -658,6 +691,13 @@ const overviewHTML = `<!doctype html>
|
|||||||
render();
|
render();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleAllowed() {
|
||||||
|
const toggle = document.getElementById('show-allowed-toggle');
|
||||||
|
showAllowed = !toggle || toggle.checked;
|
||||||
|
saveShowAllowedPreference(showAllowed);
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
async function sendAction(ip, action, promptLabel) {
|
async function sendAction(ip, action, promptLabel) {
|
||||||
const reason = window.prompt(promptLabel, '');
|
const reason = window.prompt(promptLabel, '');
|
||||||
if (reason === null) {
|
if (reason === null) {
|
||||||
@@ -994,7 +1034,7 @@ const ipDetailsHTML = `<!doctype html>
|
|||||||
renderAll(data);
|
renderAll(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
refresh().then(() => investigate());
|
refresh();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>`
|
</html>`
|
||||||
|
|||||||
@@ -80,6 +80,9 @@ func TestHandlerServesOverviewAndManualActions(t *testing.T) {
|
|||||||
if !strings.Contains(recorder.Body.String(), "Show known bots") {
|
if !strings.Contains(recorder.Body.String(), "Show known bots") {
|
||||||
t.Fatalf("overview page should expose the known bots toggle")
|
t.Fatalf("overview page should expose the known bots toggle")
|
||||||
}
|
}
|
||||||
|
if !strings.Contains(recorder.Body.String(), "Show allowed") {
|
||||||
|
t.Fatalf("overview page should expose the allowed toggle")
|
||||||
|
}
|
||||||
if !strings.Contains(recorder.Body.String(), "localStorage") {
|
if !strings.Contains(recorder.Body.String(), "localStorage") {
|
||||||
t.Fatalf("overview page should persist preferences in localStorage")
|
t.Fatalf("overview page should persist preferences in localStorage")
|
||||||
}
|
}
|
||||||
@@ -97,6 +100,9 @@ func TestHandlerServesOverviewAndManualActions(t *testing.T) {
|
|||||||
if strings.Contains(body, `const ip = "\"203.0.113.10\"";`) {
|
if strings.Contains(body, `const ip = "\"203.0.113.10\"";`) {
|
||||||
t.Fatalf("ip details page still renders a doubly quoted IP")
|
t.Fatalf("ip details page still renders a doubly quoted IP")
|
||||||
}
|
}
|
||||||
|
if strings.Contains(body, `refresh().then(() => investigate());`) {
|
||||||
|
t.Fatalf("ip details page should not auto-refresh investigation on load")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type stubApp struct {
|
type stubApp struct {
|
||||||
|
|||||||
Reference in New Issue
Block a user