cli: web advertise exit node button

A couple of gnarly assumptions in this code, as always with the async
message thing.

UI button is based on the DNS settings in the admin panel.

Co-authored-by: Maisem Ali <maisem@tailscale.com>
Signed-off-by: David Crawshaw <crawshaw@tailscale.com>
This commit is contained in:
David Crawshaw 2021-08-17 16:37:22 -07:00 committed by David Crawshaw
parent 0410f1a35a
commit cc9cf97cbe
4 changed files with 144 additions and 32 deletions

View File

@ -143,17 +143,11 @@ func warnf(format string, args ...interface{}) {
ipv6default = netaddr.MustParseIPPrefix("::/0") ipv6default = netaddr.MustParseIPPrefix("::/0")
) )
// prefsFromUpArgs returns the ipn.Prefs for the provided args. func calcAdvertiseRoutes(advertiseRoutes string, advertiseDefaultRoute bool) ([]netaddr.IPPrefix, error) {
//
// Note that the parameters upArgs and warnf are named intentionally
// to shadow the globals to prevent accidental misuse of them. This
// function exists for testing and should have no side effects or
// outside interactions (e.g. no making Tailscale local API calls).
func prefsFromUpArgs(upArgs upArgsT, warnf logger.Logf, st *ipnstate.Status, goos string) (*ipn.Prefs, error) {
routeMap := map[netaddr.IPPrefix]bool{} routeMap := map[netaddr.IPPrefix]bool{}
if advertiseRoutes != "" {
var default4, default6 bool var default4, default6 bool
if upArgs.advertiseRoutes != "" { advroutes := strings.Split(advertiseRoutes, ",")
advroutes := strings.Split(upArgs.advertiseRoutes, ",")
for _, s := range advroutes { for _, s := range advroutes {
ipp, err := netaddr.ParseIPPrefix(s) ipp, err := netaddr.ParseIPPrefix(s)
if err != nil { if err != nil {
@ -175,7 +169,7 @@ func prefsFromUpArgs(upArgs upArgsT, warnf logger.Logf, st *ipnstate.Status, goo
return nil, fmt.Errorf("%s advertised without its IPv6 counterpart, please also advertise %s", ipv6default, ipv4default) return nil, fmt.Errorf("%s advertised without its IPv6 counterpart, please also advertise %s", ipv6default, ipv4default)
} }
} }
if upArgs.advertiseDefaultRoute { if advertiseDefaultRoute {
routeMap[netaddr.MustParseIPPrefix("0.0.0.0/0")] = true routeMap[netaddr.MustParseIPPrefix("0.0.0.0/0")] = true
routeMap[netaddr.MustParseIPPrefix("::/0")] = true routeMap[netaddr.MustParseIPPrefix("::/0")] = true
} }
@ -189,6 +183,20 @@ func prefsFromUpArgs(upArgs upArgsT, warnf logger.Logf, st *ipnstate.Status, goo
} }
return routes[i].IP().Less(routes[j].IP()) return routes[i].IP().Less(routes[j].IP())
}) })
return routes, nil
}
// prefsFromUpArgs returns the ipn.Prefs for the provided args.
//
// Note that the parameters upArgs and warnf are named intentionally
// to shadow the globals to prevent accidental misuse of them. This
// function exists for testing and should have no side effects or
// outside interactions (e.g. no making Tailscale local API calls).
func prefsFromUpArgs(upArgs upArgsT, warnf logger.Logf, st *ipnstate.Status, goos string) (*ipn.Prefs, error) {
routes, err := calcAdvertiseRoutes(upArgs.advertiseRoutes, upArgs.advertiseDefaultRoute)
if err != nil {
return nil, err
}
var exitNodeIP netaddr.IP var exitNodeIP netaddr.IP
if upArgs.exitNodeIP != "" { if upArgs.exitNodeIP != "" {

View File

@ -1335,3 +1335,14 @@ html {
border-color: #6c94ec; border-color: #6c94ec;
border-color: rgba(108, 148, 236, var(--border-opacity)); border-color: rgba(108, 148, 236, var(--border-opacity));
} }
.button-red {
background-color: #d04841;
border-color: #d04841;
color: #fff;
}
.button-red:enabled:hover {
background-color: #b22d30;
border-color: #b22d30;
}

View File

@ -14,6 +14,7 @@
"flag" "flag"
"fmt" "fmt"
"html/template" "html/template"
"io"
"io/ioutil" "io/ioutil"
"log" "log"
"net" "net"
@ -26,6 +27,7 @@
"strings" "strings"
"github.com/peterbourgon/ff/v3/ffcli" "github.com/peterbourgon/ff/v3/ffcli"
"inet.af/netaddr"
"tailscale.com/client/tailscale" "tailscale.com/client/tailscale"
"tailscale.com/ipn" "tailscale.com/ipn"
"tailscale.com/tailcfg" "tailscale.com/tailcfg"
@ -56,6 +58,8 @@ type tmplData struct {
Status string Status string
DeviceName string DeviceName string
IP string IP string
AdvertiseExitNode bool
AdvertiseRoutes string
} }
var webCmd = &ffcli.Command{ var webCmd = &ffcli.Command{
@ -303,15 +307,45 @@ func webHandler(w http.ResponseWriter, r *http.Request) {
} }
if r.Method == "POST" { if r.Method == "POST" {
defer r.Body.Close()
var postData struct {
AdvertiseRoutes string
AdvertiseExitNode bool
Reauthenticate bool
}
type mi map[string]interface{} type mi map[string]interface{}
w.Header().Set("Content-Type", "application/json") if err := json.NewDecoder(r.Body).Decode(&postData); err != nil {
url, err := tailscaleUpForceReauth(r.Context()) w.WriteHeader(400)
json.NewEncoder(w).Encode(mi{"error": err.Error()})
return
}
prefs, err := tailscale.GetPrefs(r.Context())
if err != nil && !postData.Reauthenticate {
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(mi{"error": err.Error()})
return
} else {
routes, err := calcAdvertiseRoutes(postData.AdvertiseRoutes, postData.AdvertiseExitNode)
if err != nil { if err != nil {
w.WriteHeader(http.StatusInternalServerError) w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(mi{"error": err.Error()}) json.NewEncoder(w).Encode(mi{"error": err.Error()})
return return
} }
prefs.AdvertiseRoutes = routes
}
w.Header().Set("Content-Type", "application/json")
url, err := tailscaleUp(r.Context(), prefs, postData.Reauthenticate)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(mi{"error": err.Error()})
return
}
if url != "" {
json.NewEncoder(w).Encode(mi{"url": url}) json.NewEncoder(w).Encode(mi{"url": url})
} else {
io.WriteString(w, "{}")
}
return return
} }
@ -320,6 +354,11 @@ type mi map[string]interface{
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
} }
prefs, err := tailscale.GetPrefs(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
profile := st.User[st.Self.UserID] profile := st.User[st.Self.UserID]
deviceName := strings.Split(st.Self.DNSName, ".")[0] deviceName := strings.Split(st.Self.DNSName, ".")[0]
@ -329,6 +368,18 @@ type mi map[string]interface{
Status: st.BackendState, Status: st.BackendState,
DeviceName: deviceName, DeviceName: deviceName,
} }
exitNodeRouteV4 := netaddr.MustParseIPPrefix("0.0.0.0/0")
exitNodeRouteV6 := netaddr.MustParseIPPrefix("::/0")
for _, r := range prefs.AdvertiseRoutes {
if r == exitNodeRouteV4 || r == exitNodeRouteV6 {
data.AdvertiseExitNode = true
} else {
if data.AdvertiseRoutes != "" {
data.AdvertiseRoutes = ","
}
data.AdvertiseRoutes += r.String()
}
}
if len(st.TailscaleIPs) != 0 { if len(st.TailscaleIPs) != 0 {
data.IP = st.TailscaleIPs[0].String() data.IP = st.TailscaleIPs[0].String()
} }
@ -342,13 +393,15 @@ type mi map[string]interface{
} }
// TODO(crawshaw): some of this is very similar to the code in 'tailscale up', can we share anything? // TODO(crawshaw): some of this is very similar to the code in 'tailscale up', can we share anything?
func tailscaleUpForceReauth(ctx context.Context) (authURL string, retErr error) { func tailscaleUp(ctx context.Context, prefs *ipn.Prefs, forceReauth bool) (authURL string, retErr error) {
prefs := ipn.NewPrefs() if prefs == nil {
prefs = ipn.NewPrefs()
prefs.ControlURL = ipn.DefaultControlURL prefs.ControlURL = ipn.DefaultControlURL
prefs.WantRunning = true prefs.WantRunning = true
prefs.CorpDNS = true prefs.CorpDNS = true
prefs.AllowSingleHosts = true prefs.AllowSingleHosts = true
prefs.ForceDaemon = (runtime.GOOS == "windows") prefs.ForceDaemon = (runtime.GOOS == "windows")
}
if distro.Get() == distro.Synology { if distro.Get() == distro.Synology {
prefs.NetfilterMode = preftype.NetfilterOff prefs.NetfilterMode = preftype.NetfilterOff
@ -395,6 +448,14 @@ func tailscaleUpForceReauth(ctx context.Context) (authURL string, retErr error)
authURL = *url authURL = *url
cancel() cancel()
} }
if !forceReauth && n.Prefs != nil {
p1, p2 := *n.Prefs, *prefs
p1.Persist = nil
p2.Persist = nil
if p1.Equals(&p2) {
cancel()
}
}
}) })
// Wait for backend client to be connected so we know // Wait for backend client to be connected so we know
// we're subscribed to updates. Otherwise we can miss // we're subscribed to updates. Otherwise we can miss
@ -412,10 +473,15 @@ func tailscaleUpForceReauth(ctx context.Context) (authURL string, retErr error)
bc.Start(ipn.Options{ bc.Start(ipn.Options{
StateKey: ipn.GlobalDaemonStateKey, StateKey: ipn.GlobalDaemonStateKey,
}) })
if forceReauth {
bc.StartLoginInteractive() bc.StartLoginInteractive()
}
<-pumpCtx.Done() // wait for authURL or complete failure <-pumpCtx.Done() // wait for authURL or complete failure
if authURL == "" && retErr == nil { if authURL == "" && retErr == nil {
if !forceReauth {
return "", nil // no auth URL is fine
}
retErr = pumpCtx.Err() retErr = pumpCtx.Err()
} }
if authURL == "" && retErr == nil { if authURL == "" && retErr == nil {

View File

@ -86,14 +86,30 @@
<div class="mb-4"> <div class="mb-4">
<p>You are connected! Access this device over Tailscale using the device name or IP address above.</p> <p>You are connected! Access this device over Tailscale using the device name or IP address above.</p>
</div> </div>
<div class="mb-4">
<a href="#" class="mb-4 js-advertiseExitNode">
{{if .AdvertiseExitNode}}
<button class="button button-red button-medium" id="enabled">Stop advertising Exit Node</button>
{{else}}
<button class="button button-blue button-medium" id="enabled">Advertise as Exit Node</button>
{{end}}
</a>
</div>
<div class="mb-4">
<a href="#" class="mb-4 link font-medium js-loginButton" target="_blank">Reauthenticate</a> <a href="#" class="mb-4 link font-medium js-loginButton" target="_blank">Reauthenticate</a>
</div>
{{ end }} {{ end }}
</main> </main>
<script>(function () { <script>(function () {
let loginButtons = document.querySelectorAll(".js-loginButton"); const advertiseExitNode = {{.AdvertiseExitNode}};
let fetchingUrl = false; let fetchingUrl = false;
var data = {
AdvertiseRoutes: "{{.AdvertiseRoutes}}",
AdvertiseExitNode: advertiseExitNode,
Reauthenticate: false
};
function handleClick(e) { function postData(e) {
e.preventDefault(); e.preventDefault();
if (fetchingUrl) { if (fetchingUrl) {
@ -116,7 +132,8 @@ function handleClick(e) {
headers: { headers: {
"Accept": "application/json", "Accept": "application/json",
"Content-Type": "application/json", "Content-Type": "application/json",
} },
body: JSON.stringify(data)
}).then(res => res.json()).then(res => { }).then(res => res.json()).then(res => {
fetchingUrl = false; fetchingUrl = false;
const err = res["error"]; const err = res["error"];
@ -134,9 +151,19 @@ function handleClick(e) {
}); });
} }
Array.from(loginButtons).forEach(el => { Array.from(document.querySelectorAll(".js-loginButton")).forEach(el => {
el.addEventListener("click", handleClick); el.addEventListener("click", function(e) {
data.Reauthenticate = true;
postData(e);
});
}) })
Array.from(document.querySelectorAll(".js-advertiseExitNode")).forEach(el => {
el.addEventListener("click", function(e) {
data.AdvertiseExitNode = !advertiseExitNode;
postData(e);
});
})
})();</script> })();</script>
</body> </body>