All checks were successful
/ docker (push) Successful in 1m56s
Also added two fields to roles model, but haven't ran the various generators yet
299 lines
8.2 KiB
Go
299 lines
8.2 KiB
Go
package webshared
|
|
|
|
import (
|
|
"bytes"
|
|
"cmp"
|
|
"crypto/sha256"
|
|
"crypto/x509"
|
|
"encoding/base64"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/rs/zerolog/log"
|
|
"github.com/yaronf/httpsign"
|
|
|
|
"git.mstar.dev/mstar/linstrom/config"
|
|
"git.mstar.dev/mstar/linstrom/shared"
|
|
"git.mstar.dev/mstar/linstrom/storage-new/dbgen"
|
|
"git.mstar.dev/mstar/linstrom/storage-new/models"
|
|
)
|
|
|
|
/*
|
|
Links for home:
|
|
- https://pkg.go.dev/github.com/yaronf/httpsign#Client.Do
|
|
- https://www.ietf.org/archive/id/draft-richanna-http-message-signatures-00.html
|
|
- https://github.com/mastodon/mastodon/issues/29905
|
|
- https://github.com/fedify-dev/fedify/issues/208
|
|
- https://github.com/mastodon/mastodon/issues/21429
|
|
- https://github.com/go-ap/fedbox/blob/master/httpsig.go
|
|
- https://swicg.github.io/activitypub-http-signature/
|
|
- https://datatracker.ietf.org/doc/html/rfc9421
|
|
*/
|
|
|
|
// Perform a signed request. Tries RFC9421 first and on fail cavage.
|
|
// This double-knocking is because implementations currently use cavage (a precursor to RFC9421).
|
|
// However, Cavage is deprecated now and the RFC should be used instead. And so
|
|
// implementations have slowly begun to implement the RFC in addition to cavage
|
|
//
|
|
// Returns the unmodified response, if the request completed with RFC signing and an error, if any
|
|
func RequestSigned(
|
|
method, target string,
|
|
body []byte,
|
|
actor *models.User,
|
|
) (response *http.Response, wasRfc9421 bool, err error) {
|
|
if method == "POST" {
|
|
storedRequestId, err := prePostRequest(target, body, actor)
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
defer postPostRequest(response, err, *storedRequestId)
|
|
}
|
|
wasRfc9421 = true
|
|
response, err = RequestSignedRFC9421(method, target, body, actor)
|
|
if err == nil && response.StatusCode < 400 {
|
|
return
|
|
}
|
|
wasRfc9421 = false
|
|
log.Debug().Str("target", target).Msg("RFC9421 signed request failed, trying cavage signed")
|
|
response, err = RequestSignedCavage(method, target, body, actor)
|
|
return
|
|
}
|
|
|
|
// Perform a request, signing it as specified in RFC 9421
|
|
func RequestSignedRFC9421(
|
|
method, target string,
|
|
body []byte,
|
|
actor *models.User,
|
|
) (*http.Response, error) {
|
|
req, err := http.NewRequest(method, target, bytes.NewBuffer(slices.Clone(body)))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
applyDefaultHeaders(req)
|
|
req.Header.Add("Accept", "application/activity+json")
|
|
req.Header.Add("Content-Type", "application/activity+json")
|
|
var signer *httpsign.Signer
|
|
signerFields := httpsign.Headers("@request-target", "content-digest")
|
|
if config.GlobalConfig.Experimental.UseEd25519Keys {
|
|
signer, err = httpsign.NewEd25519Signer(
|
|
actor.PrivateKeyEd,
|
|
httpsign.NewSignConfig(),
|
|
signerFields,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
} else {
|
|
key, err := x509.ParsePKCS1PrivateKey(actor.PrivateKeyRsa)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
signer, err = httpsign.NewRSASigner(*key, httpsign.NewSignConfig(), signerFields)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
clientConfig := httpsign.NewClientConfig().SetSigner(signer)
|
|
if config.GlobalConfig.Experimental.UseEd25519Keys {
|
|
clientConfig = clientConfig.SetSignatureName("sig-ed")
|
|
} else {
|
|
clientConfig = clientConfig.SetSignatureName("sig-rsa")
|
|
}
|
|
client := httpsign.NewClient(
|
|
RequestClient,
|
|
clientConfig,
|
|
)
|
|
res, err := client.Do(req)
|
|
return res, err
|
|
}
|
|
|
|
// Perform a request, signing it as specified in the cavage proposal
|
|
// (https://swicg.github.io/activitypub-http-signature/#how-to-sign-a-request)
|
|
func RequestSignedCavage(
|
|
method, target string,
|
|
body []byte,
|
|
actor *models.User,
|
|
) (*http.Response, error) {
|
|
var bodyReader io.Reader
|
|
if body != nil {
|
|
bodyReader = bytes.NewReader(body)
|
|
}
|
|
req, err := NewRequest(method, target, bodyReader)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Add("Accept", "application/activity+json")
|
|
req.Header.Add("Content-Type", "application/activity+json")
|
|
|
|
var keyBytes []byte
|
|
if config.GlobalConfig.Experimental.UseEd25519Keys {
|
|
keyBytes = actor.PrivateKeyEd
|
|
} else {
|
|
keyBytes = actor.PrivateKeyRsa
|
|
}
|
|
|
|
// Sign and send
|
|
err = SignRequest(
|
|
req,
|
|
actor.ID+"#main-key",
|
|
keyBytes,
|
|
body,
|
|
)
|
|
// err = webshared.SignRequestWithHttpsig(req, linstromActor.ID+"#main-key", keyBytes, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return RequestClient.Do(req)
|
|
}
|
|
|
|
func applyDefaultHeaders(r *http.Request) {
|
|
r.Header.Add(
|
|
"User-Agent",
|
|
"Linstrom "+shared.Version+" ("+config.GlobalConfig.General.GetFullDomain()+")",
|
|
)
|
|
r.Header.Add("Date", time.Now().UTC().Format(http.TimeFormat))
|
|
r.Header.Add("Accept", "application/activity+json")
|
|
}
|
|
|
|
func applyBodyHash(headers http.Header, body []byte) error {
|
|
if len(body) == 0 {
|
|
return nil
|
|
}
|
|
hash := sha256.Sum256(body)
|
|
header := "SHA-256=" + base64.StdEncoding.EncodeToString(hash[:])
|
|
headers.Set("Digest", header)
|
|
return nil
|
|
}
|
|
|
|
// Runs before a signed outbound request.
|
|
// If the request is POST, stores it in the db as not processed yet.
|
|
// This is to ensure data consistency
|
|
func prePostRequest(
|
|
target string,
|
|
body []byte,
|
|
actor *models.User,
|
|
) (*uint64, error) {
|
|
targetUrl, err := url.Parse(target)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
server, err := dbgen.RemoteServer.Where(dbgen.RemoteServer.Domain.Eq(targetUrl.Hostname())).
|
|
First()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
fr := dbgen.FailedOutboundRequest
|
|
now := time.Now()
|
|
data := models.FailedOutboundRequest{
|
|
TargetServer: server,
|
|
TargetServerId: server.ID,
|
|
ActingUserId: actor.ID,
|
|
ActingUser: actor,
|
|
NrOfAttempts: 1,
|
|
RawData: body,
|
|
FirstAttempt: now,
|
|
LastAttempt: now,
|
|
LastFailureReason: string(models.RequestFailureNotAttemptedYet),
|
|
}
|
|
err = fr.Create(&data)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &data.Id, nil
|
|
}
|
|
|
|
// Updates the db request based on the results of the request
|
|
func postPostRequest(resp *http.Response, reqErr error, dbId uint64) {
|
|
fr := dbgen.FailedOutboundRequest
|
|
failureReason := "generic"
|
|
update := true // Flag to tell defer func to not update since request info has been deleted
|
|
defer func() {
|
|
if !update {
|
|
return
|
|
}
|
|
_, err := fr.Where(fr.Id.Eq(dbId)).UpdateColumn(fr.LastFailureReason, failureReason)
|
|
if err != nil {
|
|
log.Error().
|
|
Err(err).
|
|
Str("reason", failureReason).
|
|
Uint64("request-id", dbId).
|
|
Msg("Failed to update failure reason")
|
|
}
|
|
}()
|
|
if reqErr != nil {
|
|
failureReason = string(models.RequestFailureRequestError)
|
|
return
|
|
}
|
|
// Only check response data after handling response error
|
|
// Response could be nil otherwise, causing a panic (or an extra, useless check)
|
|
|
|
// If response status is ok (< 400) delete entry in db to not process it again
|
|
if resp.StatusCode < 400 {
|
|
update = false
|
|
_, _ = fr.Where(fr.Id.Eq(dbId)).Delete()
|
|
return
|
|
}
|
|
if resp.StatusCode == 429 {
|
|
// Always prefer the rate limit headers as defined by https://www.ietf.org/archive/id/draft-polli-ratelimit-headers-02.html
|
|
limit := cmp.Or(
|
|
resp.Header.Get("RateLimit-Limit"),
|
|
resp.Header.Get("X-RateLimit-Limit"),
|
|
resp.Header.Get("X-Rate-Limit-Limit"),
|
|
)
|
|
remaining := cmp.Or(
|
|
resp.Header.Get("RateLimit-Remaining"),
|
|
resp.Header.Get("X-RateLimit-Remaining"),
|
|
resp.Header.Get("X-Rate-Limit-Remaining"),
|
|
)
|
|
reset := cmp.Or(
|
|
resp.Header.Get("RateLimit-Reset"),
|
|
resp.Header.Get("X-RateLimit-Reset"),
|
|
resp.Header.Get("X-Rate-Limit-Reset"),
|
|
)
|
|
if cmp.Or(limit, remaining, reset) == "" {
|
|
failureReason = string(models.RequestFailureRateLimitedNoInfo)
|
|
return
|
|
} else {
|
|
limit = strings.Split(limit, ",")[0]
|
|
limit = strings.Split(limit, ";")[0]
|
|
if limit == "" {
|
|
limit = "-1"
|
|
}
|
|
limitNum, err := strconv.Atoi(limit)
|
|
if err != nil {
|
|
failureReason = string(models.RequestFailureRateLimitedNoInfo)
|
|
return
|
|
}
|
|
if remaining == "" {
|
|
remaining = "-1"
|
|
}
|
|
remainingNum, err := strconv.Atoi(remaining)
|
|
if err != nil {
|
|
failureReason = string(models.RequestFailureRateLimitedNoInfo)
|
|
return
|
|
}
|
|
if reset == "" {
|
|
reset = "-1"
|
|
}
|
|
resetNum, err := strconv.Atoi(reset)
|
|
if err != nil {
|
|
failureReason = string(models.RequestFailureRateLimitedNoInfo)
|
|
return
|
|
}
|
|
failureReason = fmt.Sprintf(string(models.RequestFailureRateLimitTemplate), limitNum, remainingNum, resetNum)
|
|
}
|
|
return
|
|
} else {
|
|
if resp.StatusCode >= 500 {
|
|
failureReason = string(models.RequestFailureInternalError)
|
|
} else {
|
|
failureReason = string(models.RequestFailureRejected)
|
|
}
|
|
}
|
|
}
|