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

// ssh-auth-none-demo is a demo SSH server that's meant to run on the
// public internet (at 188.166.70.128 port 2222) and
// highlight the unique parts of the Tailscale SSH server so SSH
// client authors can hit it easily and fix their SSH clients without
// needing to set up Tailscale and Tailscale SSH.
//
// Connections are allowed using any username except for "denyme". Connecting as
// "denyme" will result in an authentication failure with error message.
package main

import (
	"crypto/ecdsa"
	"crypto/ed25519"
	"crypto/elliptic"
	"crypto/rand"
	"crypto/rsa"
	"crypto/x509"
	"encoding/pem"
	"errors"
	"flag"
	"fmt"
	"io"
	"log"
	"os"
	"path/filepath"
	"time"

	gliderssh "github.com/tailscale/gliderssh"
	"golang.org/x/crypto/ssh"
)

// keyTypes are the SSH key types that we either try to read from the
// system's OpenSSH keys.
var keyTypes = []string{"rsa", "ecdsa", "ed25519"}

var (
	addr = flag.String("addr", ":2222", "address to listen on")
)

func main() {
	flag.Parse()

	cacheDir, err := os.UserCacheDir()
	if err != nil {
		log.Fatal(err)
	}
	dir := filepath.Join(cacheDir, "ssh-auth-none-demo")
	if err := os.MkdirAll(dir, 0700); err != nil {
		log.Fatal(err)
	}

	keys, err := getHostKeys(dir)
	if err != nil {
		log.Fatal(err)
	}
	if len(keys) == 0 {
		log.Fatal("no host keys")
	}

	srv := &gliderssh.Server{
		Addr:    *addr,
		Version: "Tailscale",
		Handler: handleSessionPostSSHAuth,
		ServerConfigCallback: func(ctx gliderssh.Context) *ssh.ServerConfig {
			start := time.Now()
			var spac ssh.ServerPreAuthConn
			return &ssh.ServerConfig{
				PreAuthConnCallback: func(conn ssh.ServerPreAuthConn) {
					spac = conn
				},
				NoClientAuth: true, // required for the NoClientAuthCallback to run
				NoClientAuthCallback: func(cm ssh.ConnMetadata) (*ssh.Permissions, error) {
					spac.SendAuthBanner(fmt.Sprintf("# Banner: doing none auth at %v\r\n", time.Since(start)))

					if cm.User() == "denyme" {
						return nil, &ssh.BannerError{
							Err:     errors.New("denying access"),
							Message: "denyme is not allowed to access this machine\n",
						}
					}

					totalBanners := 2
					if cm.User() == "banners" {
						totalBanners = 5
					}
					for banner := 2; banner <= totalBanners; banner++ {
						time.Sleep(time.Second)
						if banner == totalBanners {
							spac.SendAuthBanner(fmt.Sprintf("# Banner%d: access granted at %v\r\n", banner, time.Since(start)))
						} else {
							spac.SendAuthBanner(fmt.Sprintf("# Banner%d at %v\r\n", banner, time.Since(start)))
						}
					}
					return nil, nil
				},
				BannerCallback: func(cm ssh.ConnMetadata) string {
					log.Printf("Got connection from user %q, %q from %v", cm.User(), cm.ClientVersion(), cm.RemoteAddr())
					return fmt.Sprintf("# Banner for user %q, %q\n", cm.User(), cm.ClientVersion())
				},
			}
		},
	}

	for _, signer := range keys {
		srv.AddHostKey(signer)
	}

	log.Printf("Running on %s ...", srv.Addr)
	if err := srv.ListenAndServe(); err != nil {
		log.Fatal(err)
	}
	log.Printf("done")
}

func handleSessionPostSSHAuth(s gliderssh.Session) {
	log.Printf("Started session from user %q", s.User())
	fmt.Fprintf(s, "Hello user %q, it worked.\n", s.User())

	// Abort the session on Control-C or Control-D.
	go func() {
		buf := make([]byte, 1024)
		for {
			n, err := s.Read(buf)
			for _, b := range buf[:n] {
				if b <= 4 { // abort on Control-C (3) or Control-D (4)
					io.WriteString(s, "bye\n")
					s.Exit(1)
				}
			}
			if err != nil {
				return
			}
		}
	}()

	for i := 10; i > 0; i-- {
		fmt.Fprintf(s, "%v ...\n", i)
		time.Sleep(time.Second)
	}
	s.Exit(0)
}

func getHostKeys(dir string) (ret []gliderssh.Signer, err error) {
	for _, typ := range keyTypes {
		hostKey, err := hostKeyFileOrCreate(dir, typ)
		if err != nil {
			return nil, err
		}
		signer, err := ssh.ParsePrivateKey(hostKey)
		if err != nil {
			return nil, err
		}
		ret = append(ret, signer)
	}
	return ret, nil
}

func hostKeyFileOrCreate(keyDir, typ string) ([]byte, error) {
	path := filepath.Join(keyDir, "ssh_host_"+typ+"_key")
	v, err := os.ReadFile(path)
	if err == nil {
		return v, nil
	}
	if !os.IsNotExist(err) {
		return nil, err
	}
	var priv any
	switch typ {
	default:
		return nil, fmt.Errorf("unsupported key type %q", typ)
	case "ed25519":
		_, priv, err = ed25519.GenerateKey(rand.Reader)
	case "ecdsa":
		// curve is arbitrary. We pick whatever will at
		// least pacify clients as the actual encryption
		// doesn't matter: it's all over WireGuard anyway.
		curve := elliptic.P256()
		priv, err = ecdsa.GenerateKey(curve, rand.Reader)
	case "rsa":
		// keySize is arbitrary. We pick whatever will at
		// least pacify clients as the actual encryption
		// doesn't matter: it's all over WireGuard anyway.
		const keySize = 2048
		priv, err = rsa.GenerateKey(rand.Reader, keySize)
	}
	if err != nil {
		return nil, err
	}
	mk, err := x509.MarshalPKCS8PrivateKey(priv)
	if err != nil {
		return nil, err
	}
	pemGen := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: mk})
	err = os.WriteFile(path, pemGen, 0700)
	return pemGen, err
}
