Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
6cff006e54
|
|||
|
7f5bd3960c
|
|||
|
8287035920
|
|||
|
54a01e1255
|
|||
|
0bcd83bde3
|
|||
|
26c754bb2e
|
|||
|
da66e26614
|
|||
|
8609e9dc22
|
|||
|
3cb05a451c
|
@@ -112,17 +112,6 @@ func (l *Listener) HandleEvent(msg []byte) (err error) {
|
|||||||
|
|
||||||
// Check if policy is enabled and process event through it
|
// Check if policy is enabled and process event through it
|
||||||
if l.policyManager != nil && l.policyManager.Manager != nil && l.policyManager.Manager.IsEnabled() {
|
if l.policyManager != nil && l.policyManager.Manager != nil && l.policyManager.Manager.IsEnabled() {
|
||||||
if l.policyManager.Manager.IsDisabled() {
|
|
||||||
// Policy is disabled due to failure - reject all events
|
|
||||||
log.W.F("policy is disabled, rejecting event %0x", env.E.ID)
|
|
||||||
if err = Ok.Error(
|
|
||||||
l, env,
|
|
||||||
"policy disabled - events rejected until policy is restored",
|
|
||||||
); chk.E(err) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check policy for write access
|
// Check policy for write access
|
||||||
allowed, policyErr := l.policyManager.CheckPolicy("write", env.E, l.authedPubkey.Load(), l.remote)
|
allowed, policyErr := l.policyManager.CheckPolicy("write", env.E, l.authedPubkey.Load(), l.remote)
|
||||||
|
|||||||
@@ -431,6 +431,44 @@ privCheck:
|
|||||||
allEvents = aclFilteredEvents
|
allEvents = aclFilteredEvents
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply private tag filtering - only show events with "private" tags to authorized users
|
||||||
|
var privateFilteredEvents event.S
|
||||||
|
authedPubkey := l.authedPubkey.Load()
|
||||||
|
for _, ev := range allEvents {
|
||||||
|
// Check if event has private tags
|
||||||
|
hasPrivateTag := false
|
||||||
|
var privatePubkey []byte
|
||||||
|
|
||||||
|
if ev.Tags != nil && ev.Tags.Len() > 0 {
|
||||||
|
for _, t := range *ev.Tags {
|
||||||
|
if t.Len() >= 2 {
|
||||||
|
keyBytes := t.Key()
|
||||||
|
if len(keyBytes) == 7 && string(keyBytes) == "private" {
|
||||||
|
hasPrivateTag = true
|
||||||
|
privatePubkey = t.Value()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no private tag, include the event
|
||||||
|
if !hasPrivateTag {
|
||||||
|
privateFilteredEvents = append(privateFilteredEvents, ev)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event has private tag - check if user is authorized to see it
|
||||||
|
canSeePrivate := l.canSeePrivateEvent(authedPubkey, privatePubkey)
|
||||||
|
if canSeePrivate {
|
||||||
|
privateFilteredEvents = append(privateFilteredEvents, ev)
|
||||||
|
log.D.F("private tag: allowing event %s for authorized user", hexenc.Enc(ev.ID))
|
||||||
|
} else {
|
||||||
|
log.D.F("private tag: filtering out event %s from unauthorized user", hexenc.Enc(ev.ID))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
allEvents = privateFilteredEvents
|
||||||
|
|
||||||
seen := make(map[string]struct{})
|
seen := make(map[string]struct{})
|
||||||
for _, ev := range allEvents {
|
for _, ev := range allEvents {
|
||||||
log.T.C(
|
log.T.C(
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"next.orly.dev/pkg/database"
|
"next.orly.dev/pkg/database"
|
||||||
"next.orly.dev/pkg/encoders/event"
|
"next.orly.dev/pkg/encoders/event"
|
||||||
"next.orly.dev/pkg/encoders/filter"
|
"next.orly.dev/pkg/encoders/filter"
|
||||||
|
"next.orly.dev/pkg/utils"
|
||||||
"next.orly.dev/pkg/utils/atomic"
|
"next.orly.dev/pkg/utils/atomic"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -133,3 +134,25 @@ func (l *Listener) QueryEvents(ctx context.Context, f *filter.F) (event.S, error
|
|||||||
func (l *Listener) QueryAllVersions(ctx context.Context, f *filter.F) (event.S, error) {
|
func (l *Listener) QueryAllVersions(ctx context.Context, f *filter.F) (event.S, error) {
|
||||||
return l.D.QueryAllVersions(ctx, f)
|
return l.D.QueryAllVersions(ctx, f)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// canSeePrivateEvent checks if the authenticated user can see an event with a private tag
|
||||||
|
func (l *Listener) canSeePrivateEvent(authedPubkey, privatePubkey []byte) (canSee bool) {
|
||||||
|
// If no authenticated user, deny access
|
||||||
|
if len(authedPubkey) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the authenticated user matches the private tag pubkey, allow access
|
||||||
|
if len(privatePubkey) > 0 && utils.FastEqual(authedPubkey, privatePubkey) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user is an admin or owner (they can see all private events)
|
||||||
|
accessLevel := acl.Registry.GetAccessLevel(authedPubkey, l.remote)
|
||||||
|
if accessLevel == "admin" || accessLevel == "owner" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default deny
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|||||||
@@ -349,6 +349,8 @@ Log in to the relay dashboard to access your configuration at: %s`,
|
|||||||
if len(authorizedNpubs) > 0 {
|
if len(authorizedNpubs) > 0 {
|
||||||
privateTagValue := strings.Join(authorizedNpubs, ",")
|
privateTagValue := strings.Join(authorizedNpubs, ",")
|
||||||
*ev.Tags = append(*ev.Tags, tag.NewFromAny("private", privateTagValue))
|
*ev.Tags = append(*ev.Tags, tag.NewFromAny("private", privateTagValue))
|
||||||
|
// Add protected "-" tag to mark this event as protected
|
||||||
|
*ev.Tags = append(*ev.Tags, tag.NewFromAny("-", ""))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add a special tag to mark this as an expiry warning
|
// Add a special tag to mark this as an expiry warning
|
||||||
@@ -465,6 +467,8 @@ Log in to the relay dashboard to access your configuration at: %s`,
|
|||||||
if len(authorizedNpubs) > 0 {
|
if len(authorizedNpubs) > 0 {
|
||||||
privateTagValue := strings.Join(authorizedNpubs, ",")
|
privateTagValue := strings.Join(authorizedNpubs, ",")
|
||||||
*ev.Tags = append(*ev.Tags, tag.NewFromAny("private", privateTagValue))
|
*ev.Tags = append(*ev.Tags, tag.NewFromAny("private", privateTagValue))
|
||||||
|
// Add protected "-" tag to mark this event as protected
|
||||||
|
*ev.Tags = append(*ev.Tags, tag.NewFromAny("-", ""))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add a special tag to mark this as a trial reminder
|
// Add a special tag to mark this as a trial reminder
|
||||||
@@ -691,6 +695,8 @@ func (pp *PaymentProcessor) createPaymentNote(
|
|||||||
if len(authorizedNpubs) > 0 {
|
if len(authorizedNpubs) > 0 {
|
||||||
privateTagValue := strings.Join(authorizedNpubs, ",")
|
privateTagValue := strings.Join(authorizedNpubs, ",")
|
||||||
*ev.Tags = append(*ev.Tags, tag.NewFromAny("private", privateTagValue))
|
*ev.Tags = append(*ev.Tags, tag.NewFromAny("private", privateTagValue))
|
||||||
|
// Add protected "-" tag to mark this event as protected
|
||||||
|
*ev.Tags = append(*ev.Tags, tag.NewFromAny("-", ""))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sign and save the event
|
// Sign and save the event
|
||||||
@@ -731,9 +737,19 @@ func (pp *PaymentProcessor) CreateWelcomeNote(userPubkey []byte) error {
|
|||||||
return fmt.Errorf("failed to encode relay npub: %w", err)
|
return fmt.Errorf("failed to encode relay npub: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create the welcome note content with nostr:npub link
|
// Get user npub for personalized greeting
|
||||||
|
userNpub, err := bech32encoding.BinToNpub(userPubkey)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to encode user npub: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the welcome note content with privacy notice and personalized greeting
|
||||||
content := fmt.Sprintf(
|
content := fmt.Sprintf(
|
||||||
`Welcome to the relay! 🎉
|
`This note is only visible to you
|
||||||
|
|
||||||
|
Hi nostr:%s
|
||||||
|
|
||||||
|
Welcome to the relay! 🎉
|
||||||
|
|
||||||
You have a FREE 30-day trial that started when you first logged in.
|
You have a FREE 30-day trial that started when you first logged in.
|
||||||
|
|
||||||
@@ -753,7 +769,7 @@ Relay: nostr:%s
|
|||||||
|
|
||||||
Log in to the relay dashboard to access your configuration at: %s
|
Log in to the relay dashboard to access your configuration at: %s
|
||||||
|
|
||||||
Enjoy your time on the relay!`, monthlyPrice, monthlyPrice,
|
Enjoy your time on the relay!`, string(userNpub), monthlyPrice, monthlyPrice,
|
||||||
string(relayNpubForContent), pp.getDashboardURL(),
|
string(relayNpubForContent), pp.getDashboardURL(),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -765,8 +781,8 @@ Enjoy your time on the relay!`, monthlyPrice, monthlyPrice,
|
|||||||
ev.Content = []byte(content)
|
ev.Content = []byte(content)
|
||||||
ev.Tags = tag.NewS()
|
ev.Tags = tag.NewS()
|
||||||
|
|
||||||
// Add "p" tag for the user
|
// Add "p" tag for the user with mention in third field
|
||||||
*ev.Tags = append(*ev.Tags, tag.NewFromAny("p", hex.Enc(userPubkey)))
|
*ev.Tags = append(*ev.Tags, tag.NewFromAny("p", hex.Enc(userPubkey), "", "mention"))
|
||||||
|
|
||||||
// Add expiration tag (5 days from creation)
|
// Add expiration tag (5 days from creation)
|
||||||
noteExpiry := time.Now().AddDate(0, 0, 5)
|
noteExpiry := time.Now().AddDate(0, 0, 5)
|
||||||
@@ -778,11 +794,8 @@ Enjoy your time on the relay!`, monthlyPrice, monthlyPrice,
|
|||||||
// Add "private" tag with authorized npubs (user and relay)
|
// Add "private" tag with authorized npubs (user and relay)
|
||||||
var authorizedNpubs []string
|
var authorizedNpubs []string
|
||||||
|
|
||||||
// Add user npub
|
// Add user npub (already encoded above)
|
||||||
userNpub, err := bech32encoding.BinToNpub(userPubkey)
|
|
||||||
if err == nil {
|
|
||||||
authorizedNpubs = append(authorizedNpubs, string(userNpub))
|
authorizedNpubs = append(authorizedNpubs, string(userNpub))
|
||||||
}
|
|
||||||
|
|
||||||
// Add relay npub
|
// Add relay npub
|
||||||
relayNpub, err := bech32encoding.BinToNpub(sign.Pub())
|
relayNpub, err := bech32encoding.BinToNpub(sign.Pub())
|
||||||
@@ -794,6 +807,8 @@ Enjoy your time on the relay!`, monthlyPrice, monthlyPrice,
|
|||||||
if len(authorizedNpubs) > 0 {
|
if len(authorizedNpubs) > 0 {
|
||||||
privateTagValue := strings.Join(authorizedNpubs, ",")
|
privateTagValue := strings.Join(authorizedNpubs, ",")
|
||||||
*ev.Tags = append(*ev.Tags, tag.NewFromAny("private", privateTagValue))
|
*ev.Tags = append(*ev.Tags, tag.NewFromAny("private", privateTagValue))
|
||||||
|
// Add protected "-" tag to mark this event as protected
|
||||||
|
*ev.Tags = append(*ev.Tags, tag.NewFromAny("-", ""))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add a special tag to mark this as a welcome note
|
// Add a special tag to mark this as a welcome note
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"github.com/coder/websocket"
|
"github.com/coder/websocket"
|
||||||
"lol.mleku.dev/chk"
|
"lol.mleku.dev/chk"
|
||||||
"lol.mleku.dev/log"
|
"lol.mleku.dev/log"
|
||||||
|
"next.orly.dev/pkg/acl"
|
||||||
"next.orly.dev/pkg/encoders/envelopes/eventenvelope"
|
"next.orly.dev/pkg/encoders/envelopes/eventenvelope"
|
||||||
"next.orly.dev/pkg/encoders/event"
|
"next.orly.dev/pkg/encoders/event"
|
||||||
"next.orly.dev/pkg/encoders/filter"
|
"next.orly.dev/pkg/encoders/filter"
|
||||||
@@ -220,6 +221,34 @@ func (p *P) Deliver(ev *event.E) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check for private tags - only deliver to authorized users
|
||||||
|
if ev.Tags != nil && ev.Tags.Len() > 0 {
|
||||||
|
hasPrivateTag := false
|
||||||
|
var privatePubkey []byte
|
||||||
|
|
||||||
|
for _, t := range *ev.Tags {
|
||||||
|
if t.Len() >= 2 {
|
||||||
|
keyBytes := t.Key()
|
||||||
|
if len(keyBytes) == 7 && string(keyBytes) == "private" {
|
||||||
|
hasPrivateTag = true
|
||||||
|
privatePubkey = t.Value()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if hasPrivateTag {
|
||||||
|
canSeePrivate := p.canSeePrivateEvent(d.sub.AuthedPubkey, privatePubkey, d.sub.remote)
|
||||||
|
if !canSeePrivate {
|
||||||
|
log.D.F("subscription delivery DENIED for private event %s to %s (unauthorized)",
|
||||||
|
hex.Enc(ev.ID), d.sub.remote)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
log.D.F("subscription delivery ALLOWED for private event %s to %s (authorized)",
|
||||||
|
hex.Enc(ev.ID), d.sub.remote)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var res *eventenvelope.Result
|
var res *eventenvelope.Result
|
||||||
if res, err = eventenvelope.NewResultWith(d.id, ev); chk.E(err) {
|
if res, err = eventenvelope.NewResultWith(d.id, ev); chk.E(err) {
|
||||||
log.E.F("failed to create event envelope for %s to %s: %v",
|
log.E.F("failed to create event envelope for %s to %s: %v",
|
||||||
@@ -299,3 +328,25 @@ func (p *P) removeSubscriber(ws *websocket.Conn) {
|
|||||||
clear(p.Map[ws])
|
clear(p.Map[ws])
|
||||||
delete(p.Map, ws)
|
delete(p.Map, ws)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// canSeePrivateEvent checks if the authenticated user can see an event with a private tag
|
||||||
|
func (p *P) canSeePrivateEvent(authedPubkey, privatePubkey []byte, remote string) (canSee bool) {
|
||||||
|
// If no authenticated user, deny access
|
||||||
|
if len(authedPubkey) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the authenticated user matches the private tag pubkey, allow access
|
||||||
|
if len(privatePubkey) > 0 && utils.FastEqual(authedPubkey, privatePubkey) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user is an admin or owner (they can see all private events)
|
||||||
|
accessLevel := acl.Registry.GetAccessLevel(authedPubkey, remote)
|
||||||
|
if accessLevel == "admin" || accessLevel == "owner" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default deny
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ The policy configuration is loaded from `$HOME/.config/ORLY/policy.json`. See `d
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
|
"default_policy": "allow",
|
||||||
"kind": {
|
"kind": {
|
||||||
"whitelist": [1, 3, 5, 7, 9735],
|
"whitelist": [1, 3, 5, 7, 9735],
|
||||||
"blacklist": []
|
"blacklist": []
|
||||||
@@ -48,6 +49,17 @@ The policy configuration is loaded from `$HOME/.config/ORLY/policy.json`. See `d
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Default Policy
|
||||||
|
|
||||||
|
The `default_policy` field determines the default behavior when no specific rules deny an event:
|
||||||
|
|
||||||
|
- `"allow"` (default): Events are allowed unless explicitly denied by rules
|
||||||
|
- `"deny"`: Events are denied unless explicitly allowed by rules
|
||||||
|
|
||||||
|
This applies to:
|
||||||
|
- Events of whitelisted kinds that have no specific rules
|
||||||
|
- Events that pass all other policy checks but have no explicit allow/deny decision
|
||||||
|
|
||||||
### Policy Evaluation Order
|
### Policy Evaluation Order
|
||||||
|
|
||||||
The policy system evaluates events in the following order:
|
The policy system evaluates events in the following order:
|
||||||
@@ -56,6 +68,7 @@ The policy system evaluates events in the following order:
|
|||||||
2. **Kinds Filtering** - Whitelist/blacklist by event kind
|
2. **Kinds Filtering** - Whitelist/blacklist by event kind
|
||||||
3. **Kind-specific Rules** - Rules for specific event kinds
|
3. **Kind-specific Rules** - Rules for specific event kinds
|
||||||
4. **Script Rules** - Custom script logic (if enabled)
|
4. **Script Rules** - Custom script logic (if enabled)
|
||||||
|
5. **Default Policy** - Applied when no rules make a decision
|
||||||
|
|
||||||
### Global Rules
|
### Global Rules
|
||||||
|
|
||||||
@@ -173,17 +186,41 @@ When policy is enabled, every EVENT envelope is checked using `CheckPolicy("writ
|
|||||||
|
|
||||||
When policy is enabled, every event returned in REQ responses is filtered using `CheckPolicy("read", event, loggedInPubkey, ipAddress)` before being sent to the client. The same evaluation order applies for read access.
|
When policy is enabled, every event returned in REQ responses is filtered using `CheckPolicy("read", event, loggedInPubkey, ipAddress)` before being sent to the client. The same evaluation order applies for read access.
|
||||||
|
|
||||||
## Error Handling
|
## Script Resilience
|
||||||
|
|
||||||
- If policy script fails or times out, events are allowed by default
|
The policy system is designed to be resilient to script failures:
|
||||||
|
|
||||||
|
### Automatic Recovery
|
||||||
|
- Policy scripts are automatically restarted if they crash or fail to load
|
||||||
|
- The system continuously monitors script health and attempts recovery every 60 seconds (1 minute)
|
||||||
|
- Script failures don't disable the entire policy system
|
||||||
|
|
||||||
|
### Fallback Behavior
|
||||||
|
When a policy script fails or is not running:
|
||||||
|
- Events that would have been processed by the script fall back to the `default_policy`
|
||||||
|
- The system logs which policy rule is inactive and the fallback behavior
|
||||||
|
- Other policy rules (global, kinds, non-script rules) continue to function normally
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
|
||||||
|
- If policy script fails or times out, events fall back to `default_policy` setting
|
||||||
- If policy configuration is invalid, default policy (allow all) is used
|
- If policy configuration is invalid, default policy (allow all) is used
|
||||||
- Policy script failures are logged but don't block relay operation
|
- Policy script failures are logged with specific rule information but don't block relay operation
|
||||||
|
|
||||||
## Monitoring
|
## Monitoring
|
||||||
|
|
||||||
Policy decisions are logged at debug level:
|
Policy decisions and script health are logged:
|
||||||
|
|
||||||
|
### Policy Decisions
|
||||||
- `policy allowed event <id>`
|
- `policy allowed event <id>`
|
||||||
- `policy rejected event <id>`
|
- `policy rejected event <id>`
|
||||||
|
|
||||||
|
### Script Health
|
||||||
|
- `policy rule for kind <N> is inactive (script not running), falling back to default policy (<policy>)`
|
||||||
|
- `policy rule for kind <N> failed (script processing error: <error>), falling back to default policy (<policy>)`
|
||||||
|
- `policy rule for kind <N> returned unknown action '<action>', falling back to default policy (<policy>)`
|
||||||
|
- `policy script not found at <path>, will retry periodically`
|
||||||
|
- `policy script crashed - events will fall back to default policy until restart`
|
||||||
- `policy filtered out event <id> for read access`
|
- `policy filtered out event <id> for read access`
|
||||||
|
|
||||||
## Best Practices
|
## Best Practices
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
{
|
{
|
||||||
|
"default_policy": "allow",
|
||||||
"kind": {
|
"kind": {
|
||||||
"whitelist": [0, 1, 3, 4, 5, 6, 7, 40, 41, 42, 43, 44, 9735],
|
"whitelist": [0, 1, 3, 4, 5, 6, 7, 40, 41, 42, 43, 44, 9735],
|
||||||
"blacklist": []
|
"blacklist": []
|
||||||
|
|||||||
2
go.mod
2
go.mod
@@ -20,7 +20,7 @@ require (
|
|||||||
golang.org/x/lint v0.0.0-20241112194109-818c5a804067
|
golang.org/x/lint v0.0.0-20241112194109-818c5a804067
|
||||||
golang.org/x/net v0.44.0
|
golang.org/x/net v0.44.0
|
||||||
honnef.co/go/tools v0.6.1
|
honnef.co/go/tools v0.6.1
|
||||||
lol.mleku.dev v1.0.3
|
lol.mleku.dev v1.0.4
|
||||||
lukechampine.com/frand v1.5.1
|
lukechampine.com/frand v1.5.1
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
144
main.go
144
main.go
@@ -9,6 +9,8 @@ import (
|
|||||||
"os/exec"
|
"os/exec"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"sync"
|
||||||
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/pkg/profile"
|
"github.com/pkg/profile"
|
||||||
@@ -21,6 +23,7 @@ import (
|
|||||||
"next.orly.dev/pkg/database"
|
"next.orly.dev/pkg/database"
|
||||||
"next.orly.dev/pkg/encoders/hex"
|
"next.orly.dev/pkg/encoders/hex"
|
||||||
"next.orly.dev/pkg/spider"
|
"next.orly.dev/pkg/spider"
|
||||||
|
"next.orly.dev/pkg/utils/interrupt"
|
||||||
"next.orly.dev/pkg/version"
|
"next.orly.dev/pkg/version"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -83,16 +86,31 @@ func main() {
|
|||||||
log.I.F("enabling HTTP pprof server to support web viewer")
|
log.I.F("enabling HTTP pprof server to support web viewer")
|
||||||
cfg.PprofHTTP = true
|
cfg.PprofHTTP = true
|
||||||
}
|
}
|
||||||
|
// Ensure profiling is stopped on interrupts (SIGINT/SIGTERM) as well as on normal exit
|
||||||
|
var profileStopOnce sync.Once
|
||||||
|
profileStop := func() {}
|
||||||
switch cfg.Pprof {
|
switch cfg.Pprof {
|
||||||
case "cpu":
|
case "cpu":
|
||||||
if cfg.PprofPath != "" {
|
if cfg.PprofPath != "" {
|
||||||
prof := profile.Start(
|
prof := profile.Start(
|
||||||
profile.CPUProfile, profile.ProfilePath(cfg.PprofPath),
|
profile.CPUProfile, profile.ProfilePath(cfg.PprofPath),
|
||||||
)
|
)
|
||||||
defer prof.Stop()
|
profileStop = func() {
|
||||||
|
profileStopOnce.Do(func() {
|
||||||
|
prof.Stop()
|
||||||
|
log.I.F("cpu profiling stopped and flushed")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
defer profileStop()
|
||||||
} else {
|
} else {
|
||||||
prof := profile.Start(profile.CPUProfile)
|
prof := profile.Start(profile.CPUProfile)
|
||||||
defer prof.Stop()
|
profileStop = func() {
|
||||||
|
profileStopOnce.Do(func() {
|
||||||
|
prof.Stop()
|
||||||
|
log.I.F("cpu profiling stopped and flushed")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
defer profileStop()
|
||||||
}
|
}
|
||||||
case "memory":
|
case "memory":
|
||||||
if cfg.PprofPath != "" {
|
if cfg.PprofPath != "" {
|
||||||
@@ -100,10 +118,22 @@ func main() {
|
|||||||
profile.MemProfile, profile.MemProfileRate(32),
|
profile.MemProfile, profile.MemProfileRate(32),
|
||||||
profile.ProfilePath(cfg.PprofPath),
|
profile.ProfilePath(cfg.PprofPath),
|
||||||
)
|
)
|
||||||
defer prof.Stop()
|
profileStop = func() {
|
||||||
|
profileStopOnce.Do(func() {
|
||||||
|
prof.Stop()
|
||||||
|
log.I.F("memory profiling stopped and flushed")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
defer profileStop()
|
||||||
} else {
|
} else {
|
||||||
prof := profile.Start(profile.MemProfile)
|
prof := profile.Start(profile.MemProfile)
|
||||||
defer prof.Stop()
|
profileStop = func() {
|
||||||
|
profileStopOnce.Do(func() {
|
||||||
|
prof.Stop()
|
||||||
|
log.I.F("memory profiling stopped and flushed")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
defer profileStop()
|
||||||
}
|
}
|
||||||
case "allocation":
|
case "allocation":
|
||||||
if cfg.PprofPath != "" {
|
if cfg.PprofPath != "" {
|
||||||
@@ -111,30 +141,66 @@ func main() {
|
|||||||
profile.MemProfileAllocs, profile.MemProfileRate(32),
|
profile.MemProfileAllocs, profile.MemProfileRate(32),
|
||||||
profile.ProfilePath(cfg.PprofPath),
|
profile.ProfilePath(cfg.PprofPath),
|
||||||
)
|
)
|
||||||
defer prof.Stop()
|
profileStop = func() {
|
||||||
|
profileStopOnce.Do(func() {
|
||||||
|
prof.Stop()
|
||||||
|
log.I.F("allocation profiling stopped and flushed")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
defer profileStop()
|
||||||
} else {
|
} else {
|
||||||
prof := profile.Start(profile.MemProfileAllocs)
|
prof := profile.Start(profile.MemProfileAllocs)
|
||||||
defer prof.Stop()
|
profileStop = func() {
|
||||||
|
profileStopOnce.Do(func() {
|
||||||
|
prof.Stop()
|
||||||
|
log.I.F("allocation profiling stopped and flushed")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
defer profileStop()
|
||||||
}
|
}
|
||||||
case "heap":
|
case "heap":
|
||||||
if cfg.PprofPath != "" {
|
if cfg.PprofPath != "" {
|
||||||
prof := profile.Start(
|
prof := profile.Start(
|
||||||
profile.MemProfileHeap, profile.ProfilePath(cfg.PprofPath),
|
profile.MemProfileHeap, profile.ProfilePath(cfg.PprofPath),
|
||||||
)
|
)
|
||||||
defer prof.Stop()
|
profileStop = func() {
|
||||||
|
profileStopOnce.Do(func() {
|
||||||
|
prof.Stop()
|
||||||
|
log.I.F("heap profiling stopped and flushed")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
defer profileStop()
|
||||||
} else {
|
} else {
|
||||||
prof := profile.Start(profile.MemProfileHeap)
|
prof := profile.Start(profile.MemProfileHeap)
|
||||||
defer prof.Stop()
|
profileStop = func() {
|
||||||
|
profileStopOnce.Do(func() {
|
||||||
|
prof.Stop()
|
||||||
|
log.I.F("heap profiling stopped and flushed")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
defer profileStop()
|
||||||
}
|
}
|
||||||
case "mutex":
|
case "mutex":
|
||||||
if cfg.PprofPath != "" {
|
if cfg.PprofPath != "" {
|
||||||
prof := profile.Start(
|
prof := profile.Start(
|
||||||
profile.MutexProfile, profile.ProfilePath(cfg.PprofPath),
|
profile.MutexProfile, profile.ProfilePath(cfg.PprofPath),
|
||||||
)
|
)
|
||||||
defer prof.Stop()
|
profileStop = func() {
|
||||||
|
profileStopOnce.Do(func() {
|
||||||
|
prof.Stop()
|
||||||
|
log.I.F("mutex profiling stopped and flushed")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
defer profileStop()
|
||||||
} else {
|
} else {
|
||||||
prof := profile.Start(profile.MutexProfile)
|
prof := profile.Start(profile.MutexProfile)
|
||||||
defer prof.Stop()
|
profileStop = func() {
|
||||||
|
profileStopOnce.Do(func() {
|
||||||
|
prof.Stop()
|
||||||
|
log.I.F("mutex profiling stopped and flushed")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
defer profileStop()
|
||||||
}
|
}
|
||||||
case "threadcreate":
|
case "threadcreate":
|
||||||
if cfg.PprofPath != "" {
|
if cfg.PprofPath != "" {
|
||||||
@@ -142,33 +208,75 @@ func main() {
|
|||||||
profile.ThreadcreationProfile,
|
profile.ThreadcreationProfile,
|
||||||
profile.ProfilePath(cfg.PprofPath),
|
profile.ProfilePath(cfg.PprofPath),
|
||||||
)
|
)
|
||||||
defer prof.Stop()
|
profileStop = func() {
|
||||||
|
profileStopOnce.Do(func() {
|
||||||
|
prof.Stop()
|
||||||
|
log.I.F("threadcreate profiling stopped and flushed")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
defer profileStop()
|
||||||
} else {
|
} else {
|
||||||
prof := profile.Start(profile.ThreadcreationProfile)
|
prof := profile.Start(profile.ThreadcreationProfile)
|
||||||
defer prof.Stop()
|
profileStop = func() {
|
||||||
|
profileStopOnce.Do(func() {
|
||||||
|
prof.Stop()
|
||||||
|
log.I.F("threadcreate profiling stopped and flushed")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
defer profileStop()
|
||||||
}
|
}
|
||||||
case "goroutine":
|
case "goroutine":
|
||||||
if cfg.PprofPath != "" {
|
if cfg.PprofPath != "" {
|
||||||
prof := profile.Start(
|
prof := profile.Start(
|
||||||
profile.GoroutineProfile, profile.ProfilePath(cfg.PprofPath),
|
profile.GoroutineProfile, profile.ProfilePath(cfg.PprofPath),
|
||||||
)
|
)
|
||||||
defer prof.Stop()
|
profileStop = func() {
|
||||||
|
profileStopOnce.Do(func() {
|
||||||
|
prof.Stop()
|
||||||
|
log.I.F("goroutine profiling stopped and flushed")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
defer profileStop()
|
||||||
} else {
|
} else {
|
||||||
prof := profile.Start(profile.GoroutineProfile)
|
prof := profile.Start(profile.GoroutineProfile)
|
||||||
defer prof.Stop()
|
profileStop = func() {
|
||||||
|
profileStopOnce.Do(func() {
|
||||||
|
prof.Stop()
|
||||||
|
log.I.F("goroutine profiling stopped and flushed")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
defer profileStop()
|
||||||
}
|
}
|
||||||
case "block":
|
case "block":
|
||||||
if cfg.PprofPath != "" {
|
if cfg.PprofPath != "" {
|
||||||
prof := profile.Start(
|
prof := profile.Start(
|
||||||
profile.BlockProfile, profile.ProfilePath(cfg.PprofPath),
|
profile.BlockProfile, profile.ProfilePath(cfg.PprofPath),
|
||||||
)
|
)
|
||||||
defer prof.Stop()
|
profileStop = func() {
|
||||||
|
profileStopOnce.Do(func() {
|
||||||
|
prof.Stop()
|
||||||
|
log.I.F("block profiling stopped and flushed")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
defer profileStop()
|
||||||
} else {
|
} else {
|
||||||
prof := profile.Start(profile.BlockProfile)
|
prof := profile.Start(profile.BlockProfile)
|
||||||
defer prof.Stop()
|
profileStop = func() {
|
||||||
|
profileStopOnce.Do(func() {
|
||||||
|
prof.Stop()
|
||||||
|
log.I.F("block profiling stopped and flushed")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
defer profileStop()
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Register a handler so profiling is stopped when an interrupt is received
|
||||||
|
interrupt.AddHandler(func() {
|
||||||
|
log.I.F("interrupt received: stopping profiling")
|
||||||
|
profileStop()
|
||||||
|
})
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
var db *database.D
|
var db *database.D
|
||||||
if db, err = database.New(
|
if db, err = database.New(
|
||||||
@@ -277,7 +385,7 @@ func main() {
|
|||||||
|
|
||||||
quit := app.Run(ctx, cfg, db)
|
quit := app.Run(ctx, cfg, db)
|
||||||
sigs := make(chan os.Signal, 1)
|
sigs := make(chan os.Signal, 1)
|
||||||
signal.Notify(sigs, os.Interrupt)
|
signal.Notify(sigs, os.Interrupt, syscall.SIGTERM)
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-sigs:
|
case <-sigs:
|
||||||
@@ -296,5 +404,5 @@ func main() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
log.I.F("exiting")
|
// log.I.F("exiting")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,7 +101,6 @@ done
|
|||||||
configDir: tempDir,
|
configDir: tempDir,
|
||||||
scriptPath: scriptPath,
|
scriptPath: scriptPath,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
disabled: false,
|
|
||||||
responseChan: make(chan PolicyResponse, 100),
|
responseChan: make(chan PolicyResponse, 100),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,9 @@ import (
|
|||||||
"next.orly.dev/pkg/encoders/hex"
|
"next.orly.dev/pkg/encoders/hex"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Kinds defines the filter for events by kind; the whitelist overrides the blacklist if it has any fields, and the blacklist is ignored (implicitly all not-whitelisted are blacklisted)
|
// Kinds defines whitelist and blacklist policies for event kinds.
|
||||||
|
// Whitelist takes precedence over blacklist - if whitelist is present, only whitelisted kinds are allowed.
|
||||||
|
// If only blacklist is present, all kinds except blacklisted ones are allowed.
|
||||||
type Kinds struct {
|
type Kinds struct {
|
||||||
// Whitelist is a list of event kinds that are allowed to be written to the relay. If any are present, implicitly all others are denied.
|
// Whitelist is a list of event kinds that are allowed to be written to the relay. If any are present, implicitly all others are denied.
|
||||||
Whitelist []int `json:"whitelist,omitempty"`
|
Whitelist []int `json:"whitelist,omitempty"`
|
||||||
@@ -28,13 +30,16 @@ type Kinds struct {
|
|||||||
Blacklist []int `json:"blacklist,omitempty"`
|
Blacklist []int `json:"blacklist,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rule is a rule for an event kind.
|
// Rule defines policy criteria for a specific event kind.
|
||||||
//
|
//
|
||||||
// If Script is present, it overrides all other criteria.
|
// Rules are evaluated in the following order:
|
||||||
|
// 1. If Script is present and running, it determines the outcome
|
||||||
|
// 2. If Script fails or is not running, falls back to default_policy
|
||||||
|
// 3. Otherwise, all specified criteria are evaluated as AND operations
|
||||||
//
|
//
|
||||||
// The criteria have mutual exclude semantics on pubkey white/blacklists, if whitelist has any fields, blacklist is ignored (implicitly all not-whitelisted are blacklisted).
|
// For pubkey allow/deny lists: whitelist takes precedence over blacklist.
|
||||||
//
|
// If whitelist has entries, only whitelisted pubkeys are allowed.
|
||||||
// The other criteria are evaluated as AND operations, everything specified must match for the event to be allowed to be written to the relay.
|
// If only blacklist has entries, all pubkeys except blacklisted ones are allowed.
|
||||||
type Rule struct {
|
type Rule struct {
|
||||||
// Description is a human-readable description of the rule.
|
// Description is a human-readable description of the rule.
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
@@ -66,14 +71,16 @@ type Rule struct {
|
|||||||
MaxAgeEventInFuture *int64 `json:"max_age_event_in_future,omitempty"`
|
MaxAgeEventInFuture *int64 `json:"max_age_event_in_future,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// PolicyEvent represents an event with additional context for policy scripts
|
// PolicyEvent represents an event with additional context for policy scripts.
|
||||||
|
// It embeds the Nostr event and adds authentication and network context.
|
||||||
type PolicyEvent struct {
|
type PolicyEvent struct {
|
||||||
*event.E
|
*event.E
|
||||||
LoggedInPubkey string `json:"logged_in_pubkey,omitempty"`
|
LoggedInPubkey string `json:"logged_in_pubkey,omitempty"`
|
||||||
IPAddress string `json:"ip_address,omitempty"`
|
IPAddress string `json:"ip_address,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// MarshalJSON implements custom JSON marshaling for PolicyEvent
|
// MarshalJSON implements custom JSON marshaling for PolicyEvent.
|
||||||
|
// It safely serializes the embedded event and additional context fields.
|
||||||
func (pe *PolicyEvent) MarshalJSON() ([]byte, error) {
|
func (pe *PolicyEvent) MarshalJSON() ([]byte, error) {
|
||||||
if pe.E == nil {
|
if pe.E == nil {
|
||||||
return json.Marshal(map[string]interface{}{
|
return json.Marshal(map[string]interface{}{
|
||||||
@@ -104,14 +111,17 @@ func (pe *PolicyEvent) MarshalJSON() ([]byte, error) {
|
|||||||
return json.Marshal(safeEvent)
|
return json.Marshal(safeEvent)
|
||||||
}
|
}
|
||||||
|
|
||||||
// PolicyResponse represents a response from the policy script
|
// PolicyResponse represents a response from the policy script.
|
||||||
|
// The script should return JSON with these fields to indicate its decision.
|
||||||
type PolicyResponse struct {
|
type PolicyResponse struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Action string `json:"action"` // accept, reject, or shadowReject
|
Action string `json:"action"` // accept, reject, or shadowReject
|
||||||
Msg string `json:"msg"` // NIP-20 response message (only used for reject)
|
Msg string `json:"msg"` // NIP-20 response message (only used for reject)
|
||||||
}
|
}
|
||||||
|
|
||||||
// PolicyManager handles policy script execution and management
|
// PolicyManager handles policy script execution and management.
|
||||||
|
// It manages the lifecycle of policy scripts, handles communication with them,
|
||||||
|
// and provides resilient operation with automatic restart capabilities.
|
||||||
type PolicyManager struct {
|
type PolicyManager struct {
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
@@ -122,14 +132,15 @@ type PolicyManager struct {
|
|||||||
mutex sync.RWMutex
|
mutex sync.RWMutex
|
||||||
isRunning bool
|
isRunning bool
|
||||||
enabled bool
|
enabled bool
|
||||||
disabled bool // true when policy is disabled due to failure
|
|
||||||
stdin io.WriteCloser
|
stdin io.WriteCloser
|
||||||
stdout io.ReadCloser
|
stdout io.ReadCloser
|
||||||
stderr io.ReadCloser
|
stderr io.ReadCloser
|
||||||
responseChan chan PolicyResponse
|
responseChan chan PolicyResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
// P is a policy for a relay's ACL.
|
// P represents a complete policy configuration for a Nostr relay.
|
||||||
|
// It defines access control rules, kind filtering, and default behavior.
|
||||||
|
// Policies are evaluated in order: global rules, kind filtering, specific rules, then default policy.
|
||||||
type P struct {
|
type P struct {
|
||||||
// Kind is policies for accepting or rejecting events by kind number.
|
// Kind is policies for accepting or rejecting events by kind number.
|
||||||
Kind Kinds `json:"kind"`
|
Kind Kinds `json:"kind"`
|
||||||
@@ -137,22 +148,47 @@ type P struct {
|
|||||||
Rules map[int]Rule `json:"rules"`
|
Rules map[int]Rule `json:"rules"`
|
||||||
// Global is a rule set that applies to all events.
|
// Global is a rule set that applies to all events.
|
||||||
Global Rule `json:"global"`
|
Global Rule `json:"global"`
|
||||||
|
// DefaultPolicy determines the default behavior when no rules deny an event ("allow" or "deny", defaults to "allow")
|
||||||
|
DefaultPolicy string `json:"default_policy"`
|
||||||
// Manager handles policy script execution
|
// Manager handles policy script execution
|
||||||
Manager *PolicyManager `json:"-"`
|
Manager *PolicyManager `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new policy from JSON configuration
|
// New creates a new policy from JSON configuration.
|
||||||
|
// If policyJSON is empty, returns a policy with default settings.
|
||||||
|
// The default_policy field defaults to "allow" if not specified.
|
||||||
func New(policyJSON []byte) (p *P, err error) {
|
func New(policyJSON []byte) (p *P, err error) {
|
||||||
p = &P{}
|
p = &P{
|
||||||
|
DefaultPolicy: "allow", // Set default value
|
||||||
|
}
|
||||||
if len(policyJSON) > 0 {
|
if len(policyJSON) > 0 {
|
||||||
if err = json.Unmarshal(policyJSON, p); chk.E(err) {
|
if err = json.Unmarshal(policyJSON, p); chk.E(err) {
|
||||||
return nil, fmt.Errorf("failed to unmarshal policy JSON: %v", err)
|
return nil, fmt.Errorf("failed to unmarshal policy JSON: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Ensure default policy is valid
|
||||||
|
if p.DefaultPolicy == "" {
|
||||||
|
p.DefaultPolicy = "allow"
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewWithManager creates a new policy with a policy manager for script execution
|
// getDefaultPolicyAction returns true if the default policy is "allow", false if "deny"
|
||||||
|
func (p *P) getDefaultPolicyAction() (allowed bool) {
|
||||||
|
switch p.DefaultPolicy {
|
||||||
|
case "deny":
|
||||||
|
return false
|
||||||
|
case "allow", "":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
// Invalid value, default to allow
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewWithManager creates a new policy with a policy manager for script execution.
|
||||||
|
// It initializes the policy manager, loads configuration from files, and starts
|
||||||
|
// background processes for script management and periodic health checks.
|
||||||
func NewWithManager(ctx context.Context, appName string, enabled bool) *P {
|
func NewWithManager(ctx context.Context, appName string, enabled bool) *P {
|
||||||
configDir := filepath.Join(xdg.ConfigHome, appName)
|
configDir := filepath.Join(xdg.ConfigHome, appName)
|
||||||
scriptPath := filepath.Join(configDir, "policy.sh")
|
scriptPath := filepath.Join(configDir, "policy.sh")
|
||||||
@@ -166,12 +202,12 @@ func NewWithManager(ctx context.Context, appName string, enabled bool) *P {
|
|||||||
configDir: configDir,
|
configDir: configDir,
|
||||||
scriptPath: scriptPath,
|
scriptPath: scriptPath,
|
||||||
enabled: enabled,
|
enabled: enabled,
|
||||||
disabled: false,
|
|
||||||
responseChan: make(chan PolicyResponse, 100), // Buffered channel for responses
|
responseChan: make(chan PolicyResponse, 100), // Buffered channel for responses
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load policy configuration from JSON file
|
// Load policy configuration from JSON file
|
||||||
policy := &P{
|
policy := &P{
|
||||||
|
DefaultPolicy: "allow", // Set default value
|
||||||
Manager: manager,
|
Manager: manager,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,7 +228,8 @@ func NewWithManager(ctx context.Context, appName string, enabled bool) *P {
|
|||||||
return policy
|
return policy
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoadFromFile loads policy configuration from a JSON file
|
// LoadFromFile loads policy configuration from a JSON file.
|
||||||
|
// Returns an error if the file doesn't exist, can't be read, or contains invalid JSON.
|
||||||
func (p *P) LoadFromFile(configPath string) error {
|
func (p *P) LoadFromFile(configPath string) error {
|
||||||
if _, err := os.Stat(configPath); os.IsNotExist(err) {
|
if _, err := os.Stat(configPath); os.IsNotExist(err) {
|
||||||
return fmt.Errorf("policy configuration file does not exist: %s", configPath)
|
return fmt.Errorf("policy configuration file does not exist: %s", configPath)
|
||||||
@@ -214,7 +251,10 @@ func (p *P) LoadFromFile(configPath string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckPolicy checks if an event is allowed to be written to the relay based on the policy. The access parameter is either "write" or "read", write is for accepting events and read is for filtering events to send back to the client.
|
// CheckPolicy checks if an event is allowed based on the policy configuration.
|
||||||
|
// The access parameter should be "write" for accepting events or "read" for filtering events.
|
||||||
|
// Returns true if the event is allowed, false if denied, and an error if validation fails.
|
||||||
|
// Policy evaluation order: global rules → kind filtering → specific rules → default policy.
|
||||||
func (p *P) CheckPolicy(access string, ev *event.E, loggedInPubkey []byte, ipAddress string) (allowed bool, err error) {
|
func (p *P) CheckPolicy(access string, ev *event.E, loggedInPubkey []byte, ipAddress string) (allowed bool, err error) {
|
||||||
// Handle nil event
|
// Handle nil event
|
||||||
if ev == nil {
|
if ev == nil {
|
||||||
@@ -234,8 +274,8 @@ func (p *P) CheckPolicy(access string, ev *event.E, loggedInPubkey []byte, ipAdd
|
|||||||
// Get rule for this kind
|
// Get rule for this kind
|
||||||
rule, hasRule := p.Rules[int(ev.Kind)]
|
rule, hasRule := p.Rules[int(ev.Kind)]
|
||||||
if !hasRule {
|
if !hasRule {
|
||||||
// No specific rule for this kind, allow if global and kinds policy passed
|
// No specific rule for this kind, use default policy
|
||||||
return true, nil
|
return p.getDefaultPolicyAction(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if script is present and enabled
|
// Check if script is present and enabled
|
||||||
@@ -408,8 +448,9 @@ func (p *P) checkRulePolicy(access string, ev *event.E, rule Rule, loggedInPubke
|
|||||||
// checkScriptPolicy runs the policy script to determine if event should be allowed
|
// checkScriptPolicy runs the policy script to determine if event should be allowed
|
||||||
func (p *P) checkScriptPolicy(access string, ev *event.E, scriptPath string, loggedInPubkey []byte, ipAddress string) (allowed bool, err error) {
|
func (p *P) checkScriptPolicy(access string, ev *event.E, scriptPath string, loggedInPubkey []byte, ipAddress string) (allowed bool, err error) {
|
||||||
if p.Manager == nil || !p.Manager.IsRunning() {
|
if p.Manager == nil || !p.Manager.IsRunning() {
|
||||||
// If script is not running, default to allow
|
// If script is not running, fall back to default policy
|
||||||
return true, nil
|
log.W.F("policy rule for kind %d is inactive (script not running), falling back to default policy (%s)", ev.Kind, p.DefaultPolicy)
|
||||||
|
return p.getDefaultPolicyAction(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create policy event with additional context
|
// Create policy event with additional context
|
||||||
@@ -422,9 +463,9 @@ func (p *P) checkScriptPolicy(access string, ev *event.E, scriptPath string, log
|
|||||||
// Process event through policy script
|
// Process event through policy script
|
||||||
response, scriptErr := p.Manager.ProcessEvent(policyEvent)
|
response, scriptErr := p.Manager.ProcessEvent(policyEvent)
|
||||||
if chk.E(scriptErr) {
|
if chk.E(scriptErr) {
|
||||||
log.E.F("policy script processing failed: %v", scriptErr)
|
log.E.F("policy rule for kind %d failed (script processing error: %v), falling back to default policy (%s)", ev.Kind, scriptErr, p.DefaultPolicy)
|
||||||
// Default to allow on script failure
|
// Fall back to default policy on script failure
|
||||||
return true, nil
|
return p.getDefaultPolicyAction(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle script response
|
// Handle script response
|
||||||
@@ -436,54 +477,18 @@ func (p *P) checkScriptPolicy(access string, ev *event.E, scriptPath string, log
|
|||||||
case "shadowReject":
|
case "shadowReject":
|
||||||
return false, nil // Treat as reject for policy purposes
|
return false, nil // Treat as reject for policy purposes
|
||||||
default:
|
default:
|
||||||
log.W.F("unknown policy script action: %s", response.Action)
|
log.W.F("policy rule for kind %d returned unknown action '%s', falling back to default policy (%s)", ev.Kind, response.Action, p.DefaultPolicy)
|
||||||
// Default to allow for unknown actions
|
// Fall back to default policy for unknown actions
|
||||||
return true, nil
|
return p.getDefaultPolicyAction(), nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// PolicyManager methods (similar to SprocketManager)
|
// PolicyManager methods (similar to SprocketManager)
|
||||||
|
|
||||||
// disablePolicy disables policy due to failure
|
// periodicCheck periodically checks if policy script becomes available and attempts to restart failed scripts.
|
||||||
func (pm *PolicyManager) disablePolicy() {
|
// Runs every 60 seconds (1 minute) to provide resilient script management.
|
||||||
pm.mutex.Lock()
|
|
||||||
defer pm.mutex.Unlock()
|
|
||||||
|
|
||||||
if !pm.disabled {
|
|
||||||
pm.disabled = true
|
|
||||||
log.W.F("policy disabled due to failure - all events will be rejected (script location: %s)", pm.scriptPath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// enablePolicy re-enables policy and attempts to start it
|
|
||||||
func (pm *PolicyManager) enablePolicy() {
|
|
||||||
pm.mutex.Lock()
|
|
||||||
defer pm.mutex.Unlock()
|
|
||||||
|
|
||||||
if pm.disabled {
|
|
||||||
pm.disabled = false
|
|
||||||
log.I.F("policy re-enabled, attempting to start")
|
|
||||||
|
|
||||||
// Attempt to start policy in background
|
|
||||||
go func() {
|
|
||||||
if _, err := os.Stat(pm.scriptPath); err == nil {
|
|
||||||
if err := pm.StartPolicy(); err != nil {
|
|
||||||
log.E.F("failed to restart policy: %v", err)
|
|
||||||
pm.disablePolicy()
|
|
||||||
} else {
|
|
||||||
log.I.F("policy restarted successfully")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
log.W.F("policy script still not found, keeping disabled")
|
|
||||||
pm.disablePolicy()
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// periodicCheck periodically checks if policy script becomes available
|
|
||||||
func (pm *PolicyManager) periodicCheck() {
|
func (pm *PolicyManager) periodicCheck() {
|
||||||
ticker := time.NewTicker(30 * time.Second) // Check every 30 seconds
|
ticker := time.NewTicker(60 * time.Second) // Check every 60 seconds (1 minute)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
|
|
||||||
for {
|
for {
|
||||||
@@ -492,22 +497,16 @@ func (pm *PolicyManager) periodicCheck() {
|
|||||||
return
|
return
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
pm.mutex.RLock()
|
pm.mutex.RLock()
|
||||||
disabled := pm.disabled
|
|
||||||
running := pm.isRunning
|
running := pm.isRunning
|
||||||
pm.mutex.RUnlock()
|
pm.mutex.RUnlock()
|
||||||
|
|
||||||
// Only check if policy is disabled or not running
|
// Check if policy script is not running and try to start it
|
||||||
if disabled || !running {
|
if !running {
|
||||||
if _, err := os.Stat(pm.scriptPath); err == nil {
|
if _, err := os.Stat(pm.scriptPath); err == nil {
|
||||||
// Script is available, try to enable/restart
|
|
||||||
if disabled {
|
|
||||||
pm.enablePolicy()
|
|
||||||
} else if !running {
|
|
||||||
// Script exists but policy isn't running, try to start
|
// Script exists but policy isn't running, try to start
|
||||||
go func() {
|
go func() {
|
||||||
if err := pm.StartPolicy(); err != nil {
|
if err := pm.StartPolicy(); err != nil {
|
||||||
log.E.F("failed to restart policy: %v", err)
|
log.E.F("failed to restart policy: %v, will retry in next cycle", err)
|
||||||
pm.disablePolicy()
|
|
||||||
} else {
|
} else {
|
||||||
log.I.F("policy restarted successfully")
|
log.I.F("policy restarted successfully")
|
||||||
}
|
}
|
||||||
@@ -517,22 +516,22 @@ func (pm *PolicyManager) periodicCheck() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// startPolicyIfExists starts the policy script if the file exists
|
// startPolicyIfExists starts the policy script if the file exists
|
||||||
func (pm *PolicyManager) startPolicyIfExists() {
|
func (pm *PolicyManager) startPolicyIfExists() {
|
||||||
if _, err := os.Stat(pm.scriptPath); err == nil {
|
if _, err := os.Stat(pm.scriptPath); err == nil {
|
||||||
if err := pm.StartPolicy(); err != nil {
|
if err := pm.StartPolicy(); err != nil {
|
||||||
log.E.F("failed to start policy: %v", err)
|
log.E.F("failed to start policy: %v, will retry periodically", err)
|
||||||
pm.disablePolicy()
|
// Don't disable policy manager, just log the error and let periodic check retry
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
log.W.F("policy script not found at %s, disabling policy", pm.scriptPath)
|
log.W.F("policy script not found at %s, will retry periodically", pm.scriptPath)
|
||||||
pm.disablePolicy()
|
// Don't disable policy manager, just log and let periodic check retry
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// StartPolicy starts the policy script
|
// StartPolicy starts the policy script process.
|
||||||
|
// Returns an error if the script doesn't exist, can't be executed, or is already running.
|
||||||
func (pm *PolicyManager) StartPolicy() error {
|
func (pm *PolicyManager) StartPolicy() error {
|
||||||
pm.mutex.Lock()
|
pm.mutex.Lock()
|
||||||
defer pm.mutex.Unlock()
|
defer pm.mutex.Unlock()
|
||||||
@@ -609,7 +608,8 @@ func (pm *PolicyManager) StartPolicy() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// StopPolicy stops the policy script gracefully, with SIGKILL fallback
|
// StopPolicy stops the policy script gracefully with SIGTERM, falling back to SIGKILL if needed.
|
||||||
|
// Returns an error if the policy is not currently running.
|
||||||
func (pm *PolicyManager) StopPolicy() error {
|
func (pm *PolicyManager) StopPolicy() error {
|
||||||
pm.mutex.Lock()
|
pm.mutex.Lock()
|
||||||
defer pm.mutex.Unlock()
|
defer pm.mutex.Unlock()
|
||||||
@@ -668,7 +668,8 @@ func (pm *PolicyManager) StopPolicy() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProcessEvent sends an event to the policy script and waits for a response
|
// ProcessEvent sends an event to the policy script and waits for a response.
|
||||||
|
// Returns the script's decision or an error if the script is not running or communication fails.
|
||||||
func (pm *PolicyManager) ProcessEvent(evt *PolicyEvent) (*PolicyResponse, error) {
|
func (pm *PolicyManager) ProcessEvent(evt *PolicyEvent) (*PolicyResponse, error) {
|
||||||
pm.mutex.RLock()
|
pm.mutex.RLock()
|
||||||
if !pm.isRunning || pm.stdin == nil {
|
if !pm.isRunning || pm.stdin == nil {
|
||||||
@@ -772,35 +773,30 @@ func (pm *PolicyManager) monitorProcess() {
|
|||||||
pm.currentCancel = nil
|
pm.currentCancel = nil
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.E.F("policy process exited with error: %v", err)
|
log.E.F("policy process exited with error: %v, will retry periodically", err)
|
||||||
// Auto-disable policy on failure
|
// Don't disable policy manager, let periodic check handle restart
|
||||||
pm.disabled = true
|
log.W.F("policy script crashed - events will fall back to default policy until restart (script location: %s)", pm.scriptPath)
|
||||||
log.W.F("policy disabled due to process failure - all events will be rejected (script location: %s)", pm.scriptPath)
|
|
||||||
} else {
|
} else {
|
||||||
log.I.F("policy process exited normally")
|
log.I.F("policy process exited normally")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsEnabled returns whether policy is enabled
|
// IsEnabled returns whether the policy manager is enabled.
|
||||||
|
// This is set during initialization and doesn't change during runtime.
|
||||||
func (pm *PolicyManager) IsEnabled() bool {
|
func (pm *PolicyManager) IsEnabled() bool {
|
||||||
return pm.enabled
|
return pm.enabled
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsRunning returns whether policy is currently running
|
// IsRunning returns whether the policy script is currently running.
|
||||||
|
// This can change during runtime as scripts start, stop, or crash.
|
||||||
func (pm *PolicyManager) IsRunning() bool {
|
func (pm *PolicyManager) IsRunning() bool {
|
||||||
pm.mutex.RLock()
|
pm.mutex.RLock()
|
||||||
defer pm.mutex.RUnlock()
|
defer pm.mutex.RUnlock()
|
||||||
return pm.isRunning
|
return pm.isRunning
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsDisabled returns whether policy is disabled due to failure
|
// Shutdown gracefully shuts down the policy manager.
|
||||||
func (pm *PolicyManager) IsDisabled() bool {
|
// It cancels the context and stops any running policy script.
|
||||||
pm.mutex.RLock()
|
|
||||||
defer pm.mutex.RUnlock()
|
|
||||||
return pm.disabled
|
|
||||||
}
|
|
||||||
|
|
||||||
// Shutdown gracefully shuts down the policy manager
|
|
||||||
func (pm *PolicyManager) Shutdown() {
|
func (pm *PolicyManager) Shutdown() {
|
||||||
pm.cancel()
|
pm.cancel()
|
||||||
if pm.isRunning {
|
if pm.isRunning {
|
||||||
|
|||||||
@@ -593,9 +593,6 @@ func TestNewWithManager(t *testing.T) {
|
|||||||
t.Error("Expected policy manager to not be running initially")
|
t.Error("Expected policy manager to not be running initially")
|
||||||
}
|
}
|
||||||
|
|
||||||
if policy.Manager.IsDisabled() {
|
|
||||||
t.Error("Expected policy manager to not be disabled initially")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPolicyManagerLifecycle(t *testing.T) {
|
func TestPolicyManagerLifecycle(t *testing.T) {
|
||||||
@@ -609,7 +606,6 @@ func TestPolicyManagerLifecycle(t *testing.T) {
|
|||||||
configDir: "/tmp",
|
configDir: "/tmp",
|
||||||
scriptPath: "/tmp/policy.sh",
|
scriptPath: "/tmp/policy.sh",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
disabled: false,
|
|
||||||
responseChan: make(chan PolicyResponse, 100),
|
responseChan: make(chan PolicyResponse, 100),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -622,10 +618,6 @@ func TestPolicyManagerLifecycle(t *testing.T) {
|
|||||||
t.Error("Expected policy manager to not be running initially")
|
t.Error("Expected policy manager to not be running initially")
|
||||||
}
|
}
|
||||||
|
|
||||||
if manager.IsDisabled() {
|
|
||||||
t.Error("Expected policy manager to not be disabled initially")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test starting with non-existent script (should fail gracefully)
|
// Test starting with non-existent script (should fail gracefully)
|
||||||
err := manager.StartPolicy()
|
err := manager.StartPolicy()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -650,7 +642,6 @@ func TestPolicyManagerProcessEvent(t *testing.T) {
|
|||||||
configDir: "/tmp",
|
configDir: "/tmp",
|
||||||
scriptPath: "/tmp/policy.sh",
|
scriptPath: "/tmp/policy.sh",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
disabled: false,
|
|
||||||
responseChan: make(chan PolicyResponse, 100),
|
responseChan: make(chan PolicyResponse, 100),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -778,7 +769,6 @@ func TestEdgeCasesManagerWithInvalidScript(t *testing.T) {
|
|||||||
configDir: tempDir,
|
configDir: tempDir,
|
||||||
scriptPath: scriptPath,
|
scriptPath: scriptPath,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
disabled: false,
|
|
||||||
responseChan: make(chan PolicyResponse, 100),
|
responseChan: make(chan PolicyResponse, 100),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -797,7 +787,6 @@ func TestEdgeCasesManagerDoubleStart(t *testing.T) {
|
|||||||
configDir: "/tmp",
|
configDir: "/tmp",
|
||||||
scriptPath: "/tmp/policy.sh",
|
scriptPath: "/tmp/policy.sh",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
disabled: false,
|
|
||||||
responseChan: make(chan PolicyResponse, 100),
|
responseChan: make(chan PolicyResponse, 100),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1012,3 +1001,337 @@ func TestMaxAgeChecks(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestScriptPolicyNotRunningFallsBackToDefault(t *testing.T) {
|
||||||
|
// Create a policy with a script rule but no running manager, default policy is "allow"
|
||||||
|
policy := &P{
|
||||||
|
DefaultPolicy: "allow",
|
||||||
|
Rules: map[int]Rule{
|
||||||
|
1: {
|
||||||
|
Description: "script rule",
|
||||||
|
Script: "policy.sh",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Manager: &PolicyManager{
|
||||||
|
enabled: true,
|
||||||
|
isRunning: false, // Script is not running
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create test event
|
||||||
|
testEvent := createTestEvent("test-event-id", "test-pubkey", "test content", 1)
|
||||||
|
|
||||||
|
// Should allow the event when script is configured but not running (falls back to default "allow")
|
||||||
|
allowed, err := policy.CheckPolicy("write", testEvent, []byte("test-pubkey"), "127.0.0.1")
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if !allowed {
|
||||||
|
t.Error("Expected event to be allowed when script is not running (should fall back to default policy 'allow')")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test with default policy "deny"
|
||||||
|
policy.DefaultPolicy = "deny"
|
||||||
|
allowed2, err2 := policy.CheckPolicy("write", testEvent, []byte("test-pubkey"), "127.0.0.1")
|
||||||
|
if err2 != nil {
|
||||||
|
t.Errorf("Unexpected error: %v", err2)
|
||||||
|
}
|
||||||
|
if allowed2 {
|
||||||
|
t.Error("Expected event to be denied when script is not running and default policy is 'deny'")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultPolicyAllow(t *testing.T) {
|
||||||
|
// Test default policy "allow" behavior
|
||||||
|
policy := &P{
|
||||||
|
DefaultPolicy: "allow",
|
||||||
|
Kind: Kinds{},
|
||||||
|
Rules: map[int]Rule{}, // No specific rules
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create test event for kind 1 (no specific rule exists)
|
||||||
|
testEvent := createTestEvent("test-event-id", "test-pubkey", "test content", 1)
|
||||||
|
|
||||||
|
// Should allow the event with default policy "allow"
|
||||||
|
allowed, err := policy.CheckPolicy("write", testEvent, []byte("test-pubkey"), "127.0.0.1")
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if !allowed {
|
||||||
|
t.Error("Expected event to be allowed with default_policy 'allow'")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultPolicyDeny(t *testing.T) {
|
||||||
|
// Test default policy "deny" behavior
|
||||||
|
policy := &P{
|
||||||
|
DefaultPolicy: "deny",
|
||||||
|
Kind: Kinds{},
|
||||||
|
Rules: map[int]Rule{}, // No specific rules
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create test event for kind 1 (no specific rule exists)
|
||||||
|
testEvent := createTestEvent("test-event-id", "test-pubkey", "test content", 1)
|
||||||
|
|
||||||
|
// Should deny the event with default policy "deny"
|
||||||
|
allowed, err := policy.CheckPolicy("write", testEvent, []byte("test-pubkey"), "127.0.0.1")
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if allowed {
|
||||||
|
t.Error("Expected event to be denied with default_policy 'deny'")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultPolicyEmpty(t *testing.T) {
|
||||||
|
// Test empty default policy (should default to "allow")
|
||||||
|
policy := &P{
|
||||||
|
DefaultPolicy: "",
|
||||||
|
Kind: Kinds{},
|
||||||
|
Rules: map[int]Rule{}, // No specific rules
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create test event for kind 1 (no specific rule exists)
|
||||||
|
testEvent := createTestEvent("test-event-id", "test-pubkey", "test content", 1)
|
||||||
|
|
||||||
|
// Should allow the event with empty default policy (defaults to "allow")
|
||||||
|
allowed, err := policy.CheckPolicy("write", testEvent, []byte("test-pubkey"), "127.0.0.1")
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if !allowed {
|
||||||
|
t.Error("Expected event to be allowed with empty default_policy (should default to 'allow')")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultPolicyInvalid(t *testing.T) {
|
||||||
|
// Test invalid default policy (should default to "allow")
|
||||||
|
policy := &P{
|
||||||
|
DefaultPolicy: "invalid",
|
||||||
|
Kind: Kinds{},
|
||||||
|
Rules: map[int]Rule{}, // No specific rules
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create test event for kind 1 (no specific rule exists)
|
||||||
|
testEvent := createTestEvent("test-event-id", "test-pubkey", "test content", 1)
|
||||||
|
|
||||||
|
// Should allow the event with invalid default policy (defaults to "allow")
|
||||||
|
allowed, err := policy.CheckPolicy("write", testEvent, []byte("test-pubkey"), "127.0.0.1")
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if !allowed {
|
||||||
|
t.Error("Expected event to be allowed with invalid default_policy (should default to 'allow')")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultPolicyWithSpecificRule(t *testing.T) {
|
||||||
|
// Test that specific rules override default policy
|
||||||
|
policy := &P{
|
||||||
|
DefaultPolicy: "deny", // Default is deny
|
||||||
|
Kind: Kinds{},
|
||||||
|
Rules: map[int]Rule{
|
||||||
|
1: {
|
||||||
|
Description: "allow kind 1",
|
||||||
|
WriteAllow: []string{}, // Allow all for kind 1
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create test event for kind 1 (has specific rule)
|
||||||
|
testEvent := createTestEvent("test-event-id", "test-pubkey", "test content", 1)
|
||||||
|
|
||||||
|
// Should allow the event because specific rule allows it, despite default policy being "deny"
|
||||||
|
allowed, err := policy.CheckPolicy("write", testEvent, []byte("test-pubkey"), "127.0.0.1")
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if !allowed {
|
||||||
|
t.Error("Expected event to be allowed by specific rule, despite default_policy 'deny'")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create test event for kind 2 (no specific rule exists)
|
||||||
|
testEvent2 := createTestEvent("test-event-id-2", "test-pubkey", "test content", 2)
|
||||||
|
|
||||||
|
// Should deny the event because no specific rule and default policy is "deny"
|
||||||
|
allowed2, err2 := policy.CheckPolicy("write", testEvent2, []byte("test-pubkey"), "127.0.0.1")
|
||||||
|
if err2 != nil {
|
||||||
|
t.Errorf("Unexpected error: %v", err2)
|
||||||
|
}
|
||||||
|
if allowed2 {
|
||||||
|
t.Error("Expected event to be denied with default_policy 'deny' for kind without specific rule")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewPolicyDefaultsToAllow(t *testing.T) {
|
||||||
|
// Test that New() function sets default policy to "allow"
|
||||||
|
policy, err := New([]byte(`{}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create policy: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if policy.DefaultPolicy != "allow" {
|
||||||
|
t.Errorf("Expected default policy to be 'allow', got '%s'", policy.DefaultPolicy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewPolicyWithDefaultPolicyJSON(t *testing.T) {
|
||||||
|
// Test loading default policy from JSON
|
||||||
|
jsonConfig := `{"default_policy": "deny"}`
|
||||||
|
policy, err := New([]byte(jsonConfig))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create policy: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if policy.DefaultPolicy != "deny" {
|
||||||
|
t.Errorf("Expected default policy to be 'deny', got '%s'", policy.DefaultPolicy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScriptProcessingFailureFallsBackToDefault(t *testing.T) {
|
||||||
|
// Test that script processing failures fall back to default policy
|
||||||
|
// We'll test this by using a manager that's not running (simulating failure)
|
||||||
|
policy := &P{
|
||||||
|
DefaultPolicy: "allow",
|
||||||
|
Rules: map[int]Rule{
|
||||||
|
1: {
|
||||||
|
Description: "script rule",
|
||||||
|
Script: "policy.sh",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Manager: &PolicyManager{
|
||||||
|
enabled: true,
|
||||||
|
isRunning: false, // Script is not running (simulating failure)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create test event
|
||||||
|
testEvent := createTestEvent("test-event-id", "test-pubkey", "test content", 1)
|
||||||
|
|
||||||
|
// Should allow the event when script is not running (falls back to default "allow")
|
||||||
|
allowed, err := policy.checkScriptPolicy("write", testEvent, "policy.sh", []byte("test-pubkey"), "127.0.0.1")
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if !allowed {
|
||||||
|
t.Error("Expected event to be allowed when script is not running (should fall back to default policy 'allow')")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test with default policy "deny"
|
||||||
|
policy.DefaultPolicy = "deny"
|
||||||
|
allowed2, err2 := policy.checkScriptPolicy("write", testEvent, "policy.sh", []byte("test-pubkey"), "127.0.0.1")
|
||||||
|
if err2 != nil {
|
||||||
|
t.Errorf("Unexpected error: %v", err2)
|
||||||
|
}
|
||||||
|
if allowed2 {
|
||||||
|
t.Error("Expected event to be denied when script is not running and default policy is 'deny'")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultPolicyLogicWithRules(t *testing.T) {
|
||||||
|
// Test that default policy logic works correctly with rules
|
||||||
|
|
||||||
|
// Test 1: default_policy "deny" - should only allow if rule explicitly allows
|
||||||
|
policy1 := &P{
|
||||||
|
DefaultPolicy: "deny",
|
||||||
|
Kind: Kinds{
|
||||||
|
Whitelist: []int{1, 2, 3}, // Allow kinds 1, 2, 3
|
||||||
|
},
|
||||||
|
Rules: map[int]Rule{
|
||||||
|
1: {
|
||||||
|
Description: "allow all for kind 1",
|
||||||
|
WriteAllow: []string{}, // Empty means allow all
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
Description: "deny specific pubkey for kind 2",
|
||||||
|
WriteDeny: []string{"64656e6965642d7075626b6579"}, // hex of "denied-pubkey"
|
||||||
|
},
|
||||||
|
// No rule for kind 3
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kind 1: has rule that allows all - should be allowed
|
||||||
|
event1 := createTestEvent("test-1", "test-pubkey", "content", 1)
|
||||||
|
allowed1, err1 := policy1.CheckPolicy("write", event1, []byte("test-pubkey"), "127.0.0.1")
|
||||||
|
if err1 != nil {
|
||||||
|
t.Errorf("Unexpected error for kind 1: %v", err1)
|
||||||
|
}
|
||||||
|
if !allowed1 {
|
||||||
|
t.Error("Expected kind 1 to be allowed (rule allows all)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kind 2: has rule that denies specific pubkey - should be allowed for other pubkeys
|
||||||
|
event2 := createTestEvent("test-2", "test-pubkey", "content", 2)
|
||||||
|
allowed2, err2 := policy1.CheckPolicy("write", event2, []byte("test-pubkey"), "127.0.0.1")
|
||||||
|
if err2 != nil {
|
||||||
|
t.Errorf("Unexpected error for kind 2: %v", err2)
|
||||||
|
}
|
||||||
|
if !allowed2 {
|
||||||
|
t.Error("Expected kind 2 to be allowed for non-denied pubkey")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kind 2: denied pubkey should be denied
|
||||||
|
event2Denied := createTestEvent("test-2-denied", "denied-pubkey", "content", 2)
|
||||||
|
allowed2Denied, err2Denied := policy1.CheckPolicy("write", event2Denied, []byte("test-pubkey"), "127.0.0.1")
|
||||||
|
if err2Denied != nil {
|
||||||
|
t.Errorf("Unexpected error for kind 2 denied: %v", err2Denied)
|
||||||
|
}
|
||||||
|
if allowed2Denied {
|
||||||
|
t.Error("Expected kind 2 to be denied for denied pubkey")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kind 3: whitelisted but no rule - should follow default policy (deny)
|
||||||
|
event3 := createTestEvent("test-3", "test-pubkey", "content", 3)
|
||||||
|
allowed3, err3 := policy1.CheckPolicy("write", event3, []byte("test-pubkey"), "127.0.0.1")
|
||||||
|
if err3 != nil {
|
||||||
|
t.Errorf("Unexpected error for kind 3: %v", err3)
|
||||||
|
}
|
||||||
|
if allowed3 {
|
||||||
|
t.Error("Expected kind 3 to be denied (no rule, default policy is deny)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 2: default_policy "allow" - should allow unless rule explicitly denies
|
||||||
|
policy2 := &P{
|
||||||
|
DefaultPolicy: "allow",
|
||||||
|
Kind: Kinds{
|
||||||
|
Whitelist: []int{1, 2, 3}, // Allow kinds 1, 2, 3
|
||||||
|
},
|
||||||
|
Rules: map[int]Rule{
|
||||||
|
1: {
|
||||||
|
Description: "deny specific pubkey for kind 1",
|
||||||
|
WriteDeny: []string{"64656e6965642d7075626b6579"}, // hex of "denied-pubkey"
|
||||||
|
},
|
||||||
|
// No rules for kind 2, 3
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kind 1: has rule that denies specific pubkey - should be allowed for other pubkeys
|
||||||
|
event1Allow := createTestEvent("test-1-allow", "test-pubkey", "content", 1)
|
||||||
|
allowed1Allow, err1Allow := policy2.CheckPolicy("write", event1Allow, []byte("test-pubkey"), "127.0.0.1")
|
||||||
|
if err1Allow != nil {
|
||||||
|
t.Errorf("Unexpected error for kind 1 allow: %v", err1Allow)
|
||||||
|
}
|
||||||
|
if !allowed1Allow {
|
||||||
|
t.Error("Expected kind 1 to be allowed for non-denied pubkey")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kind 1: denied pubkey should be denied
|
||||||
|
event1Deny := createTestEvent("test-1-deny", "denied-pubkey", "content", 1)
|
||||||
|
allowed1Deny, err1Deny := policy2.CheckPolicy("write", event1Deny, []byte("test-pubkey"), "127.0.0.1")
|
||||||
|
if err1Deny != nil {
|
||||||
|
t.Errorf("Unexpected error for kind 1 deny: %v", err1Deny)
|
||||||
|
}
|
||||||
|
if allowed1Deny {
|
||||||
|
t.Error("Expected kind 1 to be denied for denied pubkey")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kind 2: whitelisted but no rule - should follow default policy (allow)
|
||||||
|
event2Allow := createTestEvent("test-2-allow", "test-pubkey", "content", 2)
|
||||||
|
allowed2Allow, err2Allow := policy2.CheckPolicy("write", event2Allow, []byte("test-pubkey"), "127.0.0.1")
|
||||||
|
if err2Allow != nil {
|
||||||
|
t.Errorf("Unexpected error for kind 2 allow: %v", err2Allow)
|
||||||
|
}
|
||||||
|
if !allowed2Allow {
|
||||||
|
t.Error("Expected kind 2 to be allowed (no rule, default policy is allow)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
v0.17.5
|
v0.17.12
|
||||||
147
scripts/run-relay-pprof.sh
Executable file
147
scripts/run-relay-pprof.sh
Executable file
@@ -0,0 +1,147 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Run the relay with CPU profiling enabled, wait 60s, then open the
|
||||||
|
# generated profile using `go tool pprof` web UI.
|
||||||
|
#
|
||||||
|
# Notes:
|
||||||
|
# - Builds a temporary relay binary in /tmp and deletes it on exit.
|
||||||
|
# - Uses the exact env requested, plus ORLY_PPROF=cpu and a deterministic
|
||||||
|
# ORLY_PPROF_PATH inside a temp dir.
|
||||||
|
# - Profiles for DURATION seconds (default 60).
|
||||||
|
# - Launches pprof web UI at http://localhost:8000 and attempts to open browser.
|
||||||
|
|
||||||
|
DURATION="${DURATION:-60}"
|
||||||
|
HEALTH_PORT="${HEALTH_PORT:-18081}"
|
||||||
|
ROOT_DIR="/home/mleku/src/next.orly.dev"
|
||||||
|
LISTEN_HOST="${LISTEN_HOST:-10.0.0.2}"
|
||||||
|
|
||||||
|
cd "$ROOT_DIR"
|
||||||
|
|
||||||
|
# Refresh embedded web assets
|
||||||
|
reset || true
|
||||||
|
./scripts/update-embedded-web.sh || true
|
||||||
|
|
||||||
|
TMP_DIR="$(mktemp -d -t orly-pprof-XXXXXX)"
|
||||||
|
BIN_PATH="$TMP_DIR/next.orly.dev"
|
||||||
|
LOG_FILE="$TMP_DIR/relay.log"
|
||||||
|
PPROF_FILE=""
|
||||||
|
RELAY_PID=""
|
||||||
|
PPROF_DIR="$TMP_DIR/profiles"
|
||||||
|
mkdir -p "$PPROF_DIR"
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
# Try to stop relay if still running
|
||||||
|
if [[ -n "${RELAY_PID}" ]] && kill -0 "${RELAY_PID}" 2>/dev/null; then
|
||||||
|
kill "${RELAY_PID}" || true
|
||||||
|
wait "${RELAY_PID}" || true
|
||||||
|
fi
|
||||||
|
rm -f "$BIN_PATH" 2>/dev/null || true
|
||||||
|
rm -rf "$TMP_DIR" 2>/dev/null || true
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
echo "[run-relay-pprof] Building relay binary ..."
|
||||||
|
GOFLAGS="${GOFLAGS:-}" go build -o "$BIN_PATH" .
|
||||||
|
|
||||||
|
echo "[run-relay-pprof] Starting relay with CPU profiling ..."
|
||||||
|
(
|
||||||
|
ORLY_LOG_LEVEL=debug \
|
||||||
|
ORLY_LISTEN="$LISTEN_HOST" \
|
||||||
|
ORLY_PORT=3334 \
|
||||||
|
ORLY_ADMINS=npub1fjqqy4a93z5zsjwsfxqhc2764kvykfdyttvldkkkdera8dr78vhsmmleku \
|
||||||
|
ORLY_ACL_MODE=follows \
|
||||||
|
ORLY_SPIDER_MODE=none \
|
||||||
|
ORLY_RELAY_ADDRESSES=test.orly.dev \
|
||||||
|
ORLY_IP_BLACKLIST=192.71.213.188 \
|
||||||
|
ORLY_HEALTH_PORT="$HEALTH_PORT" \
|
||||||
|
ORLY_ENABLE_SHUTDOWN=true \
|
||||||
|
ORLY_PPROF_HTTP=true \
|
||||||
|
ORLY_OPEN_PPROF_WEB=true \
|
||||||
|
"$BIN_PATH"
|
||||||
|
) >"$LOG_FILE" 2>&1 &
|
||||||
|
RELAY_PID=$!
|
||||||
|
|
||||||
|
# Wait for pprof HTTP server readiness
|
||||||
|
PPROF_BASE="http://${LISTEN_HOST}:6060"
|
||||||
|
echo "[run-relay-pprof] Waiting for pprof at ${PPROF_BASE} ..."
|
||||||
|
for i in {1..100}; do
|
||||||
|
if curl -fsS "${PPROF_BASE}/debug/pprof/" -o /dev/null 2>/dev/null; then
|
||||||
|
READY=1
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 0.2
|
||||||
|
done
|
||||||
|
if [[ -z "${READY:-}" ]]; then
|
||||||
|
echo "[run-relay-pprof] ERROR: pprof HTTP server not reachable at ${PPROF_BASE}." >&2
|
||||||
|
echo "[run-relay-pprof] Check that ${LISTEN_HOST} is a local bindable address." >&2
|
||||||
|
# Attempt to dump recent logs for context
|
||||||
|
tail -n 100 "$LOG_FILE" || true
|
||||||
|
# Try INT to clean up
|
||||||
|
killall -INT next.orly.dev 2>/dev/null || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Open the HTTP pprof UI
|
||||||
|
( xdg-open "${PPROF_BASE}/debug/pprof/" >/dev/null 2>&1 || true ) &
|
||||||
|
|
||||||
|
echo "[run-relay-pprof] Collecting CPU profile via HTTP for ${DURATION}s ..."
|
||||||
|
# The HTTP /debug/pprof/profile endpoint records CPU for the provided seconds
|
||||||
|
# and returns a pprof file without needing to stop the process.
|
||||||
|
curl -fsS --max-time $((DURATION+10)) \
|
||||||
|
"${PPROF_BASE}/debug/pprof/profile?seconds=${DURATION}" \
|
||||||
|
-o "$PPROF_DIR/cpu.pprof" || true
|
||||||
|
|
||||||
|
echo "[run-relay-pprof] Sending SIGINT (Ctrl+C) for graceful shutdown ..."
|
||||||
|
killall -INT next.orly.dev 2>/dev/null || true
|
||||||
|
|
||||||
|
# Wait up to ~60s for graceful shutdown so defers (pprof Stop) can run
|
||||||
|
for i in {1..300}; do
|
||||||
|
if ! pgrep -x next.orly.dev >/dev/null 2>&1; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 0.2
|
||||||
|
done
|
||||||
|
|
||||||
|
# Try HTTP shutdown if still running (ensures defer paths can run)
|
||||||
|
if pgrep -x next.orly.dev >/dev/null 2>&1; then
|
||||||
|
echo "[run-relay-pprof] Still running, requesting /shutdown ..."
|
||||||
|
curl -fsS --max-time 2 "http://10.0.0.2:${HEALTH_PORT}/shutdown" >/dev/null 2>&1 || true
|
||||||
|
for i in {1..150}; do
|
||||||
|
if ! pgrep -x next.orly.dev >/dev/null 2>&1; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 0.2
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
if pgrep -x next.orly.dev >/dev/null 2>&1; then
|
||||||
|
echo "[run-relay-pprof] Escalating: sending SIGTERM ..."
|
||||||
|
killall -TERM next.orly.dev 2>/dev/null || true
|
||||||
|
for i in {1..150}; do
|
||||||
|
if ! pgrep -x next.orly.dev >/dev/null 2>&1; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 0.2
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
if pgrep -x next.orly.dev >/dev/null 2>&1; then
|
||||||
|
echo "[run-relay-pprof] Force kill: sending SIGKILL ..."
|
||||||
|
killall -KILL next.orly.dev 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
PPROF_FILE="$PPROF_DIR/cpu.pprof"
|
||||||
|
if [[ ! -s "$PPROF_FILE" ]]; then
|
||||||
|
echo "[run-relay-pprof] ERROR: HTTP CPU profile not captured (file empty)." >&2
|
||||||
|
echo "[run-relay-pprof] Hint: Ensure ORLY_PPROF_HTTP=true and port 6060 is reachable." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[run-relay-pprof] Detected profile file: $PPROF_FILE"
|
||||||
|
echo "[run-relay-pprof] Launching 'go tool pprof' web UI on :8000 ..."
|
||||||
|
|
||||||
|
# Try to open a browser automatically, ignore failures
|
||||||
|
( sleep 0.6; xdg-open "http://localhost:8000" >/dev/null 2>&1 || true ) &
|
||||||
|
|
||||||
|
exec go tool pprof -http=:8000 "$BIN_PATH" "$PPROF_FILE"
|
||||||
|
|
||||||
|
|
||||||
Reference in New Issue
Block a user