// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package ipn

import (
	"bytes"
	"encoding/json"
	"errors"
	"fmt"
	"log"
	"net/netip"
	"os"
	"path/filepath"
	"reflect"
	"runtime"
	"strings"

	"tailscale.com/atomicfile"
	"tailscale.com/ipn/ipnstate"
	"tailscale.com/net/netaddr"
	"tailscale.com/net/tsaddr"
	"tailscale.com/tailcfg"
	"tailscale.com/types/persist"
	"tailscale.com/types/preftype"
	"tailscale.com/util/dnsname"
)

//go:generate go run tailscale.com/cmd/viewer -type=Prefs

// DefaultControlURL is the URL base of the control plane
// ("coordination server") for use when no explicit one is configured.
// The default control plane is the hosted version run by Tailscale.com.
const DefaultControlURL = "https://controlplane.tailscale.com"

var (
	// ErrExitNodeIDAlreadySet is returned from (*Prefs).SetExitNodeIP when the
	// Prefs.ExitNodeID field is already set.
	ErrExitNodeIDAlreadySet = errors.New("cannot set ExitNodeIP when ExitNodeID is already set")
)

// IsLoginServerSynonym reports whether a URL is a drop-in replacement
// for the primary Tailscale login server.
func IsLoginServerSynonym(val any) bool {
	return val == "https://login.tailscale.com" || val == "https://controlplane.tailscale.com"
}

// Prefs are the user modifiable settings of the Tailscale node agent.
type Prefs struct {
	// ControlURL is the URL of the control server to use.
	//
	// If empty, the default for new installs, DefaultControlURL
	// is used. It's set non-empty once the daemon has been started
	// for the first time.
	//
	// TODO(apenwarr): Make it safe to update this with SetPrefs().
	// Right now, you have to pass it in the initial prefs in Start(),
	// which is the only code that actually uses the ControlURL value.
	// It would be more consistent to restart controlclient
	// automatically whenever this variable changes.
	//
	// Meanwhile, you have to provide this as part of Options.Prefs or
	// Options.UpdatePrefs when calling Backend.Start().
	ControlURL string

	// RouteAll specifies whether to accept subnets advertised by
	// other nodes on the Tailscale network. Note that this does not
	// include default routes (0.0.0.0/0 and ::/0), those are
	// controlled by ExitNodeID/IP below.
	RouteAll bool

	// AllowSingleHosts specifies whether to install routes for each
	// node IP on the tailscale network, in addition to a route for
	// the whole network.
	// This corresponds to the "tailscale up --host-routes" value,
	// which defaults to true.
	//
	// TODO(danderson): why do we have this? It dumps a lot of stuff
	// into the routing table, and a single network route _should_ be
	// all that we need. But when I turn this off in my tailscaled,
	// packets stop flowing. What's up with that?
	AllowSingleHosts bool

	// ExitNodeID and ExitNodeIP specify the node that should be used
	// as an exit node for internet traffic. At most one of these
	// should be non-zero.
	//
	// The preferred way to express the chosen node is ExitNodeID, but
	// in some cases it's not possible to use that ID (e.g. in the
	// linux CLI, before tailscaled has a netmap). For those
	// situations, we allow specifying the exit node by IP, and
	// ipnlocal.LocalBackend will translate the IP into an ID when the
	// node is found in the netmap.
	//
	// If the selected exit node doesn't exist (e.g. it's not part of
	// the current tailnet), or it doesn't offer exit node services, a
	// blackhole route will be installed on the local system to
	// prevent any traffic escaping to the local network.
	ExitNodeID tailcfg.StableNodeID
	ExitNodeIP netip.Addr

	// ExitNodeAllowLANAccess indicates whether locally accessible subnets should be
	// routed directly or via the exit node.
	ExitNodeAllowLANAccess bool

	// CorpDNS specifies whether to install the Tailscale network's
	// DNS configuration, if it exists.
	CorpDNS bool

	// RunSSH bool is whether this node should run an SSH
	// server, permitting access to peers according to the
	// policies as configured by the Tailnet's admin(s).
	RunSSH bool

	// WantRunning indicates whether networking should be active on
	// this node.
	WantRunning bool

	// LoggedOut indicates whether the user intends to be logged out.
	// There are other reasons we may be logged out, including no valid
	// keys.
	// We need to remember this state so that, on next startup, we can
	// generate the "Login" vs "Connect" buttons correctly, without having
	// to contact the server to confirm our nodekey status first.
	LoggedOut bool

	// ShieldsUp indicates whether to block all incoming connections,
	// regardless of the control-provided packet filter. If false, we
	// use the packet filter as provided. If true, we block incoming
	// connections. This overrides tailcfg.Hostinfo's ShieldsUp.
	ShieldsUp bool

	// AdvertiseTags specifies groups that this node wants to join, for
	// purposes of ACL enforcement. These can be referenced from the ACL
	// security policy. Note that advertising a tag doesn't guarantee that
	// the control server will allow you to take on the rights for that
	// tag.
	AdvertiseTags []string

	// Hostname is the hostname to use for identifying the node. If
	// not set, os.Hostname is used.
	Hostname string

	// NotepadURLs is a debugging setting that opens OAuth URLs in
	// notepad.exe on Windows, rather than loading them in a browser.
	//
	// apenwarr 2020-04-29: Unfortunately this is still needed sometimes.
	// Windows' default browser setting is sometimes screwy and this helps
	// users narrow it down a bit.
	NotepadURLs bool

	// ForceDaemon specifies whether a platform that normally
	// operates in "client mode" (that is, requires an active user
	// logged in with the GUI app running) should keep running after the
	// GUI ends and/or the user logs out.
	//
	// The only current applicable platform is Windows. This
	// forced Windows to go into "server mode" where Tailscale is
	// running even with no users logged in. This might also be
	// used for macOS in the future. This setting has no effect
	// for Linux/etc, which always operate in daemon mode.
	ForceDaemon bool `json:"ForceDaemon,omitempty"`

	// Egg is a optional debug flag.
	Egg bool

	// The following block of options only have an effect on Linux.

	// AdvertiseRoutes specifies CIDR prefixes to advertise into the
	// Tailscale network as reachable through the current
	// node.
	AdvertiseRoutes []netip.Prefix

	// NoSNAT specifies whether to source NAT traffic going to
	// destinations in AdvertiseRoutes. The default is to apply source
	// NAT, which makes the traffic appear to come from the router
	// machine rather than the peer's Tailscale IP.
	//
	// Disabling SNAT requires additional manual configuration in your
	// network to route Tailscale traffic back to the subnet relay
	// machine.
	//
	// Linux-only.
	NoSNAT bool

	// NetfilterMode specifies how much to manage netfilter rules for
	// Tailscale, if at all.
	NetfilterMode preftype.NetfilterMode

	// OperatorUser is the local machine user name who is allowed to
	// operate tailscaled without being root or using sudo.
	OperatorUser string `json:",omitempty"`

	// The Persist field is named 'Config' in the file for backward
	// compatibility with earlier versions.
	// TODO(apenwarr): We should move this out of here, it's not a pref.
	//  We can maybe do that once we're sure which module should persist
	//  it (backend or frontend?)
	Persist *persist.Persist `json:"Config"`
}

// MaskedPrefs is a Prefs with an associated bitmask of which fields are set.
type MaskedPrefs struct {
	Prefs

	ControlURLSet             bool `json:",omitempty"`
	RouteAllSet               bool `json:",omitempty"`
	AllowSingleHostsSet       bool `json:",omitempty"`
	ExitNodeIDSet             bool `json:",omitempty"`
	ExitNodeIPSet             bool `json:",omitempty"`
	ExitNodeAllowLANAccessSet bool `json:",omitempty"`
	CorpDNSSet                bool `json:",omitempty"`
	RunSSHSet                 bool `json:",omitempty"`
	WantRunningSet            bool `json:",omitempty"`
	LoggedOutSet              bool `json:",omitempty"`
	ShieldsUpSet              bool `json:",omitempty"`
	AdvertiseTagsSet          bool `json:",omitempty"`
	HostnameSet               bool `json:",omitempty"`
	NotepadURLsSet            bool `json:",omitempty"`
	ForceDaemonSet            bool `json:",omitempty"`
	EggSet                    bool `json:",omitempty"`
	AdvertiseRoutesSet        bool `json:",omitempty"`
	NoSNATSet                 bool `json:",omitempty"`
	NetfilterModeSet          bool `json:",omitempty"`
	OperatorUserSet           bool `json:",omitempty"`
}

// ApplyEdits mutates p, assigning fields from m.Prefs for each MaskedPrefs
// Set field that's true.
func (p *Prefs) ApplyEdits(m *MaskedPrefs) {
	if p == nil {
		panic("can't edit nil Prefs")
	}
	pv := reflect.ValueOf(p).Elem()
	mv := reflect.ValueOf(m).Elem()
	mpv := reflect.ValueOf(&m.Prefs).Elem()
	fields := mv.NumField()
	for i := 1; i < fields; i++ {
		if mv.Field(i).Bool() {
			newFieldValue := mpv.Field(i - 1)
			pv.Field(i - 1).Set(newFieldValue)
		}
	}
}

func (m *MaskedPrefs) Pretty() string {
	if m == nil {
		return "MaskedPrefs{<nil>}"
	}
	var sb strings.Builder
	sb.WriteString("MaskedPrefs{")
	mv := reflect.ValueOf(m).Elem()
	mt := mv.Type()
	mpv := reflect.ValueOf(&m.Prefs).Elem()
	first := true

	format := func(v reflect.Value) string {
		switch v.Type().Kind() {
		case reflect.String:
			return "%s=%q"
		case reflect.Slice:
			// []string
			if v.Type().Elem().Kind() == reflect.String {
				return "%s=%q"
			}
		}
		return "%s=%v"
	}

	for i := 1; i < mt.NumField(); i++ {
		name := mt.Field(i).Name
		if mv.Field(i).Bool() {
			if !first {
				sb.WriteString(" ")
			}
			first = false
			f := mpv.Field(i - 1)
			fmt.Fprintf(&sb, format(f),
				strings.TrimSuffix(name, "Set"),
				f.Interface())
		}
	}
	sb.WriteString("}")
	return sb.String()
}

// IsEmpty reports whether p is nil or pointing to a Prefs zero value.
func (p *Prefs) IsEmpty() bool { return p == nil || p.Equals(&Prefs{}) }

func (p PrefsView) Pretty() string { return p.ж.Pretty() }

func (p *Prefs) Pretty() string { return p.pretty(runtime.GOOS) }
func (p *Prefs) pretty(goos string) string {
	var sb strings.Builder
	sb.WriteString("Prefs{")
	fmt.Fprintf(&sb, "ra=%v ", p.RouteAll)
	if !p.AllowSingleHosts {
		sb.WriteString("mesh=false ")
	}
	fmt.Fprintf(&sb, "dns=%v want=%v ", p.CorpDNS, p.WantRunning)
	if p.RunSSH {
		sb.WriteString("ssh=true ")
	}
	if p.LoggedOut {
		sb.WriteString("loggedout=true ")
	}
	if p.ForceDaemon {
		sb.WriteString("server=true ")
	}
	if p.NotepadURLs {
		sb.WriteString("notepad=true ")
	}
	if p.ShieldsUp {
		sb.WriteString("shields=true ")
	}
	if p.ExitNodeIP.IsValid() {
		fmt.Fprintf(&sb, "exit=%v lan=%t ", p.ExitNodeIP, p.ExitNodeAllowLANAccess)
	} else if !p.ExitNodeID.IsZero() {
		fmt.Fprintf(&sb, "exit=%v lan=%t ", p.ExitNodeID, p.ExitNodeAllowLANAccess)
	}
	if len(p.AdvertiseRoutes) > 0 || goos == "linux" {
		fmt.Fprintf(&sb, "routes=%v ", p.AdvertiseRoutes)
	}
	if len(p.AdvertiseRoutes) > 0 || p.NoSNAT {
		fmt.Fprintf(&sb, "snat=%v ", !p.NoSNAT)
	}
	if len(p.AdvertiseTags) > 0 {
		fmt.Fprintf(&sb, "tags=%s ", strings.Join(p.AdvertiseTags, ","))
	}
	if goos == "linux" {
		fmt.Fprintf(&sb, "nf=%v ", p.NetfilterMode)
	}
	if p.ControlURL != "" && p.ControlURL != DefaultControlURL {
		fmt.Fprintf(&sb, "url=%q ", p.ControlURL)
	}
	if p.Hostname != "" {
		fmt.Fprintf(&sb, "host=%q ", p.Hostname)
	}
	if p.OperatorUser != "" {
		fmt.Fprintf(&sb, "op=%q ", p.OperatorUser)
	}
	if p.Persist != nil {
		sb.WriteString(p.Persist.Pretty())
	} else {
		sb.WriteString("Persist=nil")
	}
	sb.WriteString("}")
	return sb.String()
}

func (p PrefsView) ToBytes() []byte {
	return p.ж.ToBytes()
}

func (p *Prefs) ToBytes() []byte {
	data, err := json.MarshalIndent(p, "", "\t")
	if err != nil {
		log.Fatalf("Prefs marshal: %v\n", err)
	}
	return data
}

func (p PrefsView) Equals(p2 PrefsView) bool {
	return p.ж.Equals(p2.ж)
}

func (p *Prefs) Equals(p2 *Prefs) bool {
	if p == nil && p2 == nil {
		return true
	}
	if p == nil || p2 == nil {
		return false
	}

	return p != nil && p2 != nil &&
		p.ControlURL == p2.ControlURL &&
		p.RouteAll == p2.RouteAll &&
		p.AllowSingleHosts == p2.AllowSingleHosts &&
		p.ExitNodeID == p2.ExitNodeID &&
		p.ExitNodeIP == p2.ExitNodeIP &&
		p.ExitNodeAllowLANAccess == p2.ExitNodeAllowLANAccess &&
		p.CorpDNS == p2.CorpDNS &&
		p.RunSSH == p2.RunSSH &&
		p.WantRunning == p2.WantRunning &&
		p.LoggedOut == p2.LoggedOut &&
		p.NotepadURLs == p2.NotepadURLs &&
		p.ShieldsUp == p2.ShieldsUp &&
		p.NoSNAT == p2.NoSNAT &&
		p.NetfilterMode == p2.NetfilterMode &&
		p.OperatorUser == p2.OperatorUser &&
		p.Hostname == p2.Hostname &&
		p.ForceDaemon == p2.ForceDaemon &&
		compareIPNets(p.AdvertiseRoutes, p2.AdvertiseRoutes) &&
		compareStrings(p.AdvertiseTags, p2.AdvertiseTags) &&
		p.Persist.Equals(p2.Persist)
}

func compareIPNets(a, b []netip.Prefix) bool {
	if len(a) != len(b) {
		return false
	}
	for i := range a {
		if a[i] != b[i] {
			return false
		}
	}
	return true
}

func compareStrings(a, b []string) bool {
	if len(a) != len(b) {
		return false
	}
	for i := range a {
		if a[i] != b[i] {
			return false
		}
	}
	return true
}

// NewPrefs returns the default preferences to use.
func NewPrefs() *Prefs {
	// Provide default values for options which might be missing
	// from the json data for any reason. The json can still
	// override them to false.
	return &Prefs{
		// ControlURL is explicitly not set to signal that
		// it's not yet configured, which relaxes the CLI "up"
		// safety net features. It will get set to DefaultControlURL
		// on first up. Or, if not, DefaultControlURL will be used
		// later anyway.
		ControlURL: "",

		RouteAll:         true,
		AllowSingleHosts: true,
		CorpDNS:          true,
		WantRunning:      false,
		NetfilterMode:    preftype.NetfilterOn,
	}
}

// ControlURLOrDefault returns the coordination server's URL base.
//
// If not configured, or if the configured value is a legacy name equivalent to
// the default, then DefaultControlURL is returned instead.
func (p PrefsView) ControlURLOrDefault() string {
	return p.ж.ControlURLOrDefault()
}

// ControlURLOrDefault returns the coordination server's URL base.
//
// If not configured, or if the configured value is a legacy name equivalent to
// the default, then DefaultControlURL is returned instead.
func (p *Prefs) ControlURLOrDefault() string {
	if p.ControlURL != "" {
		if p.ControlURL != DefaultControlURL && IsLoginServerSynonym(p.ControlURL) {
			return DefaultControlURL
		}
		return p.ControlURL
	}
	return DefaultControlURL
}

// AdminPageURL returns the admin web site URL for the current ControlURL.
func (p *Prefs) AdminPageURL() string {
	url := p.ControlURLOrDefault()
	if IsLoginServerSynonym(url) {
		// TODO(crawshaw): In future release, make this https://console.tailscale.com
		url = "https://login.tailscale.com"
	}
	return url + "/admin/machines"
}

// AdvertisesExitNode reports whether p is advertising both the v4 and
// v6 /0 exit node routes.
func (p *Prefs) AdvertisesExitNode() bool {
	if p == nil {
		return false
	}
	return tsaddr.ContainsExitRoutes(p.AdvertiseRoutes)
}

// SetAdvertiseExitNode mutates p (if non-nil) to add or remove the two
// /0 exit node routes.
func (p *Prefs) SetAdvertiseExitNode(runExit bool) {
	if p == nil {
		return
	}
	all := p.AdvertiseRoutes
	p.AdvertiseRoutes = p.AdvertiseRoutes[:0]
	for _, r := range all {
		if r.Bits() != 0 {
			p.AdvertiseRoutes = append(p.AdvertiseRoutes, r)
		}
	}
	if !runExit {
		return
	}
	p.AdvertiseRoutes = append(p.AdvertiseRoutes,
		netip.PrefixFrom(netaddr.IPv4(0, 0, 0, 0), 0),
		netip.PrefixFrom(netip.IPv6Unspecified(), 0))
}

// peerWithTailscaleIP returns the peer in st with the provided
// Tailscale IP.
func peerWithTailscaleIP(st *ipnstate.Status, ip netip.Addr) (ps *ipnstate.PeerStatus, ok bool) {
	for _, ps := range st.Peer {
		for _, ip2 := range ps.TailscaleIPs {
			if ip == ip2 {
				return ps, true
			}
		}
	}
	return nil, false
}

func isRemoteIP(st *ipnstate.Status, ip netip.Addr) bool {
	for _, selfIP := range st.TailscaleIPs {
		if ip == selfIP {
			return false
		}
	}
	return true
}

// ClearExitNode sets the ExitNodeID and ExitNodeIP to their zero values.
func (p *Prefs) ClearExitNode() {
	p.ExitNodeID = ""
	p.ExitNodeIP = netip.Addr{}
}

// ExitNodeLocalIPError is returned when the requested IP address for an exit
// node belongs to the local machine.
type ExitNodeLocalIPError struct {
	hostOrIP string
}

func (e ExitNodeLocalIPError) Error() string {
	return fmt.Sprintf("cannot use %s as an exit node as it is a local IP address to this machine", e.hostOrIP)
}

func exitNodeIPOfArg(s string, st *ipnstate.Status) (ip netip.Addr, err error) {
	if s == "" {
		return ip, os.ErrInvalid
	}
	ip, err = netip.ParseAddr(s)
	if err == nil {
		// If we're online already and have a netmap, double check that the IP
		// address specified is valid.
		if st.BackendState == "Running" {
			ps, ok := peerWithTailscaleIP(st, ip)
			if !ok {
				return ip, fmt.Errorf("no node found in netmap with IP %v", ip)
			}
			if !ps.ExitNodeOption {
				return ip, fmt.Errorf("node %v is not advertising an exit node", ip)
			}
		}
		if !isRemoteIP(st, ip) {
			return ip, ExitNodeLocalIPError{s}
		}
		return ip, nil
	}
	match := 0
	for _, ps := range st.Peer {
		baseName := dnsname.TrimSuffix(ps.DNSName, st.MagicDNSSuffix)
		if !strings.EqualFold(s, baseName) {
			continue
		}
		match++
		if len(ps.TailscaleIPs) == 0 {
			return ip, fmt.Errorf("node %q has no Tailscale IP?", s)
		}
		if !ps.ExitNodeOption {
			return ip, fmt.Errorf("node %q is not advertising an exit node", s)
		}
		ip = ps.TailscaleIPs[0]
	}
	switch match {
	case 0:
		return ip, fmt.Errorf("invalid value %q for --exit-node; must be IP or unique node name", s)
	case 1:
		if !isRemoteIP(st, ip) {
			return ip, ExitNodeLocalIPError{s}
		}
		return ip, nil
	default:
		return ip, fmt.Errorf("ambiguous exit node name %q", s)
	}
}

// SetExitNodeIP validates and sets the ExitNodeIP from a user-provided string
// specifying either an IP address or a MagicDNS base name ("foo", as opposed to
// "foo.bar.beta.tailscale.net"). This method does not mutate ExitNodeID and
// will fail if ExitNodeID is already set.
func (p *Prefs) SetExitNodeIP(s string, st *ipnstate.Status) error {
	if !p.ExitNodeID.IsZero() {
		return ErrExitNodeIDAlreadySet
	}
	ip, err := exitNodeIPOfArg(s, st)
	if err == nil {
		p.ExitNodeIP = ip
	}
	return err
}

// ShouldSSHBeRunning reports whether the SSH server should be running based on
// the prefs.
func (p PrefsView) ShouldSSHBeRunning() bool {
	return p.Valid() && p.ж.ShouldSSHBeRunning()
}

// ShouldSSHBeRunning reports whether the SSH server should be running based on
// the prefs.
func (p *Prefs) ShouldSSHBeRunning() bool {
	return p.WantRunning && p.RunSSH
}

// PrefsFromBytes deserializes Prefs from a JSON blob.
func PrefsFromBytes(b []byte) (*Prefs, error) {
	p := NewPrefs()
	if len(b) == 0 {
		return p, nil
	}
	persist := &persist.Persist{}
	err := json.Unmarshal(b, persist)
	if err == nil && (persist.Provider != "" || persist.LoginName != "") {
		// old-style relaynode config; import it
		p.Persist = persist
	} else {
		err = json.Unmarshal(b, &p)
		if err != nil {
			log.Printf("Prefs parse: %v: %v\n", err, b)
		}
	}
	return p, err
}

// LoadPrefs loads a legacy relaynode config file into Prefs
// with sensible migration defaults set.
func LoadPrefs(filename string) (*Prefs, error) {
	data, err := os.ReadFile(filename)
	if err != nil {
		return nil, fmt.Errorf("LoadPrefs open: %w", err) // err includes path
	}
	if bytes.Contains(data, jsonEscapedZero) {
		// Tailscale 1.2.0 - 1.2.8 on Windows had a memory corruption bug
		// in the backend process that ended up sending NULL bytes over JSON
		// to the frontend which wrote them out to JSON files on disk.
		// So if we see one, treat is as corrupt and the user will need
		// to log in again. (better than crashing)
		return nil, os.ErrNotExist
	}
	p, err := PrefsFromBytes(data)
	if err != nil {
		return nil, fmt.Errorf("LoadPrefs(%q) decode: %w", filename, err)
	}
	return p, nil
}

func SavePrefs(filename string, p *Prefs) {
	log.Printf("Saving prefs %v %v\n", filename, p.Pretty())
	data := p.ToBytes()
	os.MkdirAll(filepath.Dir(filename), 0700)
	if err := atomicfile.WriteFile(filename, data, 0600); err != nil {
		log.Printf("SavePrefs: %v\n", err)
	}
}
