Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
67 changes: 66 additions & 1 deletion api/handle.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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 {
Expand Down
158 changes: 158 additions & 0 deletions api/public_address_policy_test.go
Original file line number Diff line number Diff line change
@@ -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
7 changes: 7 additions & 0 deletions config.yml.example
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
38 changes: 19 additions & 19 deletions docs/FEP-AE0C-COMPATIBILITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down
19 changes: 19 additions & 0 deletions docs/INTEROPERABILITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
3 changes: 3 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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()
Expand Down
Loading