From ea1a5fd19facfa973a0766f55332c08843dcc7a1 Mon Sep 17 00:00:00 2001 From: Alan Johnson Date: Mon, 3 Aug 2026 18:21:15 -0400 Subject: [PATCH] Add configurable public address distribution policy --- CHANGELOG.md | 9 ++ api/handle.go | 67 +++++++++++- api/public_address_policy_test.go | 158 +++++++++++++++++++++++++++ config.yml.example | 7 ++ docs/FEP-AE0C-COMPATIBILITY.md | 38 +++---- docs/INTEROPERABILITY.md | 19 ++++ main.go | 3 + misc/test/config.yml | 1 + models/config.go | 95 +++++++++------- models/config_test.go | 39 ++++--- models/public_address_policy.go | 85 ++++++++++++++ models/public_address_policy_test.go | 128 ++++++++++++++++++++++ readme.md | 7 ++ testdata/fep-ae0c/cases.json | 10 +- 14 files changed, 589 insertions(+), 77 deletions(-) create mode 100644 api/public_address_policy_test.go create mode 100644 models/public_address_policy.go create mode 100644 models/public_address_policy_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e674bd..3a7a75c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ ## [Unreleased] +### Added + +- Add `PUBLIC_ADDRESS_DISTRIBUTION_POLICY` with `explicit_public_only` and `public_and_unlisted` values, fatal validation for unknown values, and effective-policy startup reporting. + +### Changed + +- Fresh example configurations explicitly select `explicit_public_only`; omitted configuration retains the pre-3.0 `public_and_unlisted` behavior for upgrade compatibility. +- Under `explicit_public_only`, Public appearing only in `cc` is acknowledged and publisher-accounted without public relay fan-out. Explicit relay-addressed relationship traffic remains separate. + ### Fixed - Retry an unknown idempotent RFC 9421 actor or object fetch once with the diff --git a/api/handle.go b/api/handle.go index a0c710e..380654c 100644 --- a/api/handle.go +++ b/api/handle.go @@ -104,6 +104,52 @@ func logInboxDecodeFailure(request *http.Request, err error) { }).Warn("Rejected inbox activity") } +func configuredPublicAddressDistributionPolicy() models.PublicAddressDistributionPolicy { + if GlobalConfig == nil { + return models.PublicAddressPublicAndUnlisted + } + return GlobalConfig.PublicAddressDistributionPolicy() +} + +func shouldDistributeByPublicAddress( + activity *models.Activity, + policy models.PublicAddressDistributionPolicy, +) bool { + if activity == nil { + return false + } + return policy.Allows(activity.To, activity.Cc) +} + +func isPublicOnlyInCCExcluded( + activity *models.Activity, + policy models.PublicAddressDistributionPolicy, +) bool { + if activity == nil { + return false + } + return policy.ExcludesPublicOnlyInCC(activity.To, activity.Cc) +} + +func executePolicyExcludedPublicActivity( + activity *models.Activity, + actor *models.Actor, + policy models.PublicAddressDistributionPolicy, +) error { + switch activity.Type { + case "Create", "Update", "Delete", "Move", "Announce": + if err := recordPublisherActivity(activity, actor); err != nil { + return err + } + logrus.WithFields(logrus.Fields{ + "actor": activity.Actor, + "policy": policy, + "type": activity.Type, + }).Debug("Skipped public fan-out: Public is only in cc") + } + return nil +} + func shouldFanOutPublicAnnounce(activity *models.Activity) bool { if activity == nil || activity.Type != "Announce" { return false @@ -161,7 +207,10 @@ func handleInbox(writer http.ResponseWriter, request *http.Request, activityDeco activityType = activity.Type actorID, _ := url.Parse(activity.Actor) switch { - case contains(activity.To, "https://www.w3.org/ns/activitystreams#Public"), contains(activity.Cc, "https://www.w3.org/ns/activitystreams#Public"): + case shouldDistributeByPublicAddress( + activity, + configuredPublicAddressDistributionPolicy(), + ): // Mastodon Traditional Style (Activity Transfer) switch activity.Type { case "Create", "Update", "Delete", "Move": @@ -286,6 +335,22 @@ func handleInbox(writer http.ResponseWriter, request *http.Request, activityDeco writer.WriteHeader(202) writer.Write(nil) } + case isPublicOnlyInCCExcluded( + activity, + configuredPublicAddressDistributionPolicy(), + ): + err = executePolicyExcludedPublicActivity( + activity, + actor, + configuredPublicAddressDistributionPolicy(), + ) + if err != nil { + writer.WriteHeader(http.StatusUnauthorized) + writer.Write([]byte(err.Error())) + return + } + writer.WriteHeader(http.StatusAccepted) + writer.Write(nil) default: // Follow, Unfollow Only switch activity.Type { diff --git a/api/public_address_policy_test.go b/api/public_address_policy_test.go new file mode 100644 index 0000000..2339674 --- /dev/null +++ b/api/public_address_policy_test.go @@ -0,0 +1,158 @@ +// File: api/public_address_policy_test.go + +package api + +import ( + "context" + "net/url" + "testing" + "time" + + "github.com/thystra/Activity-Relay/models" +) + +func TestShouldDistributeByPublicAddress(t *testing.T) { + public := fepAE0CPublic + followers := "https://publisher.example/actor/followers" + + tests := []struct { + name string + policy models.PublicAddressDistributionPolicy + to []string + cc []string + expected bool + }{ + { + name: "explicit primary Public", + policy: models.PublicAddressExplicitPublicOnly, + to: []string{public}, + expected: true, + }, + { + name: "explicit excludes cc-only Public", + policy: models.PublicAddressExplicitPublicOnly, + to: []string{followers}, + cc: []string{public}, + expected: false, + }, + { + name: "compatibility includes cc-only Public", + policy: models.PublicAddressPublicAndUnlisted, + to: []string{followers}, + cc: []string{public}, + expected: true, + }, + { + name: "followers-only is not public fan-out", + policy: models.PublicAddressPublicAndUnlisted, + to: []string{followers}, + expected: false, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + activity := &models.Activity{ + Type: "Create", + To: test.to, + Cc: test.cc, + } + got := shouldDistributeByPublicAddress( + activity, + test.policy, + ) + if got != test.expected { + t.Fatalf( + "shouldDistributeByPublicAddress() = %t; want %t", + got, + test.expected, + ) + } + }) + } +} + +func TestExplicitPublicOnlyRecordsCCOnlyPublisherWithoutFanout( + t *testing.T, +) { + const receiverDomain = "policy-receiver.example" + + activity, _ := fixtureActivity( + t, + "traditional-create-public-cc", + ) + actor := actorForFixtureActivity(t, activity, "Person") + + actorURL, err := url.Parse(actor.ID) + if err != nil { + t.Fatalf("parse fixture actor ID: %v", err) + } + sourceDomain := normalizedActorDomain(actorURL) + if sourceDomain == "" { + t.Fatalf("fixture actor has no normalized domain: %q", actor.ID) + } + + resetFEPAE0CState(t, sourceDomain, receiverDomain) + addFEPTraditionalReceiver(t, receiverDomain) + t.Cleanup(func() { + resetFEPAE0CState(t, sourceDomain, receiverDomain) + }) + + if !isPublicOnlyInCCExcluded( + activity, + models.PublicAddressExplicitPublicOnly, + ) { + t.Fatal("cc-only Public activity was not classified as excluded") + } + + if err := executePolicyExcludedPublicActivity( + activity, + actor, + models.PublicAddressExplicitPublicOnly, + ); err != nil { + t.Fatalf("execute policy-excluded activity: %v", err) + } + + time.Sleep(250 * time.Millisecond) + keys, err := models.ScanKeys( + context.Background(), + RelayState.RedisClient, + "relay:activity:*", + ) + if err != nil { + t.Fatalf("scan relay activities: %v", err) + } + if len(keys) != 0 { + t.Fatalf("policy-excluded activity created relay work: %v", keys) + } + + publisher := RelayState.SelectPublisher(sourceDomain) + if publisher == nil { + t.Fatalf( + "policy-excluded source %q was not recorded as publisher", + sourceDomain, + ) + } + if publisher.LastActivityType != "Create" { + t.Fatalf( + "publisher activity type = %q; want Create", + publisher.LastActivityType, + ) + } +} + +func TestExplicitPolicyDoesNotClassifyPrimaryPublicAsExcluded(t *testing.T) { + activity, _ := fixtureActivity( + t, + "traditional-create-public-to", + ) + + if isPublicOnlyInCCExcluded( + activity, + models.PublicAddressExplicitPublicOnly, + ) { + t.Fatal("primary Public activity was classified as cc-only exclusion") + } +} + +// EOF: api/public_address_policy_test.go diff --git a/config.yml.example b/config.yml.example index c6729dd..d9b20f0 100644 --- a/config.yml.example +++ b/config.yml.example @@ -17,6 +17,13 @@ MAX_QUEUE_JOBS: 100000 # one GET fallback after an explicit legacy Signature challenge, and never # retries a delivery POST under another profile. OUTBOUND_SIGNATURE_PROFILE: legacy +# Controls which broadly addressable activities enter public relay fan-out. +# explicit_public_only relays only when Public is in the primary `to` field. +# public_and_unlisted also relays when Public appears only in `cc`, preserving +# pre-3.0 behavior. Neither value makes followers-only or direct activities +# eligible unless they are explicitly addressed to the relay through another +# supported protocol path. +PUBLIC_ADDRESS_DISTRIBUTION_POLICY: explicit_public_only # Single-disk defaults used by contrib/ops/resource-guard.py. These should # match RELAY_STORAGE_DIR and RELAY_CACHE_DIR in the deployment environment. diff --git a/docs/FEP-AE0C-COMPATIBILITY.md b/docs/FEP-AE0C-COMPATIBILITY.md index 0c3c51d..cf7019d 100644 --- a/docs/FEP-AE0C-COMPATIBILITY.md +++ b/docs/FEP-AE0C-COMPATIBILITY.md @@ -80,7 +80,7 @@ Mastodon's historical Linked Data `RsaSignature2017` document proof. | Relationship | One-way subscription | Reciprocal follow | Supports both receiver types | Preserve | | Distribution form | Forward eligible body | Relay-authored `Announce` | Both, plus NodeBB normalization | Preserve and document extension | | Public in `to` | Public | Public | Forwarded | Preserve | -| Public only in `cc` | Commonly interpreted as unlisted | Not central | Forwarded as public relay traffic | Investigate and make policy explicit | +| Public only in `cc` | Commonly interpreted as unlisted | Not central | Configurable: exclude from public fan-out or preserve pre-3.0 fan-out | Explicit policy with compatibility mode | | Original document proof | Historical Mastodon flow uses an embedded LD proof | Not central | Preserved if already in a forwarded body; not generated or verified | Document separately from HTTP signatures | | HTTP request authentication | Historical Fediverse `Signature` | Historical Fediverse `Signature` | Required inbound and outbound | Preserve; add RFC 9421 and RFC 9530 additively | | Sender must be receiver | Commonly coupled to subscription | Commonly coupled to reciprocal follow | Open signed publishers are supported | Preserve as an Activity-Relay extension | @@ -96,29 +96,26 @@ Mastodon's historical Linked Data `RsaSignature2017` document proof. ### Public in `to` versus `cc` -The current routing condition treats the Public URI in `to` and `cc` -identically. Mastodon commonly maps Public in `to` to public visibility and -Public only in `cc` to unlisted visibility. Relaying both can therefore broaden -the distribution of content whose author expected public accessibility but not -public-timeline amplification. - -The 3.0 design should expose an explicit policy rather than retain an accidental -condition: +Activity-Relay 3.0 exposes the routing decision explicitly: ```yaml -relay_policy: - traditional_visibility: public_to_or_cc +PUBLIC_ADDRESS_DISTRIBUTION_POLICY: explicit_public_only ``` -Candidate values are: +Supported values are: -- `public_to_only` -- `public_to_or_cc` +- `explicit_public_only`: public fan-out requires Public in the primary `to` + audience. Public only in `cc` is acknowledged and publisher-accounted without + relay fan-out. +- `public_and_unlisted`: Public in either `to` or `cc` enters public fan-out, + preserving the pre-3.0 behavior. -The 3.0 default remains undecided until characterization and interoperability -tests demonstrate the consequences for Mastodon, Friendica, NodeBB, and -LitePub-family software. Migration documentation must state whether an existing -installation retains its v2.5 behavior. +Fresh example configurations select `explicit_public_only`. Omitted +configuration falls back to `public_and_unlisted` for compatibility, while +upgrade documentation instructs operators retaining the old behavior to set it +explicitly. The broader value does not make followers-only or direct activities +eligible. Explicitly relay-addressed LitePub traffic retains its separate +relationship path. ### Linked Data proofs and HTTP message signatures @@ -210,6 +207,8 @@ executable unit and multi-relay integration coverage. tests; RFC-only must not become the default until interoperability supports it. - Keep open publisher ingestion and its actor-host security checks. +- Use `explicit_public_only` in fresh examples; preserve omitted and explicit + `public_and_unlisted` as the upgrade-compatible behavior. - Keep NodeBB normalization as a documented extension rather than presenting it as FEP-ae0c behavior. - Do not add Linked Data proof generation as an incidental part of RFC 9421 @@ -239,7 +238,8 @@ Fixture-driven API tests now freeze: - traditional Public-collection subscription; - LitePub-style follow and reciprocal `Accept`; -- v2.5 forwarding for Public in `to` and Public only in `cc`; +- public-address policy coverage for Public in `to`, Public only in `cc`, + compatibility fallback, and policy exclusion without fan-out; - byte-for-byte forwarding of a body containing an existing `RsaSignature2017` proof, without claiming proof verification; - open publisher fan-out without receiver membership; diff --git a/docs/INTEROPERABILITY.md b/docs/INTEROPERABILITY.md index 60af6b5..db9a481 100644 --- a/docs/INTEROPERABILITY.md +++ b/docs/INTEROPERABILITY.md @@ -35,6 +35,25 @@ characterization cases are maintained in an input to the Activity-Relay 3.0 design; v2.5.0 behavior remains unchanged by that design document. +## Public-address distribution policy + +Activity-Relay 3.0 distinguishes an explicitly public primary audience from an +activity that places the ActivityStreams Public collection only in `cc`. + +`PUBLIC_ADDRESS_DISTRIBUTION_POLICY` accepts: + +- `explicit_public_only`: public fan-out requires Public in `to`; Public only in + `cc` is acknowledged and publisher-accounted without relay fan-out. +- `public_and_unlisted`: Public in either `to` or `cc` enters public fan-out, + preserving the pre-3.0 behavior. + +The fresh configuration example selects `explicit_public_only`. Omitted +configuration falls back to `public_and_unlisted` for compatibility, and an +upgrade retaining the old behavior should set that value explicitly. The +broader value does not make followers-only or direct activities eligible. +Explicitly relay-addressed LitePub traffic is evaluated through its own +relationship path before the cc-only exclusion. + ## Subscription models Activity-Relay supports both common relay subscription models: diff --git a/main.go b/main.go index d94ac01..b75b722 100644 --- a/main.go +++ b/main.go @@ -27,6 +27,7 @@ YAML Format RELAY_SERVICENAME: Example ActivityPub Relay JOB_CONCURRENCY: 50 OUTBOUND_SIGNATURE_PROFILE: legacy + PUBLIC_ADDRESS_DISTRIBUTION_POLICY: explicit_public_only RELAY_SUMMARY: | Example ActivityPub Relay is powered by Activity-Relay RELAY_ICON: https://example.com/example_icon.png @@ -43,6 +44,7 @@ This is Optional : When config file not exist, use environment variables. - RELAY_SERVICENAME - JOB_CONCURRENCY - OUTBOUND_SIGNATURE_PROFILE + - PUBLIC_ADDRESS_DISTRIBUTION_POLICY - RELAY_SUMMARY - RELAY_ICON - RELAY_IMAGE @@ -180,6 +182,7 @@ func initConfig(cmd *cobra.Command) { viper.BindEnv("MAX_FANOUT_TARGETS") viper.BindEnv("MAX_QUEUE_JOBS") viper.BindEnv("OUTBOUND_SIGNATURE_PROFILE") + viper.BindEnv("PUBLIC_ADDRESS_DISTRIBUTION_POLICY") } GlobalConfig, err = models.NewRelayConfig() diff --git a/misc/test/config.yml b/misc/test/config.yml index fb22d95..d9edb40 100644 --- a/misc/test/config.yml +++ b/misc/test/config.yml @@ -5,6 +5,7 @@ RELAY_BIND: 0.0.0.0:8080 RELAY_DOMAIN: relay.toot.yukimochi.jp RELAY_SERVICENAME: YUKIMOCHI Toot Relay Service JOB_CONCURRENCY: 50 +PUBLIC_ADDRESS_DISTRIBUTION_POLICY: public_and_unlisted RELAY_SUMMARY: YUKIMOCHI Toot Relay Service is Running by Activity-Relay RELAY_ICON: https://example.com/example_icon.png RELAY_IMAGE: https://example.com/example_image.png \ No newline at end of file diff --git a/models/config.go b/models/config.go index 5aa9aa0..4e7ff99 100644 --- a/models/config.go +++ b/models/config.go @@ -24,28 +24,38 @@ import ( // RelayConfig contains valid configuration. type RelayConfig struct { - actorKey *rsa.PrivateKey - domain *url.URL - redisClient *redis.Client - redisOptions *redis.Options - redisURL string - redisDisplayURL string - serverBind string - observabilityBind string - serviceName string - serviceSummary string - serviceIconURL *url.URL - serviceImageURL *url.URL - jobConcurrency int - maxActivityBytes int64 - maxFanoutTargets int - maxQueueJobs int64 - outboundSignatureProfile relayhttpsig.Profile - outboundSignatureNegotiator *relayhttpsig.DestinationNegotiator + actorKey *rsa.PrivateKey + domain *url.URL + redisClient *redis.Client + redisOptions *redis.Options + redisURL string + redisDisplayURL string + serverBind string + observabilityBind string + serviceName string + serviceSummary string + serviceIconURL *url.URL + serviceImageURL *url.URL + jobConcurrency int + maxActivityBytes int64 + maxFanoutTargets int + maxQueueJobs int64 + publicAddressDistributionPolicy PublicAddressDistributionPolicy + outboundSignatureProfile relayhttpsig.Profile + outboundSignatureNegotiator *relayhttpsig.DestinationNegotiator } // NewRelayConfig create valid RelayConfig from viper configuration. func NewRelayConfig() (*RelayConfig, error) { + publicAddressDistributionPolicy, err := ParsePublicAddressDistributionPolicy( + viper.GetString("PUBLIC_ADDRESS_DISTRIBUTION_POLICY"), + ) + if err != nil { + return nil, errors.New( + "PUBLIC_ADDRESS_DISTRIBUTION_POLICY: " + err.Error(), + ) + } + outboundSignatureProfile, err := relayhttpsig.ParseOutboundProfile( viper.GetString("OUTBOUND_SIGNATURE_PROFILE"), ) @@ -147,24 +157,25 @@ func NewRelayConfig() (*RelayConfig, error) { } return &RelayConfig{ - actorKey: privateKey, - domain: domain, - redisClient: redisClient, - redisOptions: redisOptions, - redisURL: redisURL, - redisDisplayURL: redactedRedisURL(redisURL), - serverBind: serverBind, - observabilityBind: observabilityBind, - serviceName: viper.GetString("RELAY_SERVICENAME"), - serviceSummary: viper.GetString("RELAY_SUMMARY"), - serviceIconURL: iconURL, - serviceImageURL: imageURL, - jobConcurrency: jobConcurrency, - maxActivityBytes: maxActivityBytes, - maxFanoutTargets: maxFanoutTargets, - maxQueueJobs: maxQueueJobs, - outboundSignatureProfile: outboundSignatureProfile, - outboundSignatureNegotiator: outboundSignatureNegotiator, + actorKey: privateKey, + domain: domain, + redisClient: redisClient, + redisOptions: redisOptions, + redisURL: redisURL, + redisDisplayURL: redactedRedisURL(redisURL), + serverBind: serverBind, + observabilityBind: observabilityBind, + serviceName: viper.GetString("RELAY_SERVICENAME"), + serviceSummary: viper.GetString("RELAY_SUMMARY"), + serviceIconURL: iconURL, + serviceImageURL: imageURL, + jobConcurrency: jobConcurrency, + maxActivityBytes: maxActivityBytes, + maxFanoutTargets: maxFanoutTargets, + maxQueueJobs: maxQueueJobs, + publicAddressDistributionPolicy: publicAddressDistributionPolicy, + outboundSignatureProfile: outboundSignatureProfile, + outboundSignatureNegotiator: outboundSignatureNegotiator, }, nil } @@ -202,6 +213,15 @@ func (relayConfig *RelayConfig) MaxFanoutTargets() int { return relayConfig.maxF // MaxQueueJobs limits admission based on the Redis broker backlog. func (relayConfig *RelayConfig) MaxQueueJobs() int64 { return relayConfig.maxQueueJobs } +// PublicAddressDistributionPolicy returns the effective public-address +// fan-out policy. +func (relayConfig *RelayConfig) PublicAddressDistributionPolicy() PublicAddressDistributionPolicy { + if relayConfig == nil || relayConfig.publicAddressDistributionPolicy == "" { + return PublicAddressPublicAndUnlisted + } + return relayConfig.publicAddressDistributionPolicy +} + // OutboundSignatureProfile is the process-wide authorized-fetch and // delivery HTTP-signature profile. func (relayConfig *RelayConfig) OutboundSignatureProfile() relayhttpsig.Profile { @@ -244,8 +264,9 @@ REDIS URL : %s BIND ADDRESS : %s OBSERVABILITY : %s SIGNATURE PROFILE: %s +PUBLIC ADDRESS POLICY: %s JOB_CONCURRENCY : %s -`, version, moduleName, relayConfig.serviceName, relayConfig.domain.Host, relayConfig.redisDisplayURL, relayConfig.serverBind, observabilityBind, relayConfig.OutboundSignatureProfile(), strconv.Itoa(relayConfig.jobConcurrency)) +`, version, moduleName, relayConfig.serviceName, relayConfig.domain.Host, relayConfig.redisDisplayURL, relayConfig.serverBind, observabilityBind, relayConfig.OutboundSignatureProfile(), relayConfig.PublicAddressDistributionPolicy(), strconv.Itoa(relayConfig.jobConcurrency)) } func redactedRedisURL(redisURL string) string { diff --git a/models/config_test.go b/models/config_test.go index b6895a6..024bf85 100644 --- a/models/config_test.go +++ b/models/config_test.go @@ -37,6 +37,13 @@ func TestNewRelayConfig(t *testing.T) { if relayConfig.serviceImageURL.String() != "https://example.com/example_image.png" { t.Errorf("Expected RelayConfig.serviceImageURL to be 'https://example.com/example_image.png', but got '%s'", relayConfig.serviceImageURL.String()) } + if relayConfig.PublicAddressDistributionPolicy() != PublicAddressPublicAndUnlisted { + t.Errorf( + "Expected configured public-address policy to be %q, got %q", + PublicAddressPublicAndUnlisted, + relayConfig.PublicAddressDistributionPolicy(), + ) + } if relayConfig.OutboundSignatureProfile() != relayhttpsig.ProfileLegacy { t.Errorf( "Expected omitted outbound profile to be legacy, got %q", @@ -47,13 +54,14 @@ func TestNewRelayConfig(t *testing.T) { t.Run("Fail to load invalid configuration", func(t *testing.T) { invalidConfig := map[string]string{ - "ACTOR_PEM@notFound": "../misc/test/notfound.pem", - "ACTOR_PEM@invalidKey": "../misc/test/actor.dh.pem", - "REDIS_URL@invalidURL": "", - "REDIS_URL@unreachableHost": "redis://localhost:6380", - "OBSERVABILITY_BIND@missingPort": "127.0.0.1", - "OBSERVABILITY_BIND@zeroPort": "127.0.0.1:0", - "OUTBOUND_SIGNATURE_PROFILE@unknown": "automatic", + "ACTOR_PEM@notFound": "../misc/test/notfound.pem", + "ACTOR_PEM@invalidKey": "../misc/test/actor.dh.pem", + "REDIS_URL@invalidURL": "", + "REDIS_URL@unreachableHost": "redis://localhost:6380", + "OBSERVABILITY_BIND@missingPort": "127.0.0.1", + "OBSERVABILITY_BIND@zeroPort": "127.0.0.1:0", + "OUTBOUND_SIGNATURE_PROFILE@unknown": "automatic", + "PUBLIC_ADDRESS_DISTRIBUTION_POLICY@unknown": "everything", } for key, value := range invalidConfig { @@ -110,14 +118,15 @@ func TestRelayConfig_DumpWelcomeMessage(t *testing.T) { w := relayConfig.DumpWelcomeMessage("Testing", "") informations := map[string]string{ - "module NAME": "Testing", - "RELAY NAME": relayConfig.serviceName, - "RELAY DOMAIN": relayConfig.domain.Host, - "REDIS URL": relayConfig.redisDisplayURL, - "BIND ADDRESS": relayConfig.serverBind, - "OBSERVABILITY": "disabled", - "SIGNATURE PROFILE": relayhttpsig.ProfileLegacy.String(), - "JOB_CONCURRENCY": strconv.Itoa(relayConfig.jobConcurrency), + "module NAME": "Testing", + "RELAY NAME": relayConfig.serviceName, + "RELAY DOMAIN": relayConfig.domain.Host, + "REDIS URL": relayConfig.redisDisplayURL, + "BIND ADDRESS": relayConfig.serverBind, + "OBSERVABILITY": "disabled", + "SIGNATURE PROFILE": relayhttpsig.ProfileLegacy.String(), + "PUBLIC ADDRESS POLICY": PublicAddressPublicAndUnlisted.String(), + "JOB_CONCURRENCY": strconv.Itoa(relayConfig.jobConcurrency), } for key, information := range informations { diff --git a/models/public_address_policy.go b/models/public_address_policy.go new file mode 100644 index 0000000..865921f --- /dev/null +++ b/models/public_address_policy.go @@ -0,0 +1,85 @@ +// File: models/public_address_policy.go + +package models + +import ( + "fmt" + "strings" +) + +const activityStreamsPublicAddress = "https://www.w3.org/ns/activitystreams#Public" + +// PublicAddressDistributionPolicy controls whether the Public collection must +// appear in the primary `to` audience or may also appear only in `cc`. +type PublicAddressDistributionPolicy string + +const ( + // PublicAddressExplicitPublicOnly relays only activities with Public in `to`. + PublicAddressExplicitPublicOnly PublicAddressDistributionPolicy = "explicit_public_only" + + // PublicAddressPublicAndUnlisted preserves the pre-3.0 behavior by relaying + // activities with Public in either `to` or `cc`. + PublicAddressPublicAndUnlisted PublicAddressDistributionPolicy = "public_and_unlisted" +) + +// ParsePublicAddressDistributionPolicy validates the operator-facing value. +// Empty input is the compatibility fallback for existing configurations. +func ParsePublicAddressDistributionPolicy( + value string, +) (PublicAddressDistributionPolicy, error) { + switch strings.ToLower(strings.TrimSpace(value)) { + case "": + return PublicAddressPublicAndUnlisted, nil + case string(PublicAddressExplicitPublicOnly): + return PublicAddressExplicitPublicOnly, nil + case string(PublicAddressPublicAndUnlisted): + return PublicAddressPublicAndUnlisted, nil + default: + return "", fmt.Errorf( + "unsupported value %q; supported values are %q and %q", + value, + PublicAddressExplicitPublicOnly, + PublicAddressPublicAndUnlisted, + ) + } +} + +// String returns the validated operator-facing value. +func (policy PublicAddressDistributionPolicy) String() string { + return string(policy) +} + +// Allows reports whether the activity enters public-address fan-out. +func (policy PublicAddressDistributionPolicy) Allows( + to []string, + cc []string, +) bool { + if containsPublicAddress(to) { + return true + } + + return policy == PublicAddressPublicAndUnlisted && + containsPublicAddress(cc) +} + +// ExcludesPublicOnlyInCC identifies an activity that is public through `cc` +// but intentionally excluded from public fan-out by explicit_public_only. +func (policy PublicAddressDistributionPolicy) ExcludesPublicOnlyInCC( + to []string, + cc []string, +) bool { + return policy == PublicAddressExplicitPublicOnly && + !containsPublicAddress(to) && + containsPublicAddress(cc) +} + +func containsPublicAddress(addresses []string) bool { + for _, address := range addresses { + if address == activityStreamsPublicAddress { + return true + } + } + return false +} + +// EOF: models/public_address_policy.go diff --git a/models/public_address_policy_test.go b/models/public_address_policy_test.go new file mode 100644 index 0000000..78562d4 --- /dev/null +++ b/models/public_address_policy_test.go @@ -0,0 +1,128 @@ +// File: models/public_address_policy_test.go + +package models + +import "testing" + +func TestParsePublicAddressDistributionPolicy(t *testing.T) { + tests := []struct { + name string + input string + want PublicAddressDistributionPolicy + }{ + { + name: "omitted compatibility fallback", + input: "", + want: PublicAddressPublicAndUnlisted, + }, + { + name: "explicit public only", + input: "explicit_public_only", + want: PublicAddressExplicitPublicOnly, + }, + { + name: "public and unlisted", + input: "public_and_unlisted", + want: PublicAddressPublicAndUnlisted, + }, + { + name: "trimmed and case normalized", + input: " EXPLICIT_PUBLIC_ONLY ", + want: PublicAddressExplicitPublicOnly, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, err := ParsePublicAddressDistributionPolicy(test.input) + if err != nil { + t.Fatalf("parse policy: %v", err) + } + if got != test.want { + t.Fatalf("policy = %q; want %q", got, test.want) + } + }) + } +} + +func TestParsePublicAddressDistributionPolicyRejectsUnknown(t *testing.T) { + if _, err := ParsePublicAddressDistributionPolicy("everything"); err == nil { + t.Fatal("unknown public-address policy was accepted") + } +} + +func TestPublicAddressDistributionPolicyAllows(t *testing.T) { + public := activityStreamsPublicAddress + followers := "https://publisher.example/actor/followers" + + tests := []struct { + name string + policy PublicAddressDistributionPolicy + to []string + cc []string + want bool + }{ + { + name: "explicit public in to", + policy: PublicAddressExplicitPublicOnly, + to: []string{public}, + want: true, + }, + { + name: "explicit excludes public only in cc", + policy: PublicAddressExplicitPublicOnly, + to: []string{followers}, + cc: []string{public}, + want: false, + }, + { + name: "broad includes public only in cc", + policy: PublicAddressPublicAndUnlisted, + to: []string{followers}, + cc: []string{public}, + want: true, + }, + { + name: "followers only remains ineligible", + policy: PublicAddressPublicAndUnlisted, + to: []string{followers}, + want: false, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := test.policy.Allows(test.to, test.cc); got != test.want { + t.Fatalf("Allows() = %t; want %t", got, test.want) + } + }) + } +} + +func TestPublicAddressDistributionPolicyExcludesPublicOnlyInCC(t *testing.T) { + public := activityStreamsPublicAddress + followers := "https://publisher.example/actor/followers" + + if !PublicAddressExplicitPublicOnly.ExcludesPublicOnlyInCC( + []string{followers}, + []string{public}, + ) { + t.Fatal("explicit policy did not identify cc-only Public") + } + + if PublicAddressExplicitPublicOnly.ExcludesPublicOnlyInCC( + []string{public}, + nil, + ) { + t.Fatal("explicit policy excluded Public in primary to") + } + + if PublicAddressPublicAndUnlisted.ExcludesPublicOnlyInCC( + []string{followers}, + []string{public}, + ) { + t.Fatal("broad policy excluded cc-only Public") + } +} + +// EOF: models/public_address_policy_test.go diff --git a/readme.md b/readme.md index 2e5504f..51c40bd 100644 --- a/readme.md +++ b/readme.md @@ -292,6 +292,10 @@ MAX_QUEUE_JOBS: 100000 # Outbound authorized-fetch and delivery signing. Omitted defaults to legacy. OUTBOUND_SIGNATURE_PROFILE: legacy +# Fresh-install default. Existing installations can retain the +# pre-3.0 behavior with public_and_unlisted. +PUBLIC_ADDRESS_DISTRIBUTION_POLICY: explicit_public_only + # RELAY_SUMMARY: | # Optional public relay branding. These are interoperability recommendations, # not protocol limits; clients may crop or rescale the supplied images. @@ -306,6 +310,8 @@ OUTBOUND_SIGNATURE_PROFILE: legacy Use `127.0.0.1:8080` instead when the relay and reverse proxy run directly on the same host. +`PUBLIC_ADDRESS_DISTRIBUTION_POLICY` accepts `explicit_public_only` or `public_and_unlisted`. `explicit_public_only` distributes activities only when the ActivityStreams Public collection appears in the primary `to` audience. A Public address appearing only in `cc` is acknowledged and publisher-accounted but is not distributed through public fan-out. `public_and_unlisted` preserves the pre-3.0 behavior by including Public from either `to` or `cc`; it does not make followers-only or direct activities eligible. The fresh configuration example explicitly selects `explicit_public_only`. An omitted value falls back to `public_and_unlisted` for compatibility, but upgrade configurations should set that value explicitly when retaining the old behavior. + `OUTBOUND_SIGNATURE_PROFILE` accepts `legacy`, `rfc9421`, or `dual`. Empty or omitted configuration preserves the established legacy signatures. The same value is used by the API server for signed remote GETs and by every @@ -343,6 +349,7 @@ MAX_ACTIVITY_BYTES MAX_FANOUT_TARGETS MAX_QUEUE_JOBS OUTBOUND_SIGNATURE_PROFILE +PUBLIC_ADDRESS_DISTRIBUTION_POLICY RELAY_SUMMARY RELAY_ICON RELAY_IMAGE diff --git a/testdata/fep-ae0c/cases.json b/testdata/fep-ae0c/cases.json index 8401721..0bb2231 100644 --- a/testdata/fep-ae0c/cases.json +++ b/testdata/fep-ae0c/cases.json @@ -116,14 +116,14 @@ } }, "current_v2_5": "Queues the original received body for relay fan-out.", - "v3_assertion": "Unsigned public activities use a relay-authored Announce; exact-body forwarding is preserved only for the historical Mastodon LD-signature shape." + "v3_assertion": "Both policies relay Public in the primary to audience. Unsigned source activities use a relay-authored Announce; the historical Mastodon LD-signature shape retains exact-body forwarding to traditional subscribers." }, { "id": "traditional-create-public-cc", "description": "Create addresses Public only in cc, commonly interpreted as unlisted.", "profile": "mastodon", - "status": "specified", - "decision": "investigate", + "status": "covered", + "decision": "document-extension", "distribution": "relay-announce", "activity": { "@context": "https://www.w3.org/ns/activitystreams", @@ -144,7 +144,7 @@ } }, "current_v2_5": "Routes identically to Public in to and fans out the original body.", - "v3_assertion": "Under the current broad public policy, unsigned cc-only Public activities use a relay-authored Announce; the configurable v3 distribution policy remains separate work." + "v3_assertion": "explicit_public_only acknowledges and publisher-accounts the activity without public fan-out; public_and_unlisted preserves relay-authored Announce fan-out for Public only in cc." }, { "id": "traditional-forward-preserves-ld-proof", @@ -221,7 +221,7 @@ } }, "current_v2_5": "Creates one relay-authored Announce referencing the canonical object ID for all receiver styles.", - "v3_assertion": "Preserve signer/actor alignment and same-origin restriction." + "v3_assertion": "Preserve signer/actor alignment and the same-origin restriction. A cc-only Public NodeBB activity requires public_and_unlisted unless it is also explicitly addressed through a supported relay relationship path." }, { "id": "public-announce-url-only",