// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause

// Package hostinfo answers questions about the host environment that Tailscale is
// running on.
package hostinfo

import (
	"bufio"
	"bytes"
	"io"
	"os"
	"os/exec"
	"runtime"
	"runtime/debug"
	"strings"
	"sync"
	"sync/atomic"
	"time"

	"go4.org/mem"
	"tailscale.com/envknob"
	"tailscale.com/tailcfg"
	"tailscale.com/types/lazy"
	"tailscale.com/types/opt"
	"tailscale.com/util/cloudenv"
	"tailscale.com/util/dnsname"
	"tailscale.com/util/lineiter"
	"tailscale.com/version"
	"tailscale.com/version/distro"
)

var started = time.Now()

var newHooks []func(*tailcfg.Hostinfo)

// RegisterHostinfoNewHook registers a callback to be called on a non-nil
// [tailcfg.Hostinfo] before it is returned by [New].
func RegisterHostinfoNewHook(f func(*tailcfg.Hostinfo)) {
	newHooks = append(newHooks, f)
}

// New returns a partially populated Hostinfo for the current host.
func New() *tailcfg.Hostinfo {
	hostname, _ := Hostname()
	hostname = dnsname.FirstLabel(hostname)
	hi := &tailcfg.Hostinfo{
		IPNVersion:      version.Long(),
		Hostname:        hostname,
		App:             appTypeCached(),
		OS:              version.OS(),
		OSVersion:       GetOSVersion(),
		Container:       lazyInContainer.Get(),
		Distro:          condCall(distroName),
		DistroVersion:   condCall(distroVersion),
		DistroCodeName:  condCall(distroCodeName),
		Env:             string(GetEnvType()),
		Desktop:         desktop(),
		Package:         packageTypeCached(),
		GoArch:          runtime.GOARCH,
		GoArchVar:       lazyGoArchVar.Get(),
		GoVersion:       runtime.Version(),
		Machine:         condCall(unameMachine),
		DeviceModel:     deviceModelCached(),
		Cloud:           string(cloudenv.Get()),
		NoLogsNoSupport: envknob.NoLogsNoSupport(),
		AllowsUpdate:    envknob.AllowsRemoteUpdate(),
	}
	for _, f := range newHooks {
		f(hi)
	}
	return hi
}

// non-nil on some platforms
var (
	osVersion      func() string
	packageType    func() string
	distroName     func() string
	distroVersion  func() string
	distroCodeName func() string
	unameMachine   func() string
	deviceModel    func() string
)

func condCall[T any](fn func() T) T {
	var zero T
	if fn == nil {
		return zero
	}
	return fn()
}

var (
	lazyInContainer = &lazyAtomicValue[opt.Bool]{f: new(inContainer)}
	lazyGoArchVar   = &lazyAtomicValue[string]{f: new(goArchVar)}
)

type lazyAtomicValue[T any] struct {
	// f is a pointer to a fill function. If it's nil or points
	// to nil, then Get returns the zero value for T.
	f *func() T

	once sync.Once
	v    T
}

func (v *lazyAtomicValue[T]) Get() T {
	v.once.Do(v.fill)
	return v.v
}

func (v *lazyAtomicValue[T]) fill() {
	if v.f == nil || *v.f == nil {
		return
	}
	v.v = (*v.f)()
}

// GetOSVersion returns the OSVersion of current host if available.
func GetOSVersion() string {
	if s, _ := osVersionAtomic.Load().(string); s != "" {
		return s
	}
	if osVersion != nil {
		return osVersion()
	}
	return ""
}

func appTypeCached() string {
	if v, ok := appType.Load().(string); ok {
		return v
	}
	return ""
}

func packageTypeCached() string {
	if v, _ := packagingType.Load().(string); v != "" {
		return v
	}
	if packageType == nil {
		return ""
	}
	v := packageType()
	if v != "" {
		SetPackage(v)
	}
	return v
}

// EnvType represents a known environment type.
// The empty string, the default, means unknown.
type EnvType string

const (
	KNative            = EnvType("kn")
	AWSLambda          = EnvType("lm")
	Heroku             = EnvType("hr")
	AzureAppService    = EnvType("az")
	AWSFargate         = EnvType("fg")
	FlyDotIo           = EnvType("fly")
	Kubernetes         = EnvType("k8s")
	DockerDesktop      = EnvType("dde")
	Replit             = EnvType("repl")
	HomeAssistantAddOn = EnvType("haao")
)

var envType atomic.Value // of EnvType

func GetEnvType() EnvType {
	if e, ok := envType.Load().(EnvType); ok {
		return e
	}
	e := getEnvType()
	envType.Store(e)
	return e
}

var (
	deviceModelAtomic atomic.Value // of string
	osVersionAtomic   atomic.Value // of string
	desktopAtomic     atomic.Value // of opt.Bool
	packagingType     atomic.Value // of string
	appType           atomic.Value // of string
	firewallMode      atomic.Value // of string
)

// SetDeviceModel sets the device model for use in Hostinfo updates.
func SetDeviceModel(model string) { deviceModelAtomic.Store(model) }

func deviceModelCached() string {
	if v, _ := deviceModelAtomic.Load().(string); v != "" {
		return v
	}
	if deviceModel == nil {
		return ""
	}
	v := deviceModel()
	if v != "" {
		deviceModelAtomic.Store(v)
	}
	return v
}

// SetOSVersion sets the OS version.
func SetOSVersion(v string) { osVersionAtomic.Store(v) }

// SetFirewallMode sets the firewall mode for the app.
func SetFirewallMode(v string) { firewallMode.Store(v) }

// SetPackage sets the packaging type for the app.
//
// For Android, the possible values are:
// - "googleplay": installed from Google Play Store.
// - "fdroid": installed from the F-Droid repository.
// - "amazon": installed from the Amazon Appstore.
// - "unknown": when the installer package name is null.
// - "unknown$installerPackageName": for unrecognized installer package names, prefixed by "unknown".
// Additionally, tsnet sets this value to "tsnet".
func SetPackage(v string) { packagingType.Store(v) }

// SetApp sets the app type for the app.
// It is used by tsnet to specify what app is using it such as "golinks"
// and "k8s-operator".
func SetApp(v string) { appType.Store(v) }

// FirewallMode returns the firewall mode for the app.
// It is empty if unset.
func FirewallMode() string {
	s, _ := firewallMode.Load().(string)
	return s
}

func desktop() (ret opt.Bool) {
	if runtime.GOOS != "linux" {
		return opt.Bool("")
	}
	if v := desktopAtomic.Load(); v != nil {
		v, _ := v.(opt.Bool)
		return v
	}

	seenDesktop := false
	for lr := range lineiter.File("/proc/net/unix") {
		line, _ := lr.Value()
		seenDesktop = seenDesktop || mem.Contains(mem.B(line), mem.S(".X11-unix"))
		seenDesktop = seenDesktop || mem.Contains(mem.B(line), mem.S("/wayland-1"))
	}
	ret.Set(seenDesktop)

	// Only cache after a minute - compositors might not have started yet.
	if time.Since(started) > time.Minute {
		desktopAtomic.Store(ret)
	}
	return ret
}

func getEnvType() EnvType {
	if inKnative() {
		return KNative
	}
	if inAWSLambda() {
		return AWSLambda
	}
	if inHerokuDyno() {
		return Heroku
	}
	if inAzureAppService() {
		return AzureAppService
	}
	if inAWSFargate() {
		return AWSFargate
	}
	if inFlyDotIo() {
		return FlyDotIo
	}
	if inKubernetes() {
		return Kubernetes
	}
	if inDockerDesktop() {
		return DockerDesktop
	}
	if inReplit() {
		return Replit
	}
	if inHomeAssistantAddOn() {
		return HomeAssistantAddOn
	}
	return ""
}

// inContainer reports whether we're running in a container. Best-effort only,
// there's no foolproof way to detect this, but the build tag should catch all
// official builds from 1.78.0.
func inContainer() opt.Bool {
	if runtime.GOOS != "linux" {
		return ""
	}
	var ret opt.Bool
	ret.Set(false)
	if packageType != nil && packageType() == "container" {
		// Go build tag ts_package_container was set during build.
		ret.Set(true)
		return ret
	}
	// Only set if using docker's container runtime. Not guaranteed by
	// documentation, but it's been in place for a long time.
	if _, err := os.Stat("/.dockerenv"); err == nil {
		ret.Set(true)
		return ret
	}
	if _, err := os.Stat("/run/.containerenv"); err == nil {
		// See https://github.com/cri-o/cri-o/issues/5461
		ret.Set(true)
		return ret
	}
	for lr := range lineiter.File("/proc/1/cgroup") {
		line, _ := lr.Value()
		if mem.Contains(mem.B(line), mem.S("/docker/")) ||
			mem.Contains(mem.B(line), mem.S("/lxc/")) {
			ret.Set(true)
			break
		}
	}
	for lr := range lineiter.File("/proc/mounts") {
		line, _ := lr.Value()
		if mem.Contains(mem.B(line), mem.S("lxcfs /proc/cpuinfo fuse.lxcfs")) {
			ret.Set(true)
			break
		}
	}
	return ret
}

func inKnative() bool {
	// https://cloud.google.com/run/docs/reference/container-contract#env-vars
	if os.Getenv("K_REVISION") != "" && os.Getenv("K_CONFIGURATION") != "" &&
		os.Getenv("K_SERVICE") != "" && os.Getenv("PORT") != "" {
		return true
	}
	return false
}

func inAWSLambda() bool {
	// https://docs.aws.amazon.com/lambda/latest/dg/configuration-envvars.html
	if os.Getenv("AWS_LAMBDA_FUNCTION_NAME") != "" &&
		os.Getenv("AWS_LAMBDA_FUNCTION_VERSION") != "" &&
		os.Getenv("AWS_LAMBDA_INITIALIZATION_TYPE") != "" &&
		os.Getenv("AWS_LAMBDA_RUNTIME_API") != "" {
		return true
	}
	return false
}

func inHerokuDyno() bool {
	// https://devcenter.heroku.com/articles/dynos#local-environment-variables
	if os.Getenv("PORT") != "" && os.Getenv("DYNO") != "" {
		return true
	}
	return false
}

func inAzureAppService() bool {
	if os.Getenv("APPSVC_RUN_ZIP") != "" && os.Getenv("WEBSITE_STACK") != "" &&
		os.Getenv("WEBSITE_AUTH_AUTO_AAD") != "" {
		return true
	}
	return false
}

func inAWSFargate() bool {
	return os.Getenv("AWS_EXECUTION_ENV") == "AWS_ECS_FARGATE"
}

func inFlyDotIo() bool {
	if os.Getenv("FLY_APP_NAME") != "" && os.Getenv("FLY_REGION") != "" {
		return true
	}
	return false
}

func inReplit() bool {
	// https://docs.replit.com/replit-workspace/configuring-repl#environment-variables
	if os.Getenv("REPL_OWNER") != "" && os.Getenv("REPL_SLUG") != "" {
		return true
	}
	return false
}

func inKubernetes() bool {
	if os.Getenv("KUBERNETES_SERVICE_HOST") != "" && os.Getenv("KUBERNETES_SERVICE_PORT") != "" {
		return true
	}
	return false
}

func inDockerDesktop() bool {
	return os.Getenv("TS_HOST_ENV") == "dde"
}

func inHomeAssistantAddOn() bool {
	if os.Getenv("SUPERVISOR_TOKEN") != "" || os.Getenv("HASSIO_TOKEN") != "" {
		return true
	}
	return false
}

// goArchVar returns the GOARM or GOAMD64 etc value that the binary was built
// with.
func goArchVar() string {
	bi, ok := debug.ReadBuildInfo()
	if !ok {
		return ""
	}
	// Look for GOARM, GOAMD64, GO386, etc. Note that the little-endian
	// "le"-suffixed GOARCH values don't have their own environment variable.
	//
	// See https://pkg.go.dev/cmd/go#hdr-Environment_variables and the
	// "Architecture-specific environment variables" section:
	wantKey := "GO" + strings.ToUpper(strings.TrimSuffix(runtime.GOARCH, "le"))
	for _, s := range bi.Settings {
		if s.Key == wantKey {
			return s.Value
		}
	}
	return ""
}

type etcAptSrcResult struct {
	mod      time.Time
	disabled bool
}

var etcAptSrcCache atomic.Value // of etcAptSrcResult

// DisabledEtcAptSource reports whether Ubuntu (or similar) has disabled
// the /etc/apt/sources.list.d/tailscale.list file contents upon upgrade
// to a new release of the distro.
//
// See https://github.com/tailscale/tailscale/issues/3177
func DisabledEtcAptSource() bool {
	if runtime.GOOS != "linux" {
		return false
	}
	const path = "/etc/apt/sources.list.d/tailscale.list"
	fi, err := os.Stat(path)
	if err != nil || !fi.Mode().IsRegular() {
		return false
	}
	mod := fi.ModTime()
	if c, ok := etcAptSrcCache.Load().(etcAptSrcResult); ok && c.mod.Equal(mod) {
		return c.disabled
	}
	f, err := os.Open(path)
	if err != nil {
		return false
	}
	defer f.Close()
	v := etcAptSourceFileIsDisabled(f)
	etcAptSrcCache.Store(etcAptSrcResult{mod: mod, disabled: v})
	return v
}

func etcAptSourceFileIsDisabled(r io.Reader) bool {
	bs := bufio.NewScanner(r)
	disabled := false // did we find the "disabled on upgrade" comment?
	for bs.Scan() {
		line := strings.TrimSpace(bs.Text())
		if strings.Contains(line, "# disabled on upgrade") {
			disabled = true
		}
		if line == "" || line[0] == '#' {
			continue
		}
		// Well, it has some contents in it at least.
		return false
	}
	return disabled
}

// IsSELinuxEnforcing reports whether SELinux is in "Enforcing" mode.
func IsSELinuxEnforcing() bool {
	if runtime.GOOS != "linux" {
		return false
	}
	out, _ := exec.Command("getenforce").Output()
	return string(bytes.TrimSpace(out)) == "Enforcing"
}

// IsNATLabGuestVM reports whether the current host is a NAT Lab guest VM.
func IsNATLabGuestVM() bool {
	if runtime.GOOS == "linux" && distro.Get() == distro.Gokrazy {
		cmdLine, _ := os.ReadFile("/proc/cmdline")
		return bytes.Contains(cmdLine, []byte("tailscale-tta=1"))
	}
	return false
}

const copyV86DeviceModel = "copy-v86"

var isV86Cache lazy.SyncValue[bool]

// IsInVM86 reports whether we're running in the copy/v86 wasm emulator,
// https://github.com/copy/v86/.
func IsInVM86() bool {
	return isV86Cache.Get(func() bool {
		return New().DeviceModel == copyV86DeviceModel
	})
}

type hostnameQuery func() (string, error)

var hostnameFn atomic.Value // of func() (string, error)

// SetHostNameFn sets a custom function for querying the system hostname.
func SetHostnameFn(fn hostnameQuery) {
	hostnameFn.Store(fn)
}

// Hostname returns the system hostname using the function
// set by SetHostNameFn.  We will fallback to os.Hostname.
func Hostname() (string, error) {
	if fn, ok := hostnameFn.Load().(hostnameQuery); ok && fn != nil {
		return fn()
	}
	return os.Hostname()
}
