client/web: indicate if ACLs prevent access

Use the packet filter rules to determine if any device is allowed to
connect on port 5252.  This does not check whether a specific device can
connect (since we typically don't know the source device when this is
used).  Nor does it specifically check for wide-open ACLs, which is
something we may provide a warning about in the future.

Update the login popover content to display information when the src
device is unable to connect to the dst device over its Tailscale IP. If
we know it's an ACL issue, mention that, otherwise list a couple of
things to check. In both cases, link to a placeholder URL to get more
information about web client connection issues.

Updates #10261

Signed-off-by: Will Norris <will@tailscale.com>
This commit is contained in:
Will Norris 2023-11-29 16:40:41 -08:00 committed by Will Norris
parent 5e125750bc
commit f9550e0bed
4 changed files with 109 additions and 31 deletions

View File

@ -1332,6 +1332,15 @@ func (lc *LocalClient) DebugDERPRegion(ctx context.Context, regionIDOrCode strin
return decodeJSON[*ipnstate.DebugDERPRegionReport](body) return decodeJSON[*ipnstate.DebugDERPRegionReport](body)
} }
// DebugPacketFilterRules returns the packet filter rules for the current device.
func (lc *LocalClient) DebugPacketFilterRules(ctx context.Context) ([]tailcfg.FilterRule, error) {
body, err := lc.send(ctx, "POST", "/localapi/v0/debug-packet-filter-rules", 200, nil)
if err != nil {
return nil, fmt.Errorf("error %w: %s", err, body)
}
return decodeJSON[[]tailcfg.FilterRule](body)
}
// DebugSetExpireIn marks the current node key to expire in d. // DebugSetExpireIn marks the current node key to expire in d.
// //
// This is meant primarily for debug and testing. // This is meant primarily for debug and testing.

View File

@ -8,6 +8,7 @@ import { ReactComponent as Eye } from "src/assets/icons/eye.svg"
import { ReactComponent as User } from "src/assets/icons/user.svg" import { ReactComponent as User } from "src/assets/icons/user.svg"
import { AuthResponse, AuthType } from "src/hooks/auth" import { AuthResponse, AuthType } from "src/hooks/auth"
import { NodeData } from "src/hooks/node-data" import { NodeData } from "src/hooks/node-data"
import Button from "src/ui/button"
import Popover from "src/ui/popover" import Popover from "src/ui/popover"
import ProfilePic from "src/ui/profile-pic" import ProfilePic from "src/ui/profile-pic"
@ -140,44 +141,68 @@ function LoginPopoverContent({
{!auth.canManageNode ? "Viewing" : "Managing"} {!auth.canManageNode ? "Viewing" : "Managing"}
{auth.viewerIdentity && ` as ${auth.viewerIdentity.loginName}`} {auth.viewerIdentity && ` as ${auth.viewerIdentity.loginName}`}
</div> </div>
{!auth.canManageNode && {!auth.canManageNode && (
(!auth.viewerIdentity || auth.authNeeded === AuthType.tailscale ? ( <>
<> {!auth.viewerIdentity ? (
<p className="text-gray-500 text-xs"> // User is not connected over Tailscale.
{auth.viewerIdentity ? ( // These states are only possible on the login client.
<>
{!canConnectOverTS ? (
<> <>
To make changes, sign in to confirm your identity. This extra <p className="text-gray-500 text-xs">
step helps us keep your device secure. {!node.ACLAllowsAnyIncomingTraffic ? (
// Tailnet ACLs don't allow access.
<>
The current tailnet policy file does not allow
connecting to this device.
</>
) : (
// ACLs allow access, but user can't connect.
<>
Cannot access this device's Tailscale IP. Make sure you
are connected to your tailnet, and that your policy file
allows access.
</>
)}{" "}
<a
href="https://tailscale.com/s/web-client-connection"
className="text-blue-700"
target="_blank"
rel="noreferrer"
>
Learn more &rarr;
</a>
</p>
</> </>
) : ( ) : (
// User can connect to Tailcale IP; sign in when ready.
<> <>
You can see most of this device's details. To make changes, <p className="text-gray-500 text-xs">
you need to sign in. You can see most of this device's details. To make changes,
you need to sign in.
</p>
<SignInButton auth={auth} onClick={handleSignInClick} />
</> </>
)} )}
</>
) : auth.authNeeded === AuthType.tailscale ? (
// User is connected over Tailscale, but needs to complete check mode.
<>
<p className="text-gray-500 text-xs">
To make changes, sign in to confirm your identity. This extra
step helps us keep your device secure.
</p>
<SignInButton auth={auth} onClick={handleSignInClick} />
</>
) : (
// User is connected over tailscale, but doesn't have permission to manage.
<p className="text-gray-500 text-xs">
You dont have permission to make changes to this device, but you
can view most of its details.
</p> </p>
<button )}
className={cx( </>
"w-full px-3 py-2 bg-blue-500 rounded shadow text-center text-white text-sm font-medium mt-2", )}
{
"mb-2": auth.viewerIdentity,
"cursor-not-allowed": !canConnectOverTS,
}
)}
onClick={handleSignInClick}
// TODO: add some helper info when disabled
// due to needing to connect to TS
disabled={!canConnectOverTS}
>
{auth.viewerIdentity ? "Sign in to confirm identity" : "Sign in"}
</button>
</>
) : (
<p className="text-gray-500 text-xs">
You dont have permission to make changes to this device, but you
can view most of its details.
</p>
))}
{auth.viewerIdentity && ( {auth.viewerIdentity && (
<> <>
<hr className="my-2" /> <hr className="my-2" />
@ -195,3 +220,22 @@ function LoginPopoverContent({
</div> </div>
) )
} }
function SignInButton({
auth,
onClick,
}: {
auth: AuthResponse
onClick: () => void
}) {
return (
<Button
className={cx("w-full text-sm mt-2", {
"mb-2": auth.viewerIdentity,
})}
onClick={onClick}
>
{auth.viewerIdentity ? "Sign in to confirm identity" : "Sign in"}
</Button>
)
}

View File

@ -36,6 +36,7 @@ export type NodeData = {
ControlAdminURL: string ControlAdminURL: string
LicensesURL: string LicensesURL: string
Features: { [key in Feature]: boolean } // value is true if given feature is available on this client Features: { [key in Feature]: boolean } // value is true if given feature is available on this client
ACLAllowsAnyIncomingTraffic: boolean
} }
type NodeState = type NodeState =

View File

@ -562,6 +562,9 @@ type nodeData struct {
ClientVersion *tailcfg.ClientVersion ClientVersion *tailcfg.ClientVersion
// whether tailnet ACLs allow access to port 5252 on this device
ACLAllowsAnyIncomingTraffic bool
ControlAdminURL string ControlAdminURL string
LicensesURL string LicensesURL string
@ -591,6 +594,11 @@ func (s *Server) serveGetNodeData(w http.ResponseWriter, r *http.Request) {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
} }
filterRules, err := s.lc.DebugPacketFilterRules(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
data := &nodeData{ data := &nodeData{
ID: st.Self.ID, ID: st.Self.ID,
Status: st.BackendState, Status: st.BackendState,
@ -610,6 +618,8 @@ func (s *Server) serveGetNodeData(w http.ResponseWriter, r *http.Request) {
ControlAdminURL: prefs.AdminPageURL(), ControlAdminURL: prefs.AdminPageURL(),
LicensesURL: licenses.LicensesURL(), LicensesURL: licenses.LicensesURL(),
Features: availableFeatures(), Features: availableFeatures(),
ACLAllowsAnyIncomingTraffic: s.aclsAllowAccess(filterRules),
} }
cv, err := s.lc.CheckUpdate(r.Context()) cv, err := s.lc.CheckUpdate(r.Context())
@ -692,6 +702,20 @@ func availableFeatures() map[string]bool {
} }
} }
// aclsAllowAccess returns whether tailnet ACLs (as expressed in the provided filter rules)
// permit any devices to access the local web client.
// This does not currently check whether a specific device can connect, just any device.
func (s *Server) aclsAllowAccess(rules []tailcfg.FilterRule) bool {
for _, rule := range rules {
for _, dp := range rule.DstPorts {
if dp.Ports.Contains(ListenPort) {
return true
}
}
}
return false
}
type exitNode struct { type exitNode struct {
ID tailcfg.StableNodeID ID tailcfg.StableNodeID
Name string Name string