Skip to content

Commit 8abc0e1

Browse files
committed
invites: add invite links for automatic peer acceptance
An invite link in format `awl://invite?p=<peer_id>&t=<token>&n=<name>`. Whoever presents the token is added without a manual accept, with the alias and exit node permission the creator chose when making the link. Links are single-use and expire in a day by default, and can be revoked; the token is optional, and without it the link is just the shareable form of a peer id (that is what `awl cli me id` now prints and puts in its QR). The invite itself lives in the creator's config, so nothing but the token and a display name travels in the link, revoking really closes it, and the receiver can add the creator while it is still offline. A use is spent inside the same critical section that writes the peer it pays for, so a spent use and an added peer can never come apart — hence ReserveInviteUnlocked, called from AddPeer and from the UpdatePeerFields mutator rather than reserved and rolled back. A token that no longer works degrades into an ordinary auth request for a manual accept. Blocking still outranks an invite. Adds config.Invite + Config.Invites, KnownPeer.InviteID (creator's marker) and KnownPeer.PendingInviteToken (receiver's secret, presented until confirmed and restored after a restart), the Go link parser in entity/invite_link.go, the peers/invites/{create,list,revoke} endpoints, awlevent.InviteRedeemed and two metrics. CLI and GUI come separately.
1 parent 9b69065 commit 8abc0e1

21 files changed

Lines changed: 2276 additions & 33 deletions

api/api.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,9 @@ func (h *Handler) setupRouter(address string) (*echo.Echo, error) {
150150
e.POST(RemovePeerSettingsPath, h.RemovePeer)
151151
e.GET(GetAuthRequestsPath, h.GetAuthRequests)
152152
e.GET(GetBlockedPeersPath, h.GetBlockedPeers)
153+
e.POST(CreateInvitePath, h.CreateInvite)
154+
e.GET(GetInvitesPath, h.GetInvites)
155+
e.POST(RevokeInvitePath, h.RevokeInvite)
153156

154157
// Settings
155158
e.GET(GetMyPeerInfoPath, h.GetMyPeerInfo)

api/apiclient/client.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,29 @@ func (c *Client) AuthRequests() ([]entity.AuthRequest, error) {
116116
return authRequests, nil
117117
}
118118

119+
func (c *Client) CreateInvite(request entity.CreateInviteRequest) (*entity.InviteResponse, error) {
120+
invite := new(entity.InviteResponse)
121+
err := c.sendPostRequest(api.CreateInvitePath, request, invite)
122+
if err != nil {
123+
return nil, err
124+
}
125+
return invite, nil
126+
}
127+
128+
func (c *Client) Invites() ([]entity.InviteResponse, error) {
129+
invites := make([]entity.InviteResponse, 0)
130+
err := c.sendGetRequest(api.GetInvitesPath, &invites)
131+
if err != nil {
132+
return nil, err
133+
}
134+
return invites, nil
135+
}
136+
137+
func (c *Client) RevokeInvite(id string) error {
138+
request := entity.RevokeInviteRequest{ID: id}
139+
return c.sendPostRequest(api.RevokeInvitePath, request, nil)
140+
}
141+
119142
func (c *Client) BlockedPeers() ([]config.BlockedPeer, error) {
120143
blocked := make([]config.BlockedPeer, 0)
121144
err := c.sendGetRequest(api.GetBlockedPeersPath, &blocked)

api/const.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@ const (
1515
AcceptPeerInvitationPath = V0Prefix + "peers/accept_peer"
1616
GetAuthRequestsPath = V0Prefix + "peers/auth_requests"
1717

18+
// Invite links
19+
CreateInvitePath = V0Prefix + "peers/invites/create"
20+
GetInvitesPath = V0Prefix + "peers/invites/list"
21+
RevokeInvitePath = V0Prefix + "peers/invites/revoke"
22+
1823
// Settings
1924
GetMyPeerInfoPath = V0Prefix + "settings/peer_info"
2025
UpdateMyInfoPath = V0Prefix + "settings/update"

api/invites.go

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
package api
2+
3+
import (
4+
"net/http"
5+
"strings"
6+
"time"
7+
8+
"github.com/labstack/echo/v4"
9+
10+
"github.com/anywherelan/awl/config"
11+
"github.com/anywherelan/awl/entity"
12+
)
13+
14+
const defaultInviteMaxUses = 1
15+
16+
// @Tags Peers
17+
// @Summary Create an invite link
18+
// @Accept json
19+
// @Produce json
20+
// @Param body body entity.CreateInviteRequest true "Params"
21+
// @Success 200 {object} entity.InviteResponse
22+
// @Failure 400 {object} api.Error
23+
// @Failure 500 {object} api.Error
24+
// @Router /peers/invites/create [POST]
25+
func (h *Handler) CreateInvite(c echo.Context) (err error) {
26+
req := entity.CreateInviteRequest{}
27+
err = c.Bind(&req)
28+
if err != nil {
29+
return c.JSON(http.StatusBadRequest, ErrorMessage(err.Error()))
30+
}
31+
if err = c.Validate(req); err != nil {
32+
return c.JSON(http.StatusBadRequest, ErrorMessage(err.Error()))
33+
}
34+
35+
if req.MaxUses == 0 {
36+
req.MaxUses = defaultInviteMaxUses
37+
}
38+
req.Alias = strings.TrimSpace(req.Alias)
39+
// One alias cannot name several peers, so it only makes sense for a
40+
// single-use link. Uniqueness itself is not checked here: the peer list will
41+
// have moved on by the time the link is redeemed, and a collision is
42+
// resolved then (GenUniqPeerAlias), so checking now would only produce
43+
// false rejections.
44+
if req.Alias != "" && req.MaxUses != 1 {
45+
return c.JSON(http.StatusBadRequest,
46+
ErrorMessage("alias can only be set for a single-use invite"))
47+
}
48+
49+
var expiresAt time.Time
50+
if req.ExpiresInSeconds > 0 {
51+
expiresAt = time.Now().Add(time.Duration(req.ExpiresInSeconds) * time.Second)
52+
}
53+
54+
invite, err := h.conf.CreateInvite(config.CreateInviteParams{
55+
Label: req.Label,
56+
Alias: req.Alias,
57+
AllowUsingAsExitNode: req.AllowUsingAsExitNode,
58+
MaxUses: req.MaxUses,
59+
ExpiresAt: expiresAt,
60+
})
61+
if err != nil {
62+
return c.JSON(http.StatusInternalServerError, ErrorMessage(err.Error()))
63+
}
64+
65+
return c.JSON(http.StatusOK, h.inviteResponse(invite))
66+
}
67+
68+
// @Tags Peers
69+
// @Summary Get invite links
70+
// @Accept json
71+
// @Produce json
72+
// @Success 200 {array} entity.InviteResponse
73+
// @Router /peers/invites/list [GET]
74+
func (h *Handler) GetInvites(c echo.Context) (err error) {
75+
invites := h.conf.ListInvites()
76+
77+
result := make([]entity.InviteResponse, 0, len(invites))
78+
for _, invite := range invites {
79+
result = append(result, h.inviteResponse(invite))
80+
}
81+
82+
return c.JSON(http.StatusOK, result)
83+
}
84+
85+
// @Tags Peers
86+
// @Summary Revoke an invite link
87+
// @Description Stops new connections through the link; peers already added stay.
88+
// @Accept json
89+
// @Produce json
90+
// @Param body body entity.RevokeInviteRequest true "Params"
91+
// @Success 200 "OK"
92+
// @Failure 400 {object} api.Error
93+
// @Failure 404 {object} api.Error
94+
// @Router /peers/invites/revoke [POST]
95+
func (h *Handler) RevokeInvite(c echo.Context) (err error) {
96+
req := entity.RevokeInviteRequest{}
97+
err = c.Bind(&req)
98+
if err != nil {
99+
return c.JSON(http.StatusBadRequest, ErrorMessage(err.Error()))
100+
}
101+
if err = c.Validate(req); err != nil {
102+
return c.JSON(http.StatusBadRequest, ErrorMessage(err.Error()))
103+
}
104+
105+
if !h.conf.RevokeInvite(req.ID) {
106+
return c.JSON(http.StatusNotFound, ErrorMessage("invite not found"))
107+
}
108+
109+
return c.NoContent(http.StatusOK)
110+
}
111+
112+
// inviteResponse renders an invite for the API, building the link from our
113+
// current peer ID and node name.
114+
func (h *Handler) inviteResponse(invite config.Invite) entity.InviteResponse {
115+
h.conf.RLock()
116+
peerID := h.conf.P2pNode.PeerID
117+
nodeName := h.conf.P2pNode.Name
118+
h.conf.RUnlock()
119+
120+
return entity.InviteResponse{
121+
ID: invite.ID,
122+
Label: invite.Label,
123+
Link: entity.BuildInviteLink(peerID, invite.Token, nodeName),
124+
Alias: invite.Alias,
125+
AllowUsingAsExitNode: invite.WeAllowUsingAsExitNode,
126+
MaxUses: invite.MaxUses,
127+
UsedCount: invite.UsedCount,
128+
ExpiresAt: invite.ExpiresAt,
129+
CreatedAt: invite.CreatedAt,
130+
Revoked: invite.Revoked,
131+
Status: inviteStatus(invite),
132+
}
133+
}
134+
135+
func inviteStatus(invite config.Invite) string {
136+
switch {
137+
case invite.Revoked:
138+
return entity.InviteStatusRevoked
139+
case invite.IsExpired(time.Now()):
140+
return entity.InviteStatusExpired
141+
case invite.IsUsedUp():
142+
return entity.InviteStatusUsedUp
143+
}
144+
return entity.InviteStatusActive
145+
}

api/peers.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ func (h *Handler) getKnownPeers() []entity.KnownPeersResponse {
5757
WeAllowUsingAsExitNode: knownPeer.WeAllowUsingAsExitNode,
5858
AllowedUsingAsExitNode: knownPeer.AllowedUsingAsExitNode,
5959
RemoteVPNGatewayServerEnabled: knownPeer.RemoteVPNGatewayServerEnabled,
60+
InviteID: knownPeer.InviteID,
6061
LastSeen: knownPeer.LastSeen,
6162
Connections: h.p2p.PeerConnectionsInfo(id),
6263
NetworkStats: netStats,
@@ -188,6 +189,7 @@ func (h *Handler) SendFriendRequest(c echo.Context) (err error) {
188189
Alias: req.Alias,
189190
IPAddr: req.IPAddr,
190191
AllowUsingAsExitNode: req.AllowUsingAsExitNode,
192+
PendingInviteToken: req.Token,
191193
})
192194
if err != nil {
193195
return c.JSON(http.StatusBadRequest, ErrorMessage(err.Error()))

api_handlers_test.go

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"net/http"
77
"strings"
88
"testing"
9+
"time"
910

1011
"github.com/anywherelan/awl/api"
1112
"github.com/anywherelan/awl/config"
@@ -130,6 +131,89 @@ func TestAcceptFriend_ErrorCases(t *testing.T) {
130131
})
131132
}
132133

134+
// TestInvitesAPI covers the invite link lifecycle through the API alone:
135+
// creating a link, seeing it in the list and revoking it. Redeeming one takes
136+
// two nodes and lives in application_test.go (TestAddPeerViaInviteLink and
137+
// around it).
138+
func TestInvitesAPI(t *testing.T) {
139+
ts := NewTestSuite(t)
140+
141+
peer1 := ts.NewTestPeer(false)
142+
err := peer1.api.UpdateMySettings("alice")
143+
ts.NoError(err)
144+
145+
invites, err := peer1.api.Invites()
146+
ts.NoError(err)
147+
ts.Len(invites, 0)
148+
149+
created, err := peer1.api.CreateInvite(entity.CreateInviteRequest{
150+
Label: "my laptop",
151+
Alias: "laptop",
152+
AllowUsingAsExitNode: true,
153+
ExpiresInSeconds: int64((24 * time.Hour).Seconds()),
154+
})
155+
ts.NoError(err)
156+
ts.Equal(1, created.MaxUses, "MaxUses defaults to single-use")
157+
ts.Equal(0, created.UsedCount)
158+
ts.Equal(entity.InviteStatusActive, created.Status)
159+
ts.True(created.AllowUsingAsExitNode)
160+
ts.False(created.Revoked)
161+
ts.WithinDuration(time.Now().Add(24*time.Hour), created.ExpiresAt, time.Minute)
162+
163+
link, err := entity.ParseInviteLink(created.Link)
164+
ts.NoError(err)
165+
ts.Equal(peer1.PeerID(), link.PeerID)
166+
ts.Equal("alice", link.Name)
167+
ts.NotEmpty(link.Token)
168+
169+
// The token only ever leaves through the link, never as a field of its own.
170+
withoutLink := *created
171+
withoutLink.Link = ""
172+
body, err := json.Marshal(withoutLink)
173+
ts.NoError(err)
174+
ts.NotContains(string(body), link.Token)
175+
176+
invites, err = peer1.api.Invites()
177+
ts.NoError(err)
178+
ts.Len(invites, 1)
179+
ts.Equal(*created, invites[0])
180+
181+
err = peer1.api.RevokeInvite(created.ID)
182+
ts.NoError(err)
183+
184+
invites, err = peer1.api.Invites()
185+
ts.NoError(err)
186+
ts.Len(invites, 1)
187+
ts.True(invites[0].Revoked, "revoked invites stay in the list as history")
188+
ts.Equal(entity.InviteStatusRevoked, invites[0].Status)
189+
190+
err = peer1.api.RevokeInvite("00000000")
191+
ts.Error(err)
192+
ts.ErrorContains(err, "invite not found")
193+
}
194+
195+
func TestCreateInvite_ErrorCases(t *testing.T) {
196+
ts := NewTestSuite(t)
197+
198+
peer1 := ts.NewTestPeer(false)
199+
200+
t.Run("AliasWithMultiUse", func(t *testing.T) {
201+
_, err := peer1.api.CreateInvite(entity.CreateInviteRequest{MaxUses: 5, Alias: "laptop"})
202+
ts.Error(err)
203+
ts.ErrorContains(err, "alias can only be set for a single-use invite")
204+
})
205+
206+
t.Run("TooManyUses", func(t *testing.T) {
207+
_, err := peer1.api.CreateInvite(entity.CreateInviteRequest{MaxUses: 1000})
208+
ts.Error(err)
209+
})
210+
211+
t.Run("NegativeExpiry", func(t *testing.T) {
212+
_, err := peer1.api.CreateInvite(entity.CreateInviteRequest{ExpiresInSeconds: -1})
213+
ts.Error(err)
214+
})
215+
}
216+
133217
func TestRemovePeer_PeerNotFound(t *testing.T) {
134218
ts := NewTestSuite(t)
135219

0 commit comments

Comments
 (0)