| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479 |
- // Copyright (c) Tailscale Inc & AUTHORS
- // SPDX-License-Identifier: BSD-3-Clause
- package web
- import (
- "bytes"
- "context"
- "encoding/json"
- "errors"
- "fmt"
- "io"
- "net/http"
- "net/http/httptest"
- "net/netip"
- "net/url"
- "slices"
- "strings"
- "testing"
- "time"
- "github.com/google/go-cmp/cmp"
- "tailscale.com/client/tailscale"
- "tailscale.com/client/tailscale/apitype"
- "tailscale.com/ipn"
- "tailscale.com/ipn/ipnstate"
- "tailscale.com/net/memnet"
- "tailscale.com/tailcfg"
- "tailscale.com/types/views"
- "tailscale.com/util/httpm"
- )
- func TestQnapAuthnURL(t *testing.T) {
- query := url.Values{
- "qtoken": []string{"token"},
- }
- tests := []struct {
- name string
- in string
- want string
- }{
- {
- name: "localhost http",
- in: "http://localhost:8088/",
- want: "http://localhost:8088/cgi-bin/authLogin.cgi?qtoken=token",
- },
- {
- name: "localhost https",
- in: "https://localhost:5000/",
- want: "https://localhost:5000/cgi-bin/authLogin.cgi?qtoken=token",
- },
- {
- name: "IP http",
- in: "http://10.1.20.4:80/",
- want: "http://10.1.20.4:80/cgi-bin/authLogin.cgi?qtoken=token",
- },
- {
- name: "IP6 https",
- in: "https://[ff7d:0:1:2::1]/",
- want: "https://[ff7d:0:1:2::1]/cgi-bin/authLogin.cgi?qtoken=token",
- },
- {
- name: "hostname https",
- in: "https://qnap.example.com/",
- want: "https://qnap.example.com/cgi-bin/authLogin.cgi?qtoken=token",
- },
- {
- name: "invalid URL",
- in: "This is not a URL, it is a really really really really really really really really really really really really long string to exercise the URL truncation code in the error path.",
- want: "http://localhost/cgi-bin/authLogin.cgi?qtoken=token",
- },
- {
- name: "err != nil",
- in: "http://192.168.0.%31/",
- want: "http://localhost/cgi-bin/authLogin.cgi?qtoken=token",
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- u := qnapAuthnURL(tt.in, query)
- if u != tt.want {
- t.Errorf("expected url: %q, got: %q", tt.want, u)
- }
- })
- }
- }
- // TestServeAPI tests the web client api's handling of
- // 1. invalid endpoint errors
- // 2. permissioning of api endpoints based on node capabilities
- func TestServeAPI(t *testing.T) {
- selfTags := views.SliceOf([]string{"tag:server"})
- self := &ipnstate.PeerStatus{ID: "self", Tags: &selfTags}
- prefs := &ipn.Prefs{}
- remoteUser := &tailcfg.UserProfile{ID: tailcfg.UserID(1)}
- remoteIPWithAllCapabilities := "100.100.100.101"
- remoteIPWithNoCapabilities := "100.100.100.102"
- lal := memnet.Listen("local-tailscaled.sock:80")
- defer lal.Close()
- localapi := mockLocalAPI(t,
- map[string]*apitype.WhoIsResponse{
- remoteIPWithAllCapabilities: {
- Node: &tailcfg.Node{StableID: "node1"},
- UserProfile: remoteUser,
- CapMap: tailcfg.PeerCapMap{tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{"{\"canEdit\":[\"*\"]}"}},
- },
- remoteIPWithNoCapabilities: {
- Node: &tailcfg.Node{StableID: "node2"},
- UserProfile: remoteUser,
- },
- },
- func() *ipnstate.PeerStatus { return self },
- func() *ipn.Prefs { return prefs },
- nil,
- )
- defer localapi.Close()
- go localapi.Serve(lal)
- s := &Server{
- mode: ManageServerMode,
- lc: &tailscale.LocalClient{Dial: lal.Dial},
- timeNow: time.Now,
- }
- type requestTest struct {
- remoteIP string
- wantResponse string
- wantStatus int
- }
- tests := []struct {
- reqPath string
- reqMethod string
- reqContentType string
- reqBody string
- tests []requestTest
- }{{
- reqPath: "/not-an-endpoint",
- reqMethod: httpm.POST,
- tests: []requestTest{{
- remoteIP: remoteIPWithNoCapabilities,
- wantResponse: "invalid endpoint",
- wantStatus: http.StatusNotFound,
- }, {
- remoteIP: remoteIPWithAllCapabilities,
- wantResponse: "invalid endpoint",
- wantStatus: http.StatusNotFound,
- }},
- }, {
- reqPath: "/local/v0/not-an-endpoint",
- reqMethod: httpm.POST,
- tests: []requestTest{{
- remoteIP: remoteIPWithNoCapabilities,
- wantResponse: "invalid endpoint",
- wantStatus: http.StatusNotFound,
- }, {
- remoteIP: remoteIPWithAllCapabilities,
- wantResponse: "invalid endpoint",
- wantStatus: http.StatusNotFound,
- }},
- }, {
- reqPath: "/local/v0/logout",
- reqMethod: httpm.POST,
- tests: []requestTest{{
- remoteIP: remoteIPWithNoCapabilities,
- wantResponse: "not allowed", // requesting node has insufficient permissions
- wantStatus: http.StatusUnauthorized,
- }, {
- remoteIP: remoteIPWithAllCapabilities,
- wantResponse: "success", // requesting node has sufficient permissions
- wantStatus: http.StatusOK,
- }},
- }, {
- reqPath: "/exit-nodes",
- reqMethod: httpm.GET,
- tests: []requestTest{{
- remoteIP: remoteIPWithNoCapabilities,
- wantResponse: "null",
- wantStatus: http.StatusOK, // allowed, no additional capabilities required
- }, {
- remoteIP: remoteIPWithAllCapabilities,
- wantResponse: "null",
- wantStatus: http.StatusOK,
- }},
- }, {
- reqPath: "/routes",
- reqMethod: httpm.POST,
- reqBody: "{\"setExitNode\":true}",
- tests: []requestTest{{
- remoteIP: remoteIPWithNoCapabilities,
- wantResponse: "not allowed",
- wantStatus: http.StatusUnauthorized,
- }, {
- remoteIP: remoteIPWithAllCapabilities,
- wantStatus: http.StatusOK,
- }},
- }, {
- reqPath: "/local/v0/prefs",
- reqMethod: httpm.PATCH,
- reqBody: "{\"runSSHSet\":true}",
- reqContentType: "application/json",
- tests: []requestTest{{
- remoteIP: remoteIPWithNoCapabilities,
- wantResponse: "not allowed",
- wantStatus: http.StatusUnauthorized,
- }, {
- remoteIP: remoteIPWithAllCapabilities,
- wantStatus: http.StatusOK,
- }},
- }, {
- reqPath: "/local/v0/prefs",
- reqMethod: httpm.PATCH,
- reqContentType: "multipart/form-data",
- tests: []requestTest{{
- remoteIP: remoteIPWithNoCapabilities,
- wantResponse: "invalid request",
- wantStatus: http.StatusBadRequest,
- }, {
- remoteIP: remoteIPWithAllCapabilities,
- wantResponse: "invalid request",
- wantStatus: http.StatusBadRequest,
- }},
- }}
- for _, tt := range tests {
- for _, req := range tt.tests {
- t.Run(req.remoteIP+"_requesting_"+tt.reqPath, func(t *testing.T) {
- var reqBody io.Reader
- if tt.reqBody != "" {
- reqBody = bytes.NewBuffer([]byte(tt.reqBody))
- }
- r := httptest.NewRequest(tt.reqMethod, "/api"+tt.reqPath, reqBody)
- r.RemoteAddr = req.remoteIP
- if tt.reqContentType != "" {
- r.Header.Add("Content-Type", tt.reqContentType)
- }
- w := httptest.NewRecorder()
- s.serveAPI(w, r)
- res := w.Result()
- defer res.Body.Close()
- if gotStatus := res.StatusCode; req.wantStatus != gotStatus {
- t.Errorf("wrong status; want=%v, got=%v", req.wantStatus, gotStatus)
- }
- body, err := io.ReadAll(res.Body)
- if err != nil {
- t.Fatal(err)
- }
- gotResp := strings.TrimSuffix(string(body), "\n") // trim trailing newline
- if req.wantResponse != gotResp {
- t.Errorf("wrong response; want=%q, got=%q", req.wantResponse, gotResp)
- }
- })
- }
- }
- }
- func TestGetTailscaleBrowserSession(t *testing.T) {
- userA := &tailcfg.UserProfile{ID: tailcfg.UserID(1)}
- userB := &tailcfg.UserProfile{ID: tailcfg.UserID(2)}
- userANodeIP := "100.100.100.101"
- userBNodeIP := "100.100.100.102"
- taggedNodeIP := "100.100.100.103"
- var selfNode *ipnstate.PeerStatus
- tags := views.SliceOf([]string{"tag:server"})
- tailnetNodes := map[string]*apitype.WhoIsResponse{
- userANodeIP: {
- Node: &tailcfg.Node{ID: 1, StableID: "1"},
- UserProfile: userA,
- },
- userBNodeIP: {
- Node: &tailcfg.Node{ID: 2, StableID: "2"},
- UserProfile: userB,
- },
- taggedNodeIP: {
- Node: &tailcfg.Node{ID: 3, StableID: "3", Tags: tags.AsSlice()},
- },
- }
- lal := memnet.Listen("local-tailscaled.sock:80")
- defer lal.Close()
- localapi := mockLocalAPI(t, tailnetNodes, func() *ipnstate.PeerStatus { return selfNode }, nil, nil)
- defer localapi.Close()
- go localapi.Serve(lal)
- s := &Server{
- timeNow: time.Now,
- lc: &tailscale.LocalClient{Dial: lal.Dial},
- }
- // Add some browser sessions to cache state.
- userASession := &browserSession{
- ID: "cookie1",
- SrcNode: 1,
- SrcUser: userA.ID,
- Created: time.Now(),
- Authenticated: false, // not yet authenticated
- }
- userBSession := &browserSession{
- ID: "cookie2",
- SrcNode: 2,
- SrcUser: userB.ID,
- Created: time.Now().Add(-2 * sessionCookieExpiry),
- Authenticated: true, // expired
- }
- userASessionAuthorized := &browserSession{
- ID: "cookie3",
- SrcNode: 1,
- SrcUser: userA.ID,
- Created: time.Now(),
- Authenticated: true, // authenticated and not expired
- }
- s.browserSessions.Store(userASession.ID, userASession)
- s.browserSessions.Store(userBSession.ID, userBSession)
- s.browserSessions.Store(userASessionAuthorized.ID, userASessionAuthorized)
- tests := []struct {
- name string
- selfNode *ipnstate.PeerStatus
- remoteAddr string
- cookie string
- wantSession *browserSession
- wantError error
- wantIsAuthorized bool // response from session.isAuthorized
- }{
- {
- name: "not-connected-over-tailscale",
- selfNode: &ipnstate.PeerStatus{ID: "self", UserID: userA.ID},
- remoteAddr: "77.77.77.77",
- wantSession: nil,
- wantError: errNotUsingTailscale,
- },
- {
- name: "no-session-user-self-node",
- selfNode: &ipnstate.PeerStatus{ID: "self", UserID: userA.ID},
- remoteAddr: userANodeIP,
- cookie: "not-a-cookie",
- wantSession: nil,
- wantError: errNoSession,
- },
- {
- name: "no-session-tagged-self-node",
- selfNode: &ipnstate.PeerStatus{ID: "self", Tags: &tags},
- remoteAddr: userANodeIP,
- wantSession: nil,
- wantError: errNoSession,
- },
- {
- name: "not-owner",
- selfNode: &ipnstate.PeerStatus{ID: "self", UserID: userA.ID},
- remoteAddr: userBNodeIP,
- wantSession: nil,
- wantError: errNotOwner,
- },
- {
- name: "tagged-remote-source",
- selfNode: &ipnstate.PeerStatus{ID: "self", UserID: userA.ID},
- remoteAddr: taggedNodeIP,
- wantSession: nil,
- wantError: errTaggedRemoteSource,
- },
- {
- name: "tagged-local-source",
- selfNode: &ipnstate.PeerStatus{ID: "3"},
- remoteAddr: taggedNodeIP, // same node as selfNode
- wantSession: nil,
- wantError: errTaggedLocalSource,
- },
- {
- name: "not-tagged-local-source",
- selfNode: &ipnstate.PeerStatus{ID: "1", UserID: userA.ID},
- remoteAddr: userANodeIP, // same node as selfNode
- cookie: userASession.ID,
- wantSession: userASession,
- wantError: nil, // should not error
- },
- {
- name: "has-session",
- selfNode: &ipnstate.PeerStatus{ID: "self", UserID: userA.ID},
- remoteAddr: userANodeIP,
- cookie: userASession.ID,
- wantSession: userASession,
- wantError: nil,
- },
- {
- name: "has-authorized-session",
- selfNode: &ipnstate.PeerStatus{ID: "self", UserID: userA.ID},
- remoteAddr: userANodeIP,
- cookie: userASessionAuthorized.ID,
- wantSession: userASessionAuthorized,
- wantError: nil,
- wantIsAuthorized: true,
- },
- {
- name: "session-associated-with-different-source",
- selfNode: &ipnstate.PeerStatus{ID: "self", UserID: userB.ID},
- remoteAddr: userBNodeIP,
- cookie: userASession.ID,
- wantSession: nil,
- wantError: errNoSession,
- },
- {
- name: "session-expired",
- selfNode: &ipnstate.PeerStatus{ID: "self", UserID: userB.ID},
- remoteAddr: userBNodeIP,
- cookie: userBSession.ID,
- wantSession: nil,
- wantError: errNoSession,
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- selfNode = tt.selfNode
- r := &http.Request{RemoteAddr: tt.remoteAddr, Header: http.Header{}}
- if tt.cookie != "" {
- r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: tt.cookie})
- }
- session, _, _, err := s.getSession(r)
- if !errors.Is(err, tt.wantError) {
- t.Errorf("wrong error; want=%v, got=%v", tt.wantError, err)
- }
- if diff := cmp.Diff(session, tt.wantSession); diff != "" {
- t.Errorf("wrong session; (-got+want):%v", diff)
- }
- if gotIsAuthorized := session.isAuthorized(s.timeNow()); gotIsAuthorized != tt.wantIsAuthorized {
- t.Errorf("wrong isAuthorized; want=%v, got=%v", tt.wantIsAuthorized, gotIsAuthorized)
- }
- })
- }
- }
- // TestAuthorizeRequest tests the s.authorizeRequest function.
- // 2023-10-18: These tests currently cover tailscale auth mode (not platform auth).
- func TestAuthorizeRequest(t *testing.T) {
- // Create self and remoteNode owned by same user.
- // See TestGetTailscaleBrowserSession for tests of
- // browser sessions w/ different users.
- user := &tailcfg.UserProfile{ID: tailcfg.UserID(1)}
- self := &ipnstate.PeerStatus{ID: "self", UserID: user.ID}
- remoteNode := &apitype.WhoIsResponse{Node: &tailcfg.Node{StableID: "node"}, UserProfile: user}
- remoteIP := "100.100.100.101"
- lal := memnet.Listen("local-tailscaled.sock:80")
- defer lal.Close()
- localapi := mockLocalAPI(t,
- map[string]*apitype.WhoIsResponse{remoteIP: remoteNode},
- func() *ipnstate.PeerStatus { return self },
- nil,
- nil,
- )
- defer localapi.Close()
- go localapi.Serve(lal)
- s := &Server{
- mode: ManageServerMode,
- lc: &tailscale.LocalClient{Dial: lal.Dial},
- timeNow: time.Now,
- }
- validCookie := "ts-cookie"
- s.browserSessions.Store(validCookie, &browserSession{
- ID: validCookie,
- SrcNode: remoteNode.Node.ID,
- SrcUser: user.ID,
- Created: time.Now(),
- Authenticated: true,
- })
- tests := []struct {
- reqPath string
- reqMethod string
- wantOkNotOverTailscale bool // simulates req over public internet
- wantOkWithoutSession bool // simulates req over TS without valid browser session
- wantOkWithSession bool // simulates req over TS with valid browser session
- }{{
- reqPath: "/api/data",
- reqMethod: httpm.GET,
- wantOkNotOverTailscale: false,
- wantOkWithoutSession: true,
- wantOkWithSession: true,
- }, {
- reqPath: "/api/data",
- reqMethod: httpm.POST,
- wantOkNotOverTailscale: false,
- wantOkWithoutSession: false,
- wantOkWithSession: true,
- }, {
- reqPath: "/api/somethingelse",
- reqMethod: httpm.GET,
- wantOkNotOverTailscale: false,
- wantOkWithoutSession: false,
- wantOkWithSession: true,
- }, {
- reqPath: "/assets/styles.css",
- wantOkNotOverTailscale: false,
- wantOkWithoutSession: true,
- wantOkWithSession: true,
- }}
- for _, tt := range tests {
- t.Run(fmt.Sprintf("%s-%s", tt.reqMethod, tt.reqPath), func(t *testing.T) {
- doAuthorize := func(remoteAddr string, cookie string) bool {
- r := httptest.NewRequest(tt.reqMethod, tt.reqPath, nil)
- r.RemoteAddr = remoteAddr
- if cookie != "" {
- r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: cookie})
- }
- w := httptest.NewRecorder()
- return s.authorizeRequest(w, r)
- }
- // Do request from non-Tailscale IP.
- if gotOk := doAuthorize("123.456.789.999", ""); gotOk != tt.wantOkNotOverTailscale {
- t.Errorf("wantOkNotOverTailscale; want=%v, got=%v", tt.wantOkNotOverTailscale, gotOk)
- }
- // Do request from Tailscale IP w/o associated session.
- if gotOk := doAuthorize(remoteIP, ""); gotOk != tt.wantOkWithoutSession {
- t.Errorf("wantOkWithoutSession; want=%v, got=%v", tt.wantOkWithoutSession, gotOk)
- }
- // Do request from Tailscale IP w/ associated session.
- if gotOk := doAuthorize(remoteIP, validCookie); gotOk != tt.wantOkWithSession {
- t.Errorf("wantOkWithSession; want=%v, got=%v", tt.wantOkWithSession, gotOk)
- }
- })
- }
- }
- func TestServeAuth(t *testing.T) {
- user := &tailcfg.UserProfile{LoginName: "[email protected]", ID: tailcfg.UserID(1)}
- self := &ipnstate.PeerStatus{
- ID: "self",
- UserID: user.ID,
- TailscaleIPs: []netip.Addr{netip.MustParseAddr("100.1.2.3")},
- }
- remoteIP := "100.100.100.101"
- remoteNode := &apitype.WhoIsResponse{
- Node: &tailcfg.Node{
- Name: "nodey",
- ID: 1,
- Addresses: []netip.Prefix{netip.MustParsePrefix(remoteIP + "/32")},
- },
- UserProfile: user,
- }
- vi := &viewerIdentity{
- LoginName: user.LoginName,
- NodeName: remoteNode.Node.Name,
- NodeIP: remoteIP,
- ProfilePicURL: user.ProfilePicURL,
- Capabilities: peerCapabilities{capFeatureAll: true},
- }
- testControlURL := &defaultControlURL
- lal := memnet.Listen("local-tailscaled.sock:80")
- defer lal.Close()
- localapi := mockLocalAPI(t,
- map[string]*apitype.WhoIsResponse{remoteIP: remoteNode},
- func() *ipnstate.PeerStatus { return self },
- func() *ipn.Prefs {
- return &ipn.Prefs{ControlURL: *testControlURL}
- },
- nil,
- )
- defer localapi.Close()
- go localapi.Serve(lal)
- timeNow := time.Now()
- oneHourAgo := timeNow.Add(-time.Hour)
- sixtyDaysAgo := timeNow.Add(-sessionCookieExpiry * 2)
- s := &Server{
- mode: ManageServerMode,
- lc: &tailscale.LocalClient{Dial: lal.Dial},
- timeNow: func() time.Time { return timeNow },
- newAuthURL: mockNewAuthURL,
- waitAuthURL: mockWaitAuthURL,
- }
- successCookie := "ts-cookie-success"
- s.browserSessions.Store(successCookie, &browserSession{
- ID: successCookie,
- SrcNode: remoteNode.Node.ID,
- SrcUser: user.ID,
- Created: oneHourAgo,
- AuthID: testAuthPathSuccess,
- AuthURL: *testControlURL + testAuthPathSuccess,
- })
- failureCookie := "ts-cookie-failure"
- s.browserSessions.Store(failureCookie, &browserSession{
- ID: failureCookie,
- SrcNode: remoteNode.Node.ID,
- SrcUser: user.ID,
- Created: oneHourAgo,
- AuthID: testAuthPathError,
- AuthURL: *testControlURL + testAuthPathError,
- })
- expiredCookie := "ts-cookie-expired"
- s.browserSessions.Store(expiredCookie, &browserSession{
- ID: expiredCookie,
- SrcNode: remoteNode.Node.ID,
- SrcUser: user.ID,
- Created: sixtyDaysAgo,
- AuthID: "/a/old-auth-url",
- AuthURL: *testControlURL + "/a/old-auth-url",
- })
- tests := []struct {
- name string
- controlURL string // if empty, defaultControlURL is used
- cookie string // cookie attached to request
- wantNewCookie bool // want new cookie generated during request
- wantSession *browserSession // session associated w/ cookie after request
- path string
- wantStatus int
- wantResp any
- }{
- {
- name: "no-session",
- path: "/api/auth",
- wantStatus: http.StatusOK,
- wantResp: &authResponse{ViewerIdentity: vi, ServerMode: ManageServerMode},
- wantNewCookie: false,
- wantSession: nil,
- },
- {
- name: "new-session",
- path: "/api/auth/session/new",
- wantStatus: http.StatusOK,
- wantResp: &newSessionAuthResponse{AuthURL: *testControlURL + testAuthPath},
- wantNewCookie: true,
- wantSession: &browserSession{
- ID: "GENERATED_ID", // gets swapped for newly created ID by test
- SrcNode: remoteNode.Node.ID,
- SrcUser: user.ID,
- Created: timeNow,
- AuthID: testAuthPath,
- AuthURL: *testControlURL + testAuthPath,
- Authenticated: false,
- },
- },
- {
- name: "query-existing-incomplete-session",
- path: "/api/auth",
- cookie: successCookie,
- wantStatus: http.StatusOK,
- wantResp: &authResponse{ViewerIdentity: vi, ServerMode: ManageServerMode},
- wantSession: &browserSession{
- ID: successCookie,
- SrcNode: remoteNode.Node.ID,
- SrcUser: user.ID,
- Created: oneHourAgo,
- AuthID: testAuthPathSuccess,
- AuthURL: *testControlURL + testAuthPathSuccess,
- Authenticated: false,
- },
- },
- {
- name: "existing-session-used",
- path: "/api/auth/session/new", // should not create new session
- cookie: successCookie,
- wantStatus: http.StatusOK,
- wantResp: &newSessionAuthResponse{AuthURL: *testControlURL + testAuthPathSuccess},
- wantSession: &browserSession{
- ID: successCookie,
- SrcNode: remoteNode.Node.ID,
- SrcUser: user.ID,
- Created: oneHourAgo,
- AuthID: testAuthPathSuccess,
- AuthURL: *testControlURL + testAuthPathSuccess,
- Authenticated: false,
- },
- },
- {
- name: "transition-to-successful-session",
- path: "/api/auth/session/wait",
- cookie: successCookie,
- wantStatus: http.StatusOK,
- wantResp: nil,
- wantSession: &browserSession{
- ID: successCookie,
- SrcNode: remoteNode.Node.ID,
- SrcUser: user.ID,
- Created: oneHourAgo,
- AuthID: testAuthPathSuccess,
- AuthURL: *testControlURL + testAuthPathSuccess,
- Authenticated: true,
- },
- },
- {
- name: "query-existing-complete-session",
- path: "/api/auth",
- cookie: successCookie,
- wantStatus: http.StatusOK,
- wantResp: &authResponse{Authorized: true, ViewerIdentity: vi, ServerMode: ManageServerMode},
- wantSession: &browserSession{
- ID: successCookie,
- SrcNode: remoteNode.Node.ID,
- SrcUser: user.ID,
- Created: oneHourAgo,
- AuthID: testAuthPathSuccess,
- AuthURL: *testControlURL + testAuthPathSuccess,
- Authenticated: true,
- },
- },
- {
- name: "transition-to-failed-session",
- path: "/api/auth/session/wait",
- cookie: failureCookie,
- wantStatus: http.StatusUnauthorized,
- wantResp: nil,
- wantSession: nil, // session deleted
- },
- {
- name: "failed-session-cleaned-up",
- path: "/api/auth/session/new",
- cookie: failureCookie,
- wantStatus: http.StatusOK,
- wantResp: &newSessionAuthResponse{AuthURL: *testControlURL + testAuthPath},
- wantNewCookie: true,
- wantSession: &browserSession{
- ID: "GENERATED_ID",
- SrcNode: remoteNode.Node.ID,
- SrcUser: user.ID,
- Created: timeNow,
- AuthID: testAuthPath,
- AuthURL: *testControlURL + testAuthPath,
- Authenticated: false,
- },
- },
- {
- name: "expired-cookie-gets-new-session",
- path: "/api/auth/session/new",
- cookie: expiredCookie,
- wantStatus: http.StatusOK,
- wantResp: &newSessionAuthResponse{AuthURL: *testControlURL + testAuthPath},
- wantNewCookie: true,
- wantSession: &browserSession{
- ID: "GENERATED_ID",
- SrcNode: remoteNode.Node.ID,
- SrcUser: user.ID,
- Created: timeNow,
- AuthID: testAuthPath,
- AuthURL: *testControlURL + testAuthPath,
- Authenticated: false,
- },
- },
- {
- name: "control-server-no-check-mode",
- controlURL: "http://alternate-server.com/",
- path: "/api/auth/session/new",
- wantStatus: http.StatusOK,
- wantResp: &newSessionAuthResponse{},
- wantNewCookie: true,
- wantSession: &browserSession{
- ID: "GENERATED_ID", // gets swapped for newly created ID by test
- SrcNode: remoteNode.Node.ID,
- SrcUser: user.ID,
- Created: timeNow,
- Authenticated: true,
- },
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- if tt.controlURL != "" {
- testControlURL = &tt.controlURL
- } else {
- testControlURL = &defaultControlURL
- }
- r := httptest.NewRequest("GET", "http://100.1.2.3:5252"+tt.path, nil)
- r.RemoteAddr = remoteIP
- r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: tt.cookie})
- w := httptest.NewRecorder()
- s.serve(w, r)
- res := w.Result()
- defer res.Body.Close()
- // Validate response status/data.
- if gotStatus := res.StatusCode; tt.wantStatus != gotStatus {
- t.Errorf("wrong status; want=%v, got=%v", tt.wantStatus, gotStatus)
- }
- var gotResp string
- if res.StatusCode == http.StatusOK {
- body, err := io.ReadAll(res.Body)
- if err != nil {
- t.Fatal(err)
- }
- gotResp = strings.Trim(string(body), "\n")
- }
- var wantResp string
- if tt.wantResp != nil {
- b, _ := json.Marshal(tt.wantResp)
- wantResp = string(b)
- }
- if diff := cmp.Diff(gotResp, string(wantResp)); diff != "" {
- t.Errorf("wrong response; (-got+want):%v", diff)
- }
- // Validate cookie creation.
- sessionID := tt.cookie
- var gotCookie bool
- for _, c := range w.Result().Cookies() {
- if c.Name == sessionCookieName {
- gotCookie = true
- sessionID = c.Value
- break
- }
- }
- if gotCookie != tt.wantNewCookie {
- t.Errorf("wantNewCookie wrong; want=%v, got=%v", tt.wantNewCookie, gotCookie)
- }
- // Validate browser session contents.
- var gotSesson *browserSession
- if s, ok := s.browserSessions.Load(sessionID); ok {
- gotSesson = s.(*browserSession)
- }
- if tt.wantSession != nil && tt.wantSession.ID == "GENERATED_ID" {
- // If requested, swap in the generated session ID before
- // comparing got/want.
- tt.wantSession.ID = sessionID
- }
- if diff := cmp.Diff(gotSesson, tt.wantSession); diff != "" {
- t.Errorf("wrong session; (-got+want):%v", diff)
- }
- })
- }
- }
- // TestServeAPIAuthMetricLogging specifically tests metric logging in the serveAPIAuth function.
- // For each given test case, we assert that the local API received a request to log the expected metric.
- func TestServeAPIAuthMetricLogging(t *testing.T) {
- user := &tailcfg.UserProfile{LoginName: "[email protected]", ID: tailcfg.UserID(1)}
- otherUser := &tailcfg.UserProfile{LoginName: "[email protected]", ID: tailcfg.UserID(2)}
- self := &ipnstate.PeerStatus{
- ID: "self",
- UserID: user.ID,
- TailscaleIPs: []netip.Addr{netip.MustParseAddr("100.1.2.3")},
- }
- remoteIP := "100.100.100.101"
- remoteNode := &apitype.WhoIsResponse{
- Node: &tailcfg.Node{
- Name: "remote-managed",
- ID: 1,
- Addresses: []netip.Prefix{netip.MustParsePrefix(remoteIP + "/32")},
- },
- UserProfile: user,
- }
- remoteTaggedIP := "100.123.100.213"
- remoteTaggedNode := &apitype.WhoIsResponse{
- Node: &tailcfg.Node{
- Name: "remote-tagged",
- ID: 2,
- Addresses: []netip.Prefix{netip.MustParsePrefix(remoteTaggedIP + "/32")},
- Tags: []string{"dev-machine"},
- },
- UserProfile: user,
- }
- localIP := "100.1.2.3"
- localNode := &apitype.WhoIsResponse{
- Node: &tailcfg.Node{
- Name: "local-managed",
- ID: 3,
- StableID: "self",
- Addresses: []netip.Prefix{netip.MustParsePrefix(localIP + "/32")},
- },
- UserProfile: user,
- }
- localTaggedIP := "100.1.2.133"
- localTaggedNode := &apitype.WhoIsResponse{
- Node: &tailcfg.Node{
- Name: "local-tagged",
- ID: 4,
- StableID: "self",
- Addresses: []netip.Prefix{netip.MustParsePrefix(localTaggedIP + "/32")},
- Tags: []string{"prod-machine"},
- },
- UserProfile: user,
- }
- otherIP := "100.100.2.3"
- otherNode := &apitype.WhoIsResponse{
- Node: &tailcfg.Node{
- Name: "other-node",
- ID: 5,
- Addresses: []netip.Prefix{netip.MustParsePrefix(otherIP + "/32")},
- },
- UserProfile: otherUser,
- }
- nonTailscaleIP := "10.100.2.3"
- testControlURL := &defaultControlURL
- var loggedMetrics []string
- lal := memnet.Listen("local-tailscaled.sock:80")
- defer lal.Close()
- localapi := mockLocalAPI(t,
- map[string]*apitype.WhoIsResponse{remoteIP: remoteNode, localIP: localNode, otherIP: otherNode, localTaggedIP: localTaggedNode, remoteTaggedIP: remoteTaggedNode},
- func() *ipnstate.PeerStatus { return self },
- func() *ipn.Prefs {
- return &ipn.Prefs{ControlURL: *testControlURL}
- },
- func(metricName string) {
- loggedMetrics = append(loggedMetrics, metricName)
- },
- )
- defer localapi.Close()
- go localapi.Serve(lal)
- timeNow := time.Now()
- oneHourAgo := timeNow.Add(-time.Hour)
- s := &Server{
- mode: ManageServerMode,
- lc: &tailscale.LocalClient{Dial: lal.Dial},
- timeNow: func() time.Time { return timeNow },
- newAuthURL: mockNewAuthURL,
- waitAuthURL: mockWaitAuthURL,
- }
- authenticatedRemoteNodeCookie := "ts-cookie-remote-node-authenticated"
- s.browserSessions.Store(authenticatedRemoteNodeCookie, &browserSession{
- ID: authenticatedRemoteNodeCookie,
- SrcNode: remoteNode.Node.ID,
- SrcUser: user.ID,
- Created: oneHourAgo,
- AuthID: testAuthPathSuccess,
- AuthURL: *testControlURL + testAuthPathSuccess,
- Authenticated: true,
- })
- authenticatedLocalNodeCookie := "ts-cookie-local-node-authenticated"
- s.browserSessions.Store(authenticatedLocalNodeCookie, &browserSession{
- ID: authenticatedLocalNodeCookie,
- SrcNode: localNode.Node.ID,
- SrcUser: user.ID,
- Created: oneHourAgo,
- AuthID: testAuthPathSuccess,
- AuthURL: *testControlURL + testAuthPathSuccess,
- Authenticated: true,
- })
- unauthenticatedRemoteNodeCookie := "ts-cookie-remote-node-unauthenticated"
- s.browserSessions.Store(unauthenticatedRemoteNodeCookie, &browserSession{
- ID: unauthenticatedRemoteNodeCookie,
- SrcNode: remoteNode.Node.ID,
- SrcUser: user.ID,
- Created: oneHourAgo,
- AuthID: testAuthPathSuccess,
- AuthURL: *testControlURL + testAuthPathSuccess,
- Authenticated: false,
- })
- unauthenticatedLocalNodeCookie := "ts-cookie-local-node-unauthenticated"
- s.browserSessions.Store(unauthenticatedLocalNodeCookie, &browserSession{
- ID: unauthenticatedLocalNodeCookie,
- SrcNode: localNode.Node.ID,
- SrcUser: user.ID,
- Created: oneHourAgo,
- AuthID: testAuthPathSuccess,
- AuthURL: *testControlURL + testAuthPathSuccess,
- Authenticated: false,
- })
- tests := []struct {
- name string
- cookie string // cookie attached to request
- remoteAddr string // remote address to hit
- wantLoggedMetric string // expected metric to be logged
- }{
- {
- name: "managing-remote",
- cookie: authenticatedRemoteNodeCookie,
- remoteAddr: remoteIP,
- wantLoggedMetric: "web_client_managing_remote",
- },
- {
- name: "managing-local",
- cookie: authenticatedLocalNodeCookie,
- remoteAddr: localIP,
- wantLoggedMetric: "web_client_managing_local",
- },
- {
- name: "viewing-not-owner",
- cookie: authenticatedRemoteNodeCookie,
- remoteAddr: otherIP,
- wantLoggedMetric: "web_client_viewing_not_owner",
- },
- {
- name: "viewing-local-tagged",
- cookie: authenticatedLocalNodeCookie,
- remoteAddr: localTaggedIP,
- wantLoggedMetric: "web_client_viewing_local_tag",
- },
- {
- name: "viewing-remote-tagged",
- cookie: authenticatedRemoteNodeCookie,
- remoteAddr: remoteTaggedIP,
- wantLoggedMetric: "web_client_viewing_remote_tag",
- },
- {
- name: "viewing-local-non-tailscale",
- cookie: authenticatedLocalNodeCookie,
- remoteAddr: nonTailscaleIP,
- wantLoggedMetric: "web_client_viewing_local",
- },
- {
- name: "viewing-local-unauthenticated",
- cookie: unauthenticatedLocalNodeCookie,
- remoteAddr: localIP,
- wantLoggedMetric: "web_client_viewing_local",
- },
- {
- name: "viewing-remote-unauthenticated",
- cookie: unauthenticatedRemoteNodeCookie,
- remoteAddr: remoteIP,
- wantLoggedMetric: "web_client_viewing_remote",
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- testControlURL = &defaultControlURL
- r := httptest.NewRequest("GET", "http://100.1.2.3:5252/api/auth", nil)
- r.RemoteAddr = tt.remoteAddr
- r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: tt.cookie})
- w := httptest.NewRecorder()
- s.serveAPIAuth(w, r)
- if !slices.Contains(loggedMetrics, tt.wantLoggedMetric) {
- t.Errorf("expected logged metrics to contain: '%s' but was: '%v'", tt.wantLoggedMetric, loggedMetrics)
- }
- loggedMetrics = []string{}
- res := w.Result()
- defer res.Body.Close()
- })
- }
- }
- // TestPathPrefix tests that the provided path prefix is normalized correctly.
- // If a leading '/' is missing, one should be added.
- // If multiple leading '/' are present, they should be collapsed to one.
- // Additionally verify that this prevents open redirects when enforcing the path prefix.
- func TestPathPrefix(t *testing.T) {
- tests := []struct {
- name string
- prefix string
- wantPrefix string
- wantLocation string
- }{
- {
- name: "no-leading-slash",
- prefix: "javascript:alert(1)",
- wantPrefix: "/javascript:alert(1)",
- wantLocation: "/javascript:alert(1)/",
- },
- {
- name: "2-slashes",
- prefix: "//evil.example.com/goat",
- // We must also get the trailing slash added:
- wantPrefix: "/evil.example.com/goat",
- wantLocation: "/evil.example.com/goat/",
- },
- {
- name: "absolute-url",
- prefix: "http://evil.example.com",
- // We must also get the trailing slash added:
- wantPrefix: "/http:/evil.example.com",
- wantLocation: "/http:/evil.example.com/",
- },
- {
- name: "double-dot",
- prefix: "/../.././etc/passwd",
- // We must also get the trailing slash added:
- wantPrefix: "/etc/passwd",
- wantLocation: "/etc/passwd/",
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- options := ServerOpts{
- Mode: LoginServerMode,
- PathPrefix: tt.prefix,
- CGIMode: true,
- }
- s, err := NewServer(options)
- if err != nil {
- t.Error(err)
- }
- // verify provided prefix was normalized correctly
- if s.pathPrefix != tt.wantPrefix {
- t.Errorf("prefix was not normalized correctly; want=%q, got=%q", tt.wantPrefix, s.pathPrefix)
- }
- s.logf = t.Logf
- r := httptest.NewRequest(httpm.GET, "http://localhost/", nil)
- w := httptest.NewRecorder()
- s.ServeHTTP(w, r)
- res := w.Result()
- defer res.Body.Close()
- location := w.Header().Get("Location")
- if location != tt.wantLocation {
- t.Errorf("request got wrong location; want=%q, got=%q", tt.wantLocation, location)
- }
- })
- }
- }
- func TestRequireTailscaleIP(t *testing.T) {
- self := &ipnstate.PeerStatus{
- TailscaleIPs: []netip.Addr{
- netip.MustParseAddr("100.1.2.3"),
- netip.MustParseAddr("fd7a:115c::1234"),
- },
- }
- lal := memnet.Listen("local-tailscaled.sock:80")
- defer lal.Close()
- localapi := mockLocalAPI(t, nil, func() *ipnstate.PeerStatus { return self }, nil, nil)
- defer localapi.Close()
- go localapi.Serve(lal)
- s := &Server{
- mode: ManageServerMode,
- lc: &tailscale.LocalClient{Dial: lal.Dial},
- timeNow: time.Now,
- logf: t.Logf,
- }
- tests := []struct {
- name string
- target string
- wantHandled bool
- wantLocation string
- }{
- {
- name: "localhost",
- target: "http://localhost/",
- wantHandled: true,
- wantLocation: "http://100.1.2.3:5252/",
- },
- {
- name: "ipv4-no-port",
- target: "http://100.1.2.3/",
- wantHandled: true,
- wantLocation: "http://100.1.2.3:5252/",
- },
- {
- name: "ipv4-correct-port",
- target: "http://100.1.2.3:5252/",
- wantHandled: false,
- },
- {
- name: "ipv6-no-port",
- target: "http://[fd7a:115c::1234]/",
- wantHandled: true,
- wantLocation: "http://100.1.2.3:5252/",
- },
- {
- name: "ipv6-correct-port",
- target: "http://[fd7a:115c::1234]:5252/",
- wantHandled: false,
- },
- {
- name: "quad-100",
- target: "http://100.100.100.100/",
- wantHandled: false,
- },
- {
- name: "ipv6-service-addr",
- target: "http://[fd7a:115c:a1e0::53]/",
- wantHandled: false,
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- s.logf = t.Logf
- r := httptest.NewRequest(httpm.GET, tt.target, nil)
- w := httptest.NewRecorder()
- handled := s.requireTailscaleIP(w, r)
- if handled != tt.wantHandled {
- t.Errorf("request(%q) was handled; want=%v, got=%v", tt.target, tt.wantHandled, handled)
- }
- location := w.Header().Get("Location")
- if location != tt.wantLocation {
- t.Errorf("request(%q) wrong location; want=%q, got=%q", tt.target, tt.wantLocation, location)
- }
- })
- }
- }
- func TestPeerCapabilities(t *testing.T) {
- userOwnedStatus := &ipnstate.Status{Self: &ipnstate.PeerStatus{UserID: tailcfg.UserID(1)}}
- tags := views.SliceOf[string]([]string{"tag:server"})
- tagOwnedStatus := &ipnstate.Status{Self: &ipnstate.PeerStatus{Tags: &tags}}
- // Testing web.toPeerCapabilities
- toPeerCapsTests := []struct {
- name string
- status *ipnstate.Status
- whois *apitype.WhoIsResponse
- wantCaps peerCapabilities
- }{
- {
- name: "empty-whois",
- status: userOwnedStatus,
- whois: nil,
- wantCaps: peerCapabilities{},
- },
- {
- name: "user-owned-node-non-owner-caps-ignored",
- status: userOwnedStatus,
- whois: &apitype.WhoIsResponse{
- UserProfile: &tailcfg.UserProfile{ID: tailcfg.UserID(2)},
- Node: &tailcfg.Node{ID: tailcfg.NodeID(1)},
- CapMap: tailcfg.PeerCapMap{
- tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{
- "{\"canEdit\":[\"ssh\",\"subnets\"]}",
- },
- },
- },
- wantCaps: peerCapabilities{},
- },
- {
- name: "user-owned-node-owner-caps-ignored",
- status: userOwnedStatus,
- whois: &apitype.WhoIsResponse{
- UserProfile: &tailcfg.UserProfile{ID: tailcfg.UserID(1)},
- Node: &tailcfg.Node{ID: tailcfg.NodeID(1)},
- CapMap: tailcfg.PeerCapMap{
- tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{
- "{\"canEdit\":[\"ssh\",\"subnets\"]}",
- },
- },
- },
- wantCaps: peerCapabilities{capFeatureAll: true}, // should just have wildcard
- },
- {
- name: "tag-owned-no-webui-caps",
- status: tagOwnedStatus,
- whois: &apitype.WhoIsResponse{
- Node: &tailcfg.Node{ID: tailcfg.NodeID(1)},
- CapMap: tailcfg.PeerCapMap{
- tailcfg.PeerCapabilityDebugPeer: []tailcfg.RawMessage{},
- },
- },
- wantCaps: peerCapabilities{},
- },
- {
- name: "tag-owned-one-webui-cap",
- status: tagOwnedStatus,
- whois: &apitype.WhoIsResponse{
- Node: &tailcfg.Node{ID: tailcfg.NodeID(1)},
- CapMap: tailcfg.PeerCapMap{
- tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{
- "{\"canEdit\":[\"ssh\",\"subnets\"]}",
- },
- },
- },
- wantCaps: peerCapabilities{
- capFeatureSSH: true,
- capFeatureSubnets: true,
- },
- },
- {
- name: "tag-owned-multiple-webui-cap",
- status: tagOwnedStatus,
- whois: &apitype.WhoIsResponse{
- Node: &tailcfg.Node{ID: tailcfg.NodeID(1)},
- CapMap: tailcfg.PeerCapMap{
- tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{
- "{\"canEdit\":[\"ssh\",\"subnets\"]}",
- "{\"canEdit\":[\"subnets\",\"exitnodes\",\"*\"]}",
- },
- },
- },
- wantCaps: peerCapabilities{
- capFeatureSSH: true,
- capFeatureSubnets: true,
- capFeatureExitNodes: true,
- capFeatureAll: true,
- },
- },
- {
- name: "tag-owned-case-insensitive-caps",
- status: tagOwnedStatus,
- whois: &apitype.WhoIsResponse{
- Node: &tailcfg.Node{ID: tailcfg.NodeID(1)},
- CapMap: tailcfg.PeerCapMap{
- tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{
- "{\"canEdit\":[\"SSH\",\"sUBnets\"]}",
- },
- },
- },
- wantCaps: peerCapabilities{
- capFeatureSSH: true,
- capFeatureSubnets: true,
- },
- },
- {
- name: "tag-owned-random-canEdit-contents-get-dropped",
- status: tagOwnedStatus,
- whois: &apitype.WhoIsResponse{
- Node: &tailcfg.Node{ID: tailcfg.NodeID(1)},
- CapMap: tailcfg.PeerCapMap{
- tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{
- "{\"canEdit\":[\"unknown-feature\"]}",
- },
- },
- },
- wantCaps: peerCapabilities{},
- },
- {
- name: "tag-owned-no-canEdit-section",
- status: tagOwnedStatus,
- whois: &apitype.WhoIsResponse{
- Node: &tailcfg.Node{ID: tailcfg.NodeID(1)},
- CapMap: tailcfg.PeerCapMap{
- tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{
- "{\"canDoSomething\":[\"*\"]}",
- },
- },
- },
- wantCaps: peerCapabilities{},
- },
- {
- name: "tagged-source-caps-ignored",
- status: tagOwnedStatus,
- whois: &apitype.WhoIsResponse{
- Node: &tailcfg.Node{ID: tailcfg.NodeID(1), Tags: tags.AsSlice()},
- CapMap: tailcfg.PeerCapMap{
- tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{
- "{\"canEdit\":[\"ssh\",\"subnets\"]}",
- },
- },
- },
- wantCaps: peerCapabilities{},
- },
- }
- for _, tt := range toPeerCapsTests {
- t.Run("toPeerCapabilities-"+tt.name, func(t *testing.T) {
- got, err := toPeerCapabilities(tt.status, tt.whois)
- if err != nil {
- t.Fatalf("unexpected: %v", err)
- }
- if diff := cmp.Diff(got, tt.wantCaps); diff != "" {
- t.Errorf("wrong caps; (-got+want):%v", diff)
- }
- })
- }
- // Testing web.peerCapabilities.canEdit
- canEditTests := []struct {
- name string
- caps peerCapabilities
- wantCanEdit map[capFeature]bool
- }{
- {
- name: "empty-caps",
- caps: nil,
- wantCanEdit: map[capFeature]bool{
- capFeatureAll: false,
- capFeatureSSH: false,
- capFeatureSubnets: false,
- capFeatureExitNodes: false,
- capFeatureAccount: false,
- },
- },
- {
- name: "some-caps",
- caps: peerCapabilities{capFeatureSSH: true, capFeatureAccount: true},
- wantCanEdit: map[capFeature]bool{
- capFeatureAll: false,
- capFeatureSSH: true,
- capFeatureSubnets: false,
- capFeatureExitNodes: false,
- capFeatureAccount: true,
- },
- },
- {
- name: "wildcard-in-caps",
- caps: peerCapabilities{capFeatureAll: true, capFeatureAccount: true},
- wantCanEdit: map[capFeature]bool{
- capFeatureAll: true,
- capFeatureSSH: true,
- capFeatureSubnets: true,
- capFeatureExitNodes: true,
- capFeatureAccount: true,
- },
- },
- }
- for _, tt := range canEditTests {
- t.Run("canEdit-"+tt.name, func(t *testing.T) {
- for f, want := range tt.wantCanEdit {
- if got := tt.caps.canEdit(f); got != want {
- t.Errorf("wrong canEdit(%s); got=%v, want=%v", f, got, want)
- }
- }
- })
- }
- }
- var (
- defaultControlURL = "https://controlplane.tailscale.com"
- testAuthPath = "/a/12345"
- testAuthPathSuccess = "/a/will-succeed"
- testAuthPathError = "/a/will-error"
- )
- // mockLocalAPI constructs a test localapi handler that can be used
- // to simulate localapi responses without a functioning tailnet.
- //
- // self accepts a function that resolves to a self node status,
- // so that tests may swap out the /localapi/v0/status response
- // as desired.
- func mockLocalAPI(t *testing.T, whoIs map[string]*apitype.WhoIsResponse, self func() *ipnstate.PeerStatus, prefs func() *ipn.Prefs, metricCapture func(string)) *http.Server {
- return &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- switch r.URL.Path {
- case "/localapi/v0/whois":
- addr := r.URL.Query().Get("addr")
- if addr == "" {
- t.Fatalf("/whois call missing \"addr\" query")
- }
- if node := whoIs[addr]; node != nil {
- writeJSON(w, &node)
- return
- }
- http.Error(w, "not a node", http.StatusUnauthorized)
- return
- case "/localapi/v0/status":
- writeJSON(w, ipnstate.Status{Self: self()})
- return
- case "/localapi/v0/prefs":
- writeJSON(w, prefs())
- return
- case "/localapi/v0/upload-client-metrics":
- type metricName struct {
- Name string `json:"name"`
- }
- var metricNames []metricName
- if err := json.NewDecoder(r.Body).Decode(&metricNames); err != nil {
- http.Error(w, "invalid JSON body", http.StatusBadRequest)
- return
- }
- metricCapture(metricNames[0].Name)
- writeJSON(w, struct{}{})
- return
- case "/localapi/v0/logout":
- fmt.Fprintf(w, "success")
- return
- default:
- t.Fatalf("unhandled localapi test endpoint %q, add to localapi handler func in test", r.URL.Path)
- }
- })}
- }
- func mockNewAuthURL(_ context.Context, src tailcfg.NodeID) (*tailcfg.WebClientAuthResponse, error) {
- // Create new dummy auth URL.
- return &tailcfg.WebClientAuthResponse{ID: testAuthPath, URL: defaultControlURL + testAuthPath}, nil
- }
- func mockWaitAuthURL(_ context.Context, id string, src tailcfg.NodeID) (*tailcfg.WebClientAuthResponse, error) {
- switch id {
- case testAuthPathSuccess: // successful auth URL
- return &tailcfg.WebClientAuthResponse{Complete: true}, nil
- case testAuthPathError: // error auth URL
- return nil, errors.New("authenticated as wrong user")
- default:
- return nil, errors.New("unknown id")
- }
- }
|