Skip to content

Commit a0deae7

Browse files
committed
Added admin event logging to track key lock/unlock, app suspend/resume, etc. Improved pool tracking to prevent stale SSE connections from not restarting during long sleep/wake cycles. Replaced relays widget with system status widget. Added option kill switch to send daemon commands via NIP-04 and NIP-17 DMs from user-defined admin npub. Updated documentation.
1 parent 5227ca5 commit a0deae7

File tree

74 files changed

+4752
-565
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

74 files changed

+4752
-565
lines changed

CHANGELOG.md

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,65 @@
11
# Changelog
22

3-
## [Unreleased]
3+
## [1.4.0]
4+
5+
### Added
6+
- Admin activity logging: Track key lock/unlock, app suspend/resume, and daemon start events
7+
- New "Admin" tab in Activity page to view admin-only events
8+
- Admin events appear in Recent activity widget on Home page
9+
- Admin events included in "All" filter on Activity page
10+
- Events show source: "via Signet UI", "via Kill Switch", or "via Signet Android"
11+
- Kill switch command audit logging: All DM commands are now logged
12+
- New `command_executed` event type records every command and its result
13+
- Commands logged even when no state change occurs (e.g., locking already-locked key)
14+
- Provides full audit trail for security review
15+
- Kill switch status command: Send `status` DM to check daemon state
16+
- Returns active/locked keys, suspended apps count
17+
- Logged as `status_checked` admin event
18+
- Web UI: System Status widget replaces Relays widget on dashboard
19+
- Shows daemon uptime with health status label (Healthy/Degraded/Offline)
20+
- HeartPulse icon color indicates status (green/yellow/red)
21+
- Click opens System Status modal with full details:
22+
- Status badge, uptime, memory usage, active listeners, connected clients, last pool reset, key stats
23+
- Expandable relay section showing per-relay connection status
24+
- Android: System Status widget replaces Relays widget on dashboard (matches web UI)
25+
- Shows daemon uptime with health status label (Healthy/Degraded/Offline)
26+
- Heart icon color indicates status (green/yellow/red)
27+
- Tap opens System Status sheet with full health details and expandable relay section
28+
- Android: X-Signet-Client header sent with all API requests for client identification
29+
30+
### Improved
31+
- SSE real-time updates: Activity feeds now update instantly without API refresh
32+
- `request:approved`, `request:denied`, and `request:auto_approved` events now include `activity` field
33+
- Web UI Recent activity widget updates in real-time for all approval types
34+
- Android Home screen Recent activity updates via SSE data (no API refresh needed)
35+
- New `ping` event type for SSE heartbeat (fixes reconnection issues)
36+
- Daemon: Enhanced health monitoring
37+
- Health status now logged every 30 minutes (was 1 hour)
38+
- Event-triggered logging after pool reset and key lock/unlock
39+
- Rich `/health` endpoint returns full JSON status for programmatic monitoring
40+
- Response includes: uptime, memory, relay connections, key counts, subscriptions, SSE clients, last pool reset
41+
- Uptime display: More compact formatting with 2 significant units max
42+
- Short uptimes: `45s`, `5m`, `2h 30m`, `3d 12h`
43+
- Long uptimes switch to months/years: `2mo 15d`, `1y 3mo`
44+
- Consistent across web UI and Android
45+
- Added KILLSWITCH.md to document new killswitch by DM feature
46+
47+
### Fixed
48+
- Daemon: NIP-46 requests now work correctly after system suspend/resume
49+
- RelayPool detects sleep/wake cycles (30s heartbeat, >90s gap triggers reset)
50+
- SubscriptionManager has fallback detection (60s health check, >3min gap triggers reset)
51+
- Two-layer detection ensures recovery even after very long sleep periods
52+
- Pool reset creates fresh SimplePool and emits events for dependent services
53+
- SubscriptionManager recreates NIP-46 subscriptions on pool reset
54+
- AdminCommandService (kill switch) refreshes its WebSocket connections on wake
55+
- Docker: Custom SIGNET_PORT now works correctly (#27)
56+
- Fixed port mapping to use dynamic port on both sides
57+
- Fixed healthcheck to use configured port
58+
- Fixed DAEMON_URL to use configured port for UI→daemon communication
459

560
---
661

7-
## [1.3.0] - 2026-01-05
62+
## [1.3.0]
863

964
### Security
1065
- Bunker URIs now use one-time connection tokens instead of persistent secrets

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
A modern NIP-46 remote signer for Nostr. Manages multiple keys securely with a web dashboard for administration. This project was originally forked from [nsecbunkerd](https://github.com/kind-0/nsecbunkerd), but has since received an extensive rewrite.
44

5-
An Android app is [available on ZapStore](https://zapstore.dev/apps/naddr1qvzqqqr7pvpzpk4yr0kmdpv3xcalgsrldp7tj7yuc4p76qjtka7z95kgfky02s2nqq2hgetrdqhxwet9dd6x7umgdyh8x6t8dejhgck8a3z). This app is just a frontend, so running the daemon is still required. DO NOT run the daemon on a public machine. The preferred method is to run it on a machine sitting on a private network. Tailscale is preferred and documented in [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md).
5+
An Android app is [available on ZapStore](https://zapstore.dev/apps/naddr1qvzqqqr7pvpzpk4yr0kmdpv3xcalgsrldp7tj7yuc4p76qjtka7z95kgfky02s2nqq2hgetrdqhxwet9dd6x7umgdyh8x6t8dejhgck8a3z). The Android app is a frontend only, so running the daemon is required. DO NOT run the daemon on a public machine. The preferred method is to run it on a machine sitting on a private network. Tailscale is preferred and documented in [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md).
66

77
## Web UI Screenshots
88

@@ -134,5 +134,6 @@ See [docs/SECURITY.md](docs/SECURITY.md) for the full security model.
134134
- [Configuration Reference](docs/CONFIGURATION.md) - All config options
135135
- [Deployment Guide](docs/DEPLOYMENT.md) - Tailscale, reverse proxies, etc.
136136
- [Security Model](docs/SECURITY.md) - Security architecture and threat model
137+
- [Kill Switch Guide](docs/KILLSWITCH.md) - Emergency remote control via Nostr DMs
137138
- [API Reference](docs/API.md) - REST API endpoints
138139
- [Android App](docs/ANDROID.md) - Setup and building the mobile app

VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
1.3.0
1+
1.4.0

apps/signet-android/app/build.gradle.kts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ android {
3030
applicationId = "tech.geektoshi.signet"
3131
minSdk = 26
3232
targetSdk = 35
33-
versionCode = 5
33+
versionCode = 6
3434
versionName = appVersion
3535

3636
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"

apps/signet-android/app/src/main/kotlin/tech/geektoshi/signet/data/api/SignetApiClient.kt

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
package tech.geektoshi.signet.data.api
22

3+
import tech.geektoshi.signet.data.model.AdminActivityEntry
4+
import tech.geektoshi.signet.data.model.AdminActivityResponse
35
import tech.geektoshi.signet.data.model.ApproveRequestBody
46
import tech.geektoshi.signet.data.model.AppsResponse
57
import tech.geektoshi.signet.data.model.ConnectionTokenResponse
68
import tech.geektoshi.signet.data.model.SuspendAppBody
79
import tech.geektoshi.signet.data.model.DashboardResponse
10+
import tech.geektoshi.signet.data.model.HealthStatus
811
import tech.geektoshi.signet.data.model.KeysResponse
912
import tech.geektoshi.signet.data.model.OperationResponse
1013
import tech.geektoshi.signet.data.model.RelaysResponse
@@ -20,11 +23,13 @@ import io.ktor.client.request.get
2023
import io.ktor.client.request.parameter
2124
import io.ktor.client.request.patch
2225
import io.ktor.client.request.post
26+
import io.ktor.client.request.header
2327
import io.ktor.client.request.setBody
2428
import io.ktor.http.ContentType
2529
import io.ktor.http.contentType
2630
import io.ktor.serialization.kotlinx.json.json
2731
import kotlinx.serialization.json.Json
32+
import tech.geektoshi.signet.BuildConfig
2833

2934
class SignetApiClient(
3035
private val baseUrl: String
@@ -42,6 +47,8 @@ class SignetApiClient(
4247
contentType(ContentType.Application.Json)
4348
// Bearer auth skips CSRF checks (token value doesn't matter when requireAuth=false)
4449
bearerAuth("android-client")
50+
// Identify client for admin activity logging
51+
header("X-Signet-Client", "Signet Android/${BuildConfig.VERSION_NAME}")
4552
}
4653
}
4754

@@ -54,16 +61,21 @@ class SignetApiClient(
5461

5562
/**
5663
* Get list of requests
64+
* @param excludeAdmin When true with status="all", excludes admin events from response
5765
*/
5866
suspend fun getRequests(
5967
status: String = "pending",
6068
limit: Int = 50,
61-
offset: Int = 0
69+
offset: Int = 0,
70+
excludeAdmin: Boolean = false
6271
): RequestsResponse {
6372
return client.get("/requests") {
6473
parameter("status", status)
6574
parameter("limit", limit)
6675
parameter("offset", offset)
76+
if (excludeAdmin) {
77+
parameter("excludeAdmin", "true")
78+
}
6779
}.body()
6880
}
6981

@@ -222,6 +234,27 @@ class SignetApiClient(
222234
return client.get("/relays").body()
223235
}
224236

237+
/**
238+
* Get full health status from daemon
239+
*/
240+
suspend fun getHealth(): HealthStatus {
241+
return client.get("/health").body()
242+
}
243+
244+
/**
245+
* Get admin activity (key lock/unlock, app suspend/resume, daemon start events)
246+
*/
247+
suspend fun getAdminActivity(
248+
limit: Int = 50,
249+
offset: Int = 0
250+
): List<AdminActivityEntry> {
251+
return client.get("/requests") {
252+
parameter("status", "admin")
253+
parameter("limit", limit)
254+
parameter("offset", offset)
255+
}.body<AdminActivityResponse>().requests
256+
}
257+
225258
/**
226259
* Check if the daemon is reachable
227260
*/

apps/signet-android/app/src/main/kotlin/tech/geektoshi/signet/data/api/SignetSSEClient.kt

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
package tech.geektoshi.signet.data.api
22

3+
import tech.geektoshi.signet.data.model.ActivityEntry
34
import tech.geektoshi.signet.data.model.DashboardStats
5+
import tech.geektoshi.signet.data.model.MixedActivityEntry
46
import tech.geektoshi.signet.data.model.PendingRequest
57
import io.ktor.client.HttpClient
68
import io.ktor.client.engine.okhttp.OkHttp
79
import io.ktor.client.plugins.defaultRequest
10+
import io.ktor.client.request.header
811
import io.ktor.client.request.prepareGet
12+
import tech.geektoshi.signet.BuildConfig
913
import io.ktor.client.statement.bodyAsChannel
1014
import io.ktor.utils.io.readUTF8Line
1115
import kotlinx.coroutines.CancellationException
@@ -35,13 +39,15 @@ sealed class ServerEvent {
3539
@Serializable
3640
data class RequestApproved(
3741
val type: String = "request:approved",
38-
val requestId: String
42+
val requestId: String,
43+
val activity: ActivityEntry
3944
) : ServerEvent()
4045

4146
@Serializable
4247
data class RequestDenied(
4348
val type: String = "request:denied",
44-
val requestId: String
49+
val requestId: String,
50+
val activity: ActivityEntry
4551
) : ServerEvent()
4652

4753
@Serializable
@@ -52,7 +58,13 @@ sealed class ServerEvent {
5258

5359
@Serializable
5460
data class RequestAutoApproved(
55-
val type: String = "request:auto_approved"
61+
val type: String = "request:auto_approved",
62+
val activity: ActivityEntry
63+
) : ServerEvent()
64+
65+
@Serializable
66+
data class Ping(
67+
val type: String = "ping"
5668
) : ServerEvent()
5769

5870
@Serializable
@@ -101,6 +113,12 @@ sealed class ServerEvent {
101113
val keyName: String
102114
) : ServerEvent()
103115

116+
@Serializable
117+
data class AdminEvent(
118+
val type: String = "admin:event",
119+
val activity: MixedActivityEntry
120+
) : ServerEvent()
121+
104122
@Serializable
105123
data class Unknown(val type: String) : ServerEvent()
106124
}
@@ -130,6 +148,8 @@ class SignetSSEClient(
130148
private val client = HttpClient(OkHttp) {
131149
defaultRequest {
132150
url(baseUrl)
151+
// Identify client for admin activity logging
152+
header("X-Signet-Client", "Signet Android/${BuildConfig.VERSION_NAME}")
133153
}
134154
}
135155

@@ -203,7 +223,7 @@ class SignetSSEClient(
203223
"request:approved" -> json.decodeFromString<ServerEvent.RequestApproved>(data)
204224
"request:denied" -> json.decodeFromString<ServerEvent.RequestDenied>(data)
205225
"request:expired" -> json.decodeFromString<ServerEvent.RequestExpired>(data)
206-
"request:auto_approved" -> ServerEvent.RequestAutoApproved()
226+
"request:auto_approved" -> json.decodeFromString<ServerEvent.RequestAutoApproved>(data)
207227
"stats:updated" -> json.decodeFromString<ServerEvent.StatsUpdated>(data)
208228
"app:connected" -> ServerEvent.AppConnected()
209229
"app:revoked" -> json.decodeFromString<ServerEvent.AppRevoked>(data)
@@ -213,6 +233,8 @@ class SignetSSEClient(
213233
"key:deleted" -> json.decodeFromString<ServerEvent.KeyDeleted>(data)
214234
"key:renamed" -> json.decodeFromString<ServerEvent.KeyRenamed>(data)
215235
"key:updated" -> json.decodeFromString<ServerEvent.KeyUpdated>(data)
236+
"admin:event" -> json.decodeFromString<ServerEvent.AdminEvent>(data)
237+
"ping" -> ServerEvent.Ping()
216238
else -> ServerEvent.Unknown(type)
217239
}
218240
} catch (e: Exception) {

0 commit comments

Comments
 (0)