commit 25fed994674728cf1905a717c1334f78c12781ea from: Oliver Lowe date: Tue Mar 12 00:16:00 2024 UTC all: refactor like Unix, Plan 9 upas That is, delegate all message handling, incoming and outgoing to a short-lived process. Maybe this is really like sendmail but I dunno. Anyway it works and this is a big and bad enough commit already! commit - 158b818071282f1db2ff66815dca7883ae8b4019 commit + 25fed994674728cf1905a717c1334f78c12781ea blob - 005bdb3a278e9e0849e6e6b4267b8c5798f4bff6 blob + 0ba7ff4eb378b0ba7e6afead99b05991a4dd552b --- apub.go +++ apub.go @@ -19,12 +19,12 @@ import ( // @context const AtContext string = "https://www.w3.org/ns/activitystreams" +// ContentType is the MIME media type for ActivityPub. const ContentType string = "application/activity+json" -const AcceptMediaType string = `application/activity+json; profile="https://www.w3.org/ns/activitystreams"` - -// Activities addressed to this collection indicates the activity -// is available to all users, authenticated or not. +// PublicCollection is the ActivityPub ID for the special collection indicating public access. +// Any Activity addressed to this collection is meant to be available to all users, +// authenticated or not. // See W3C Recommendation ActivityPub Section 5.6. const PublicCollection string = "https://www.w3.org/ns/activitystreams#Public" @@ -80,6 +80,10 @@ func (act *Activity) UnmarshalJSON(b []byte) error { return nil } +// Unwrap returns the JSON-encoded Activity, if any, enclosed in act. +// The Activity may be referenced by ID, +// in which case the activity is looked up by client or by +// apub.defaultClient if client is nil. func (act *Activity) Unwrap(client *Client) (*Activity, error) { if act.Object == nil { return nil, errors.New("no wrapped activity") @@ -132,6 +136,13 @@ type PublicKey struct { PublicKeyPEM string `json:"publicKeyPem"` } +// Address generates the most likely address of the Actor. +// The Actor's name (not the username) is used as the address' proper name, if present. +// Implementors should verify the address using WebFinger. +// For example, the followers address for Actor ID +// https://hachyderm.io/users/otl is: +// +// "Oliver Lowe" func (a *Actor) Address() *mail.Address { if a.Username == "" && a.Name == "" { return &mail.Address{"", a.ID} @@ -142,6 +153,15 @@ func (a *Actor) Address() *mail.Address { return &mail.Address{a.Name, addr} } +// FollowersAddress generates a non-standard address representing the Actor's followers +// using plus addressing. +// It is the Actor's address username part with a "+followers" suffix. +// The address cannot be resolved using WebFinger. +// +// For example, the followers address for Actor ID +// https://hachyderm.io/users/otl is: +// +// "Oliver Lowe (followers)" func (a *Actor) FollowersAddress() *mail.Address { if a.Followers == "" { return &mail.Address{"", ""} blob - e5c409b090790dfdb9623c5fe12fad3e4def11e1 blob + bfcb8e5d35c75965c081cafe858dbf41b98e644d --- client.go +++ client.go @@ -23,8 +23,11 @@ func LookupActor(id string) (*Actor, error) { type Client struct { *http.Client - Key *rsa.PrivateKey - Actor *Actor + // Key is a RSA private key which will be used to sign requests. + Key *rsa.PrivateKey + // PubKeyID is a URL where the corresponding public key of Key + // may be accessed. This must be set if Key is also set. + PubKeyID string // actor.PublicKey.ID } func (c *Client) Lookup(id string) (*Activity, error) { @@ -40,8 +43,8 @@ func (c *Client) Lookup(id string) (*Activity, error) return nil, err } req.Header.Set("Accept", ContentType) - if c.Key != nil && c.Actor != nil { - if err := Sign(req, c.Key, c.Actor.PublicKey.ID); err != nil { + if c.Key != nil && c.PubKeyID != "" { + if err := Sign(req, c.Key, c.PubKeyID); err != nil { return nil, fmt.Errorf("sign http request: %w", err) } } @@ -95,7 +98,7 @@ func (c *Client) Send(inbox string, activity *Activity return nil, err } req.Header.Set("Content-Type", ContentType) - if err := Sign(req, c.Key, c.Actor.PublicKey.ID); err != nil { + if err := Sign(req, c.Key, c.PubKeyID); err != nil { return nil, fmt.Errorf("sign outgoing request: %w", err) } resp, err := c.Do(req) blob - 876dfda95b73ac0831d8bbaf14680b2993c4b797 (mode 644) blob + /dev/null --- cmd/apmail/listen.go +++ /dev/null @@ -1,235 +0,0 @@ -package main - -import ( - "encoding/json" - "fmt" - "log" - "net/http" - "net/smtp" - "os" - "os/user" - "path" - "strings" - - "olowe.co/apub" -) - -type server struct { - acceptFor []user.User - relayAddr string -} - -func (srv *server) relay(username string, activity *apub.Activity) { - var err error - switch activity.Type { - case "Note": - // check if we need to dereference - if activity.Content == "" { - activity, err = apub.Lookup(activity.ID) - if err != nil { - log.Printf("dereference %s %s: %v", activity.Type, activity.ID, err) - return - } - } - case "Page": - // check if we need to dereference - if activity.Name == "" { - activity, err = apub.Lookup(activity.ID) - if err != nil { - log.Printf("dereference %s %s: %v", activity.Type, activity.ID, err) - return - } - } - case "Create", "Update": - wrapped, err := activity.Unwrap(nil) - if err != nil { - log.Printf("unwrap from %s: %v", activity.ID, err) - return - } - srv.relay(username, wrapped) - return - default: - return - } - - if err := apub.SendMail(srv.relayAddr, nil, "nobody", []string{username}, activity); err != nil { - log.Printf("relay %s via SMTP: %v", activity.ID, err) - } -} - -func (srv *server) handleInbox(w http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodPost { - stat := http.StatusMethodNotAllowed - http.Error(w, http.StatusText(stat), stat) - return - } - if req.Header.Get("Content-Type") != apub.ContentType { - w.Header().Set("Accept", apub.ContentType) - w.WriteHeader(http.StatusUnsupportedMediaType) - return - } - // url is https://example.com/{username}/inbox - username := path.Dir(req.URL.Path) - _, err := user.Lookup(username) - if _, ok := err.(user.UnknownUserError); ok { - w.WriteHeader(http.StatusNotFound) - return - } else if err != nil { - log.Println("handle inbox:", err) - w.WriteHeader(http.StatusInternalServerError) - return - } - var accepted bool - for i := range srv.acceptFor { - if srv.acceptFor[i].Username == username { - accepted = true - } - } - if !accepted { - w.WriteHeader(http.StatusNotFound) - return - } - - defer req.Body.Close() - var rcv apub.Activity // received - if err := json.NewDecoder(req.Body).Decode(&rcv); err != nil { - log.Println("decode apub message:", err) - http.Error(w, "malformed activitypub message", http.StatusBadRequest) - return - } - activity := &rcv - if rcv.Type == "Announce" { - var err error - activity, err = rcv.Unwrap(nil) - if err != nil { - err = fmt.Errorf("unwrap apub object in %s: %w", rcv.ID, err) - log.Println(err) - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - } - raddr := req.RemoteAddr - if req.Header.Get("X-Forwarded-For") != "" { - raddr = req.Header.Get("X-Forwarded-For") - } - if activity.Type != "Like" && activity.Type != "Dislike" { - log.Printf("%s %s received from %s", activity.Type, activity.ID, raddr) - } - switch activity.Type { - case "Accept", "Reject": - w.WriteHeader(http.StatusAccepted) - return - case "Create", "Note", "Page", "Article": - w.WriteHeader(http.StatusAccepted) - log.Printf("accepted %s %s for relay to %s", activity.Type, activity.ID, username) - go srv.relay(username, activity) - return - } - w.WriteHeader(http.StatusAccepted) -} - -var home string = os.Getenv("HOME") - -func logRequest(next http.Handler) http.HandlerFunc { - return func(w http.ResponseWriter, req *http.Request) { - // skip logging from checks by load balancer - if req.URL.Path == "/" && req.Method == http.MethodHead { - next.ServeHTTP(w, req) - return - } - addr := req.RemoteAddr - if req.Header.Get("X-Forwarded-For") != "" { - addr = req.Header.Get("X-Forwarded-For") - } - log.Printf("%s %s %s", addr, req.Method, req.URL) - next.ServeHTTP(w, req) - } -} - -func serveActorFile(name string) http.HandlerFunc { - return func(w http.ResponseWriter, req *http.Request) { - w.Header().Set("Content-Type", apub.ContentType) - http.ServeFile(w, req, name) - } -} - -func serveActivityFile(hfsys http.Handler) http.HandlerFunc { - return func(w http.ResponseWriter, req *http.Request) { - w.Header().Set("Content-Type", apub.ContentType) - hfsys.ServeHTTP(w, req) - } -} - -func serveWebFingerFile(w http.ResponseWriter, req *http.Request) { - if !req.URL.Query().Has("resource") { - http.Error(w, "missing resource query parameter", http.StatusBadRequest) - return - } - q := req.URL.Query().Get("resource") - if !strings.HasPrefix(q, "acct:") { - http.Error(w, "only acct resource lookup supported", http.StatusNotImplemented) - return - } - addr := strings.TrimPrefix(q, "acct:") - username, _, ok := strings.Cut(addr, "@") - if !ok { - http.Error(w, "bad acct lookup: missing @ in address", http.StatusBadRequest) - return - } - fname, err := apub.UserWebFingerFile(username) - if _, ok := err.(user.UnknownUserError); ok { - http.Error(w, "no such user", http.StatusNotFound) - return - } else if err != nil { - log.Println(err) - stat := http.StatusInternalServerError - http.Error(w, http.StatusText(stat), stat) - return - } - w.Header().Set("Content-Type", "application/json") - http.ServeFile(w, req, fname) -} - -const usage string = "usage: apmail [address]" - -func main() { - if len(os.Args) > 2 { - log.Fatal(usage) - } - - current, err := user.Current() - if err != nil { - log.Fatalf("lookup current user: %v", err) - } - acceptFor := []user.User{*current} - - raddr := "[::1]:smtp" - if len(os.Args) == 2 { - raddr = os.Args[1] - } - sclient, err := smtp.Dial(raddr) - if err != nil { - log.Fatal(err) - } - if err := sclient.Noop(); err != nil { - log.Fatalf("check connection to %s: %v", raddr, err) - } - sclient.Quit() - sclient.Close() - - srv := &server{ - relayAddr: raddr, - acceptFor: acceptFor, - } - http.HandleFunc("/.well-known/webfinger", serveWebFingerFile) - - for _, u := range acceptFor { - dataDir := path.Join(u.HomeDir, "apubtest") - root := fmt.Sprintf("/%s/", u.Username) - inbox := path.Join(root, "inbox") - hfsys := serveActivityFile(http.FileServer(http.Dir(dataDir))) - http.Handle(root, http.StripPrefix(root, hfsys)) - http.HandleFunc(inbox, srv.handleInbox) - } - log.Fatal(http.ListenAndServe("[::1]:8082", nil)) -} blob - /dev/null blob + 5ad0de9649498f47f2554e606336495f5627d036 (mode 644) --- /dev/null +++ cmd/apsend/apsend.go @@ -0,0 +1,167 @@ +package main + +import ( + "bytes" + "encoding/json" + "flag" + "fmt" + "io" + "log" + "net/mail" + "os" + "os/user" + "path" + "strconv" + "strings" + "time" + + "olowe.co/apub" + "olowe.co/apub/internal/sys" +) + +const usage string = "apsend [-F] [-t] rcpt ..." + +// Delivers the mail message to the user's Maildir. +func deliverLocal(username string, msg []byte) error { + u, err := user.Lookup(username) + if err != nil { + return err + } + inbox := path.Join(u.HomeDir, "Maildir/new") + fname := fmt.Sprintf("%s/%d", inbox, time.Now().Unix()) + return os.WriteFile(fname, msg, 0664) +} + +func wrapCreate(activity *apub.Activity) (*apub.Activity, error) { + b, err := json.Marshal(activity) + if err != nil { + return nil, err + } + return &apub.Activity{ + AtContext: activity.AtContext, + ID: activity.ID + "-create", + Actor: activity.AttributedTo, + Type: "Create", + Published: activity.Published, + To: activity.To, + CC: activity.CC, + Object: b, + }, nil +} + +var tflag bool +var Fflag bool + +func init() { + // log.SetFlags... + flag.BoolVar(&Fflag, "F", false, "file a copy for the sender") + flag.BoolVar(&tflag, "t", false, "read recipients from message") + flag.Parse() +} + +const sysName string = "apubtest2.srcbeat.com" + +func main() { + if tflag { + log.Fatal("flag -t not implemented yet") + } + if len(flag.Args()) == 0 { + fmt.Fprintln(os.Stderr, "usage:", usage) + os.Exit(1) + } + + bmsg, err := io.ReadAll(os.Stdin) + if err != nil { + log.Fatal(err) + } + msg, err := mail.ReadMessage(bytes.NewReader(bmsg)) + if err != nil { + log.Fatal(err) + } + activity, err := apub.UnmarshalMail(msg) + if err != nil { + log.Fatalln("unmarshal activity from message:", err) + } + + var remote []string + for _, rcpt := range flag.Args() { + if !strings.Contains(rcpt, "@") { + if err := deliverLocal(rcpt, bmsg); err != nil { + log.Printf("local delivery to %s: %v", rcpt, err) + } + continue + } + remote = append(remote, rcpt) + } + + var gotErr bool + if len(remote) > 0 { + if !strings.HasPrefix(activity.AttributedTo, "https://"+sysName) { + log.Fatalln("cannot send activity from non-local actor", activity.AttributedTo) + } + + from, err := apub.LookupActor(activity.AttributedTo) + if err != nil { + log.Fatalf("lookup actor %s: %v", activity.AttributedTo, err) + } + client, err := sys.ClientFor(from.Username, sysName) + if err != nil { + log.Fatalf("apub cilent for %s: %v", from.Username, err) + } + + // overwrite auto generated ID from mail clients + if !strings.HasPrefix(activity.ID, "https://") { + activity.ID = from.Outbox + "/" + strconv.Itoa(int(activity.Published.Unix())) + bmsg, _ = apub.MarshalMail(activity) + } + + // Permit this activity for the public, too; + // let's not pretend the fediverse is not public access. + activity.To = append(activity.To, apub.PublicCollection) + create, err := wrapCreate(activity) + if err != nil { + log.Fatalf("wrap %s %s in Create activity: %v", activity.Type, activity.ID, err) + } + + // append outbound activities to the user's outbox so others can fetch it. + sysuser, err := user.Lookup(from.Username) + if err != nil { + log.Fatalf("lookup system user from %s: %v", activity.ID, err) + } + outbox := path.Join(sys.UserDataDir(sysuser), "outbox") + for _, a := range []*apub.Activity{activity, create} { + b, err := json.Marshal(a) + if err != nil { + log.Fatalf("encode %s: %v", activity.ID, err) + } + fname := path.Base(a.ID) + fname = path.Join(outbox, fname) + if err := os.WriteFile(fname, b, 0644); err != nil { + log.Fatalf("write activity to outbox: %v", err) + } + } + + for _, rcpt := range remote { + ra, err := apub.Finger(rcpt) + if err != nil { + log.Printf("webfinger %s: %v", rcpt, err) + gotErr = true + continue + } + if _, err = client.Send(ra.Inbox, create); err != nil { + log.Printf("send %s %s to %s: %v", activity.Type, activity.ID, rcpt, err) + gotErr = true + } + } + if Fflag { + if err := deliverLocal(from.Username, bmsg); err != nil { + log.Printf("file copy for %s: %v", from.Username, err) + gotErr = true + } + } + } + + if gotErr { + os.Exit(1) + } +} blob - /dev/null blob + f98f969282313d199dc35907a74d9c0b7f66367e (mode 644) --- /dev/null +++ cmd/apsend/doc.go @@ -0,0 +1,43 @@ +/* +Command apsend reads a mail message from the standard input +and disposes of it based on the provided recipient addresses. + +Its usage is: + + apsend [ -F ] [ -t ] rcpt ... + +Messages are disposed of in one of two ways: + + - If the recipient refers to a local user, the message is appended to their local mailbox. + - If the recipients refers to a remote user or collection, the message is sent via ActivityPub to each recipent's corresponding Actor inbox. + +Local recipients are addrsessed as a plain username such as "otl". +Remote recipients are addressed as an email address, +such as +"mort@novum.streats.dev" or +"otl+followers@hachyderm.io". + +apsend is not intended to be executed directly by users. +Usually it is executed as a mailer by a SMTP server like [apsubmit], +or by a server which receives ActivityPub activities for local recipients like [apserve]. + +The flags understood are: + + - *-F* File a copy to the sender's mailbox. + + - *-t* Read recipients from the To: and CC: lines of the message. + +# Example + +Given the following message in the file greeting.eml: + + From: otl@apubtest2.srcbeat.com + To: otl@hachyderm.io + + Hello, Oliver! + +Send it with the following command: + + apsend otl@hachyderm.io < greeting.eml +*/ +package main blob - /dev/null blob + 7bf7d0b29c57bd0e8278ae997f90b413dc67756f (mode 644) --- /dev/null +++ cmd/apsend/msg.go @@ -0,0 +1,25 @@ +package main + +import ( + "bytes" + "fmt" + "io" + "net/mail" + "strings" +) + +func encodMsg(msg *mail.Message) []byte { + buf := &bytes.Buffer{} + fmt.Fprintln(buf, msg.Header.Get("From")) + delete(msg.Header, "From") + for k, v := range msg.Header { + if k == "Subject" { + continue + } + fmt.Fprintf(buf, "%s: %s\n", k, strings.Join(v, ", ")) + } + fmt.Fprintln(buf, "Subject:", msg.Header.Get("Subject")) + fmt.Fprintln(buf) + io.Copy(buf, msg.Body) + return buf.Bytes() +} blob - /dev/null blob + eab3871c626f8918fd0f0d200573a4c080f291d6 (mode 644) --- /dev/null +++ cmd/apserve/listen.go @@ -0,0 +1,202 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "log" + "net/http" + "os" + "os/exec" + "os/user" + "path" + "strings" + + "olowe.co/apub" +) + +type server struct { + acceptFor []user.User + relayAddr string +} + +func (srv *server) relay(username string, activity *apub.Activity) { + var err error + switch activity.Type { + case "Note": + // check if we need to dereference + if activity.Content == "" { + activity, err = apub.Lookup(activity.ID) + if err != nil { + log.Printf("dereference %s %s: %v", activity.Type, activity.ID, err) + return + } + } + case "Page": + // check if we need to dereference + if activity.Name == "" { + activity, err = apub.Lookup(activity.ID) + if err != nil { + log.Printf("dereference %s %s: %v", activity.Type, activity.ID, err) + return + } + } + case "Create", "Update": + wrapped, err := activity.Unwrap(nil) + if err != nil { + log.Printf("unwrap from %s: %v", activity.ID, err) + return + } + srv.relay(username, wrapped) + return + default: + return + } + + cmd := exec.Command("apsend", username) + msg, err := apub.MarshalMail(activity) + if err != nil { + log.Printf("marshal %s %s to mail message: %v", activity.Type, activity.ID, err) + return + } + cmd.Stdin = bytes.NewReader(msg) + cmd.Stderr = os.Stderr + err = cmd.Run() + if err != nil { + log.Printf("execute mailer for %s: %v", activity.ID, err) + return + } +} + +func (srv *server) handleInbox(w http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodPost { + stat := http.StatusMethodNotAllowed + http.Error(w, http.StatusText(stat), stat) + return + } + if req.Header.Get("Content-Type") != apub.ContentType { + w.Header().Set("Accept", apub.ContentType) + w.WriteHeader(http.StatusUnsupportedMediaType) + return + } + // url is https://example.com/{username}/inbox + username := strings.Trim(path.Dir(req.URL.Path), "/") + _, err := user.Lookup(username) + if _, ok := err.(user.UnknownUserError); ok { + log.Println("handle inbox:", err) + w.WriteHeader(http.StatusNotFound) + return + } else if err != nil { + log.Println("handle inbox:", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + var accepted bool + for i := range srv.acceptFor { + if srv.acceptFor[i].Username == username { + accepted = true + } + } + if !accepted { + w.WriteHeader(http.StatusNotFound) + return + } + + defer req.Body.Close() + var rcv apub.Activity // received + if err := json.NewDecoder(req.Body).Decode(&rcv); err != nil { + log.Println("decode apub message:", err) + http.Error(w, "malformed activitypub message", http.StatusBadRequest) + return + } + activity := &rcv + if rcv.Type == "Announce" { + var err error + activity, err = rcv.Unwrap(nil) + if err != nil { + err = fmt.Errorf("unwrap apub object in %s: %w", rcv.ID, err) + log.Println(err) + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + } + raddr := req.RemoteAddr + if req.Header.Get("X-Forwarded-For") != "" { + raddr = req.Header.Get("X-Forwarded-For") + } + if activity.Type != "Like" && activity.Type != "Dislike" { + log.Printf("%s %s received from %s", activity.Type, activity.ID, raddr) + } + switch activity.Type { + case "Accept", "Reject": + w.WriteHeader(http.StatusAccepted) + return + case "Create", "Note", "Page", "Article": + w.WriteHeader(http.StatusAccepted) + log.Printf("accepted %s %s for %s", activity.Type, activity.ID, username) + go srv.relay(username, activity) + return + } + w.WriteHeader(http.StatusAccepted) +} + +func logRequest(next http.Handler) http.HandlerFunc { + return func(w http.ResponseWriter, req *http.Request) { + // skip logging from checks by load balancer + if req.URL.Path == "/" && req.Method == http.MethodHead { + next.ServeHTTP(w, req) + return + } + addr := req.RemoteAddr + if req.Header.Get("X-Forwarded-For") != "" { + addr = req.Header.Get("X-Forwarded-For") + } + log.Printf("%s %s %s", addr, req.Method, req.URL) + next.ServeHTTP(w, req) + } +} + +func serveActorFile(name string) http.HandlerFunc { + return func(w http.ResponseWriter, req *http.Request) { + w.Header().Set("Content-Type", apub.ContentType) + http.ServeFile(w, req, name) + } +} + +func serveActivityFile(hfsys http.Handler) http.HandlerFunc { + return func(w http.ResponseWriter, req *http.Request) { + w.Header().Set("Content-Type", apub.ContentType) + hfsys.ServeHTTP(w, req) + } +} + +const usage string = "apserve" + +const domain = "apubtest2.srcbeat.com" + +func main() { + if len(os.Args) > 1 { + log.Fatalln("usage:", usage) + } + + current, err := user.Current() + if err != nil { + log.Fatalf("lookup current user: %v", err) + } + acceptFor := []user.User{*current} + + srv := &server{ + acceptFor: acceptFor, + } + http.HandleFunc("/.well-known/webfinger", serveWebFingerFile) + + for _, u := range acceptFor { + dataDir := path.Join(u.HomeDir, "apubtest") + root := fmt.Sprintf("/%s/", u.Username) + hfsys := serveActivityFile(http.FileServer(http.Dir(dataDir))) + http.Handle(root, http.StripPrefix(root, hfsys)) + inbox := path.Join(root, "inbox") + http.HandleFunc(inbox, srv.handleInbox) + } + log.Fatal(http.ListenAndServe("[::1]:8082", nil)) +} blob - /dev/null blob + 356e4b4df27a20951d0e75d0bc7add8040b38f5c (mode 644) --- /dev/null +++ cmd/apserve/user.go @@ -0,0 +1,58 @@ +package main + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + "os/user" + "strings" + + "olowe.co/apub" + "olowe.co/apub/internal/sys" +) + +func serveActor(w http.ResponseWriter, req http.Request, username string) { + actor, err := sys.Actor(username, domain) + if err != nil { + // for security reasons we lie here; prevents user enumeration + log.Println("lookup actor:", err) + http.Error(w, "no such actor", http.StatusNotFound) + return + } + w.Header().Set("Content-Type", apub.ContentType) + if err := json.NewEncoder(w).Encode(actor); err != nil { + log.Printf("encode actor %s: %v", actor.Username, err) + } +} + +func serveWebFinger(w http.ResponseWriter, req *http.Request) { + if !req.URL.Query().Has("resource") { + http.Error(w, "missing resource query parameter", http.StatusBadRequest) + return + } + q := req.URL.Query().Get("resource") + if !strings.HasPrefix(q, "acct:") { + http.Error(w, "only acct resource lookup supported", http.StatusNotImplemented) + return + } + addr := strings.TrimPrefix(q, "acct:") + username, _, ok := strings.Cut(addr, "@") + if !ok { + http.Error(w, "bad acct lookup: missing @ in address", http.StatusBadRequest) + return + } + jrd, err := sys.JRDFor(username, domain) + if _, ok := err.(user.UnknownUserError); ok { + http.Error(w, "no such user", http.StatusNotFound) + return + } else if err != nil { + err = fmt.Errorf("webfinger jrd for %s: %v", username, err) + log.Print(err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + if err := json.NewEncoder(w).Encode(jrd); err != nil { + log.Printf("encode webfinger response: %v", err) + } +} blob - /dev/null blob + 55b792985762870e72f6b60e22f53c8748830d2f (mode 644) --- /dev/null +++ cmd/apsubmit/apsubmit.go @@ -0,0 +1,18 @@ +package main + +import ( + "log" + + "github.com/emersion/go-smtp" +) + +func main() { + srv := smtp.NewServer(&Backend{}) + srv.Addr = ":2525" + srv.Domain = "apubtest2.srcbeat.com" + srv.AllowInsecureAuth = true + + if err := srv.ListenAndServe(); err != nil { + log.Fatal(err) + } +} blob - /dev/null blob + 79f86165289e764f031e0ac473cb523d7918b0bb (mode 644) --- /dev/null +++ cmd/apsubmit/server.go @@ -0,0 +1,80 @@ +package main + +import ( + "errors" + "fmt" + "io" + "log" + "net/mail" + "os" + "os/exec" + "os/user" + + "github.com/emersion/go-smtp" + "olowe.co/apub" +) + +type Backend struct { + auth string +} + +func (be *Backend) NewSession(conn *smtp.Conn) (smtp.Session, error) { + return &Session{}, nil +} + +type Session struct { + recipients []string + User *user.User +} + +func (s *Session) AuthPlain(username, password string) error { + u, err := user.Lookup(username) + if err != nil { + return errors.New("invalid username or password") + } + // TODO allow other users except for me lol + // TODO implement BSD Auth and/or PAM? + if u.Username != "otl" { + return errors.New("invalid username or password") + } + if password != "yamum" { + return errors.New("invalid username or password") + } + s.User = u + return nil +} + +func (s *Session) Logout() error { return nil } +func (s *Session) Reset() {} + +func (s *Session) Mail(from string, opts *smtp.MailOptions) error { + log.Println("MAIL FROM:", from) + return nil +} + +func (s *Session) Rcpt(to string, opts *smtp.RcptOptions) error { + log.Println("RCPT TO:", to) + addr, err := mail.ParseAddress(to) + if err != nil { + return err + } + if _, err = apub.Finger(addr.Address); err != nil { + return err + } + s.recipients = append(s.recipients, to) + return nil +} + +func (s *Session) Data(r io.Reader) error { + args := append([]string{"-F"}, s.recipients...) + cmd := exec.Command("apsend", args...) + cmd.Stdin = r + cmd.Stderr = os.Stderr + err := cmd.Run() + if err1, ok := err.(*exec.ExitError); ok { + return fmt.Errorf("execute mailer: %v", string(err1.Stderr)) + } else if err != nil { + return fmt.Errorf("execute mailer: %v", err) + } + return nil +} blob - afc05ba205700e52300473ed8e2a3a1b3ed6faef blob + 1e25afd5c580e5a4af28ac994ad0df2b418c244e --- mail.go +++ mail.go @@ -91,15 +91,19 @@ func UnmarshalMail(msg *mail.Message) (*Activity, erro return nil, fmt.Errorf("webfinger From: %w", err) } - to, err := msg.Header.AddressList("To") - if err != nil { - return nil, fmt.Errorf("parse To address list: %w", err) + var wto, wcc []string + if msg.Header.Get("To") != "" { + to, err := msg.Header.AddressList("To") + // ignore missing To line. Some ActivityPub servers only have the + // PublicCollection listed, which we don't care about. + if err != nil { + return nil, fmt.Errorf("parse To address list: %w", err) + } + wto, err = fingerAll(to) + if err != nil { + return nil, fmt.Errorf("webfinger To addresses: %w", err) + } } - wto, err := fingerAll(to) - if err != nil { - return nil, fmt.Errorf("webfinger To addresses: %w", err) - } - var wcc []string if msg.Header.Get("CC") != "" { cc, err := msg.Header.AddressList("CC") if err != nil { blob - /dev/null blob + 8aa85921ec7f83e153d9158bee7c546bc60713f2 (mode 644) --- /dev/null +++ internal/sys/user.go @@ -0,0 +1,120 @@ +package sys + +import ( + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "fmt" + "net/http" + "net/url" + "os" + "os/user" + "path" + + "olowe.co/apub" + "webfinger.net/go/webfinger" +) + +func UserDataDir(u *user.User) string { + return path.Join(u.HomeDir, "apubtest") +} + +func ConfigDir(u *user.User) (string, error) { + paths := []string{ + path.Join(u.HomeDir, ".config/apubtest"), // Unix-like + path.Join(u.HomeDir, "Application Support/apubtest"), // macOS + path.Join(u.HomeDir, "lib/apubtest"), // Plan 9 + } + for i := range paths { + if _, err := os.Stat(paths[i]); err == nil { + return paths[i], nil + } + } + return "", fmt.Errorf("no apubtest dir") +} + +func Actor(name, host string) (*apub.Actor, error) { + u, err := user.Lookup(name) + if err != nil { + return nil, err + } + uri, err := url.Parse("https://" + host) + if err != nil { + return nil, fmt.Errorf("bad host: %w", err) + } + uri.Path = path.Join("/", u.Username) + root := uri.String() + + cdir, err := ConfigDir(u) + if err != nil { + return nil, fmt.Errorf("find config directory: %w", err) + } + pubkey, err := os.ReadFile(path.Join(cdir, "public.pem")) + if err != nil { + return nil, fmt.Errorf("read public key file: %w", err) + } + return &apub.Actor{ + AtContext: apub.AtContext, + ID: root + "/actor.json", + Type: "Person", + Name: u.Name, + Username: u.Username, + Inbox: root + "/inbox", + Outbox: root + "/outbox", + PublicKey: apub.PublicKey{ + ID: root + "/actor.json#main-key", + Owner: root + "/actor.json", + PublicKeyPEM: string(pubkey), + }, + }, nil +} + +func ClientFor(username, host string) (*apub.Client, error) { + sysuser, err := user.Lookup(username) + if err != nil { + return nil, err + } + actor, err := Actor(sysuser.Username, host) + if err != nil { + return nil, fmt.Errorf("load system actor: %w", err) + } + cdir, err := ConfigDir(sysuser) + if err != nil { + return nil, fmt.Errorf("find config dir: %w", err) + } + + key, err := loadKey(path.Join(cdir, "private.pem")) + if err != nil { + return nil, fmt.Errorf("load private key: %w", err) + } + return &apub.Client{ + Client: http.DefaultClient, + Key: key, + PubKeyID: actor.PublicKey.ID, + }, nil +} + +func loadKey(name string) (*rsa.PrivateKey, error) { + b, err := os.ReadFile(name) + if err != nil { + return nil, err + } + block, _ := pem.Decode(b) + return x509.ParsePKCS1PrivateKey(block.Bytes) +} + +func JRDFor(username, domain string) (*webfinger.JRD, error) { + if _, err := user.Lookup(username); err != nil { + return nil, err + } + return &webfinger.JRD{ + Subject: fmt.Sprintf("acct:%s@%s", username, domain), + Links: []webfinger.Link{ + webfinger.Link{ + Rel: "self", + Type: apub.ContentType, + Href: fmt.Sprintf("https://%s/%s/actor.json", domain, username), + }, + }, + }, nil +} blob - /dev/null blob + d1361750b4e59aef58727bc664032877b9cc84e5 (mode 644) --- /dev/null +++ internal/sys/user_test.go @@ -0,0 +1,16 @@ +package sys + +import "testing" + +func TestJRD(t *testing.T) { + jrd, err := JRDFor("nobody", "example.com") + if err != nil { + t.Fatal(err) + } + if jrd.Subject != "acct:nobody@example.com" { + t.Errorf("unexpected subject %s", jrd.Subject) + } + if jrd.Links[0].Href != "https://example.com/nobody/actor.json" { + t.Errorf("unexpected href %s", jrd.Links[0].Href) + } +}