// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0

package api

import (
	"context"
	"crypto/tls"
	"crypto/x509"
	"fmt"
	"io/ioutil"
	"net/http"
	"os"

	"github.com/hashicorp/errwrap"
	cleanhttp "github.com/hashicorp/go-cleanhttp"
	multierror "github.com/hashicorp/go-multierror"
	rootcerts "github.com/hashicorp/go-rootcerts"
	"github.com/hashicorp/hcl"
	"github.com/hashicorp/hcl/hcl/ast"
	"github.com/mitchellh/mapstructure"
)

const (
	// SSHHelperDefaultMountPoint is the default path at which SSH backend will be
	// mounted in the Vault server.
	SSHHelperDefaultMountPoint = "ssh"

	// VerifyEchoRequest is the echo request message sent as OTP by the helper.
	VerifyEchoRequest = "verify-echo-request"

	// VerifyEchoResponse is the echo response message sent as a response to OTP
	// matching echo request.
	VerifyEchoResponse = "verify-echo-response"
)

// SSHHelper is a structure representing a vault-ssh-helper which can talk to vault server
// in order to verify the OTP entered by the user. It contains the path at which
// SSH backend is mounted at the server.
type SSHHelper struct {
	c          *Client
	MountPoint string
}

// SSHVerifyResponse is a structure representing the fields in Vault server's
// response.
type SSHVerifyResponse struct {
	// Usually empty. If the request OTP is echo request message, this will
	// be set to the corresponding echo response message.
	Message string `json:"message" mapstructure:"message"`

	// Username associated with the OTP
	Username string `json:"username" mapstructure:"username"`

	// IP associated with the OTP
	IP string `json:"ip" mapstructure:"ip"`

	// Name of the role against which the OTP was issued
	RoleName string `json:"role_name" mapstructure:"role_name"`
}

// SSHHelperConfig is a structure which represents the entries from the vault-ssh-helper's configuration file.
type SSHHelperConfig struct {
	VaultAddr       string `hcl:"vault_addr"`
	SSHMountPoint   string `hcl:"ssh_mount_point"`
	Namespace       string `hcl:"namespace"`
	CACert          string `hcl:"ca_cert"`
	CAPath          string `hcl:"ca_path"`
	AllowedCidrList string `hcl:"allowed_cidr_list"`
	AllowedRoles    string `hcl:"allowed_roles"`
	TLSSkipVerify   bool   `hcl:"tls_skip_verify"`
	TLSServerName   string `hcl:"tls_server_name"`
}

// SetTLSParameters sets the TLS parameters for this SSH agent.
func (c *SSHHelperConfig) SetTLSParameters(clientConfig *Config, certPool *x509.CertPool) {
	tlsConfig := &tls.Config{
		InsecureSkipVerify: c.TLSSkipVerify,
		MinVersion:         tls.VersionTLS12,
		RootCAs:            certPool,
		ServerName:         c.TLSServerName,
	}

	transport := cleanhttp.DefaultTransport()
	transport.TLSClientConfig = tlsConfig
	clientConfig.HttpClient.Transport = transport
}

// Returns true if any of the following conditions are true:
//   - CA cert is configured
//   - CA path is configured
//   - configured to skip certificate verification
//   - TLS server name is configured
func (c *SSHHelperConfig) shouldSetTLSParameters() bool {
	return c.CACert != "" || c.CAPath != "" || c.TLSServerName != "" || c.TLSSkipVerify
}

// NewClient returns a new client for the configuration. This client will be used by the
// vault-ssh-helper to communicate with Vault server and verify the OTP entered by user.
// If the configuration supplies Vault SSL certificates, then the client will
// have TLS configured in its transport.
func (c *SSHHelperConfig) NewClient() (*Client, error) {
	// Creating a default client configuration for communicating with vault server.
	clientConfig := DefaultConfig()

	// Pointing the client to the actual address of vault server.
	clientConfig.Address = c.VaultAddr

	// Check if certificates are provided via config file.
	if c.shouldSetTLSParameters() {
		rootConfig := &rootcerts.Config{
			CAFile: c.CACert,
			CAPath: c.CAPath,
		}
		certPool, err := rootcerts.LoadCACerts(rootConfig)
		if err != nil {
			return nil, err
		}
		// Enable TLS on the HTTP client information
		c.SetTLSParameters(clientConfig, certPool)
	}

	// Creating the client object for the given configuration
	client, err := NewClient(clientConfig)
	if err != nil {
		return nil, err
	}

	// Configure namespace
	if c.Namespace != "" {
		client.SetNamespace(c.Namespace)
	}

	return client, nil
}

// LoadSSHHelperConfig loads ssh-helper's configuration from the file and populates the corresponding
// in-memory structure.
//
// Vault address is a required parameter.
// Mount point defaults to "ssh".
func LoadSSHHelperConfig(path string) (*SSHHelperConfig, error) {
	contents, err := ioutil.ReadFile(path)
	if err != nil && !os.IsNotExist(err) {
		return nil, multierror.Prefix(err, "ssh_helper:")
	}
	return ParseSSHHelperConfig(string(contents))
}

// ParseSSHHelperConfig parses the given contents as a string for the SSHHelper
// configuration.
func ParseSSHHelperConfig(contents string) (*SSHHelperConfig, error) {
	// TODO (HCL_DUP_KEYS_DEPRECATION): replace with simple call to hcl.Parse once deprecation of duplicate attributes
	// is over, for now just ignore duplicates
	root, _, err := parseAndCheckForDuplicateHclAttributes(contents)
	if err != nil {
		return nil, errwrap.Wrapf("error parsing config: {{err}}", err)
	}

	list, ok := root.Node.(*ast.ObjectList)
	if !ok {
		return nil, fmt.Errorf("error parsing config: file doesn't contain a root object")
	}

	valid := []string{
		"vault_addr",
		"ssh_mount_point",
		"namespace",
		"ca_cert",
		"ca_path",
		"allowed_cidr_list",
		"allowed_roles",
		"tls_skip_verify",
		"tls_server_name",
	}
	if err := CheckHCLKeys(list, valid); err != nil {
		return nil, multierror.Prefix(err, "ssh_helper:")
	}

	var c SSHHelperConfig
	c.SSHMountPoint = SSHHelperDefaultMountPoint
	if err := hcl.DecodeObject(&c, list); err != nil {
		return nil, multierror.Prefix(err, "ssh_helper:")
	}

	if c.VaultAddr == "" {
		return nil, fmt.Errorf(`missing config "vault_addr"`)
	}
	return &c, nil
}

func CheckHCLKeys(node ast.Node, valid []string) error {
	var list *ast.ObjectList
	switch n := node.(type) {
	case *ast.ObjectList:
		list = n
	case *ast.ObjectType:
		list = n.List
	default:
		return fmt.Errorf("cannot check HCL keys of type %T", n)
	}

	validMap := make(map[string]struct{}, len(valid))
	for _, v := range valid {
		validMap[v] = struct{}{}
	}

	var result error
	for _, item := range list.Items {
		key := item.Keys[0].Token.Value().(string)
		if _, ok := validMap[key]; !ok {
			result = multierror.Append(result, fmt.Errorf("invalid key %q on line %d", key, item.Assign.Line))
		}
	}

	return result
}

// SSHHelper creates an SSHHelper object which can talk to Vault server with SSH backend
// mounted at default path ("ssh").
func (c *Client) SSHHelper() *SSHHelper {
	return c.SSHHelperWithMountPoint(SSHHelperDefaultMountPoint)
}

// SSHHelperWithMountPoint creates an SSHHelper object which can talk to Vault server with SSH backend
// mounted at a specific mount point.
func (c *Client) SSHHelperWithMountPoint(mountPoint string) *SSHHelper {
	return &SSHHelper{
		c:          c,
		MountPoint: mountPoint,
	}
}

// Verify verifies if the key provided by user is present in Vault server. The response
// will contain the IP address and username associated with the OTP. In case the
// OTP matches the echo request message, instead of searching an entry for the OTP,
// an echo response message is returned. This feature is used by ssh-helper to verify if
// its configured correctly.
func (c *SSHHelper) Verify(otp string) (*SSHVerifyResponse, error) {
	return c.VerifyWithContext(context.Background(), otp)
}

// VerifyWithContext the same as Verify but with a custom context.
func (c *SSHHelper) VerifyWithContext(ctx context.Context, otp string) (*SSHVerifyResponse, error) {
	ctx, cancelFunc := c.c.withConfiguredTimeout(ctx)
	defer cancelFunc()

	data := map[string]interface{}{
		"otp": otp,
	}
	verifyPath := fmt.Sprintf("/v1/%s/verify", c.MountPoint)
	r := c.c.NewRequest(http.MethodPut, verifyPath)
	if err := r.SetJSONBody(data); err != nil {
		return nil, err
	}

	resp, err := c.c.rawRequestWithContext(ctx, r)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()

	secret, err := ParseSecret(resp.Body)
	if err != nil {
		return nil, err
	}

	if secret.Data == nil {
		return nil, nil
	}

	var verifyResp SSHVerifyResponse
	err = mapstructure.Decode(secret.Data, &verifyResp)
	if err != nil {
		return nil, err
	}
	return &verifyResp, nil
}
