ssh.go 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352
  1. // Copyright (c) Tailscale Inc & contributors
  2. // SPDX-License-Identifier: BSD-3-Clause
  3. package e2e
  4. import (
  5. "context"
  6. "crypto/ed25519"
  7. "crypto/rand"
  8. "encoding/hex"
  9. "encoding/pem"
  10. "fmt"
  11. "io"
  12. "net"
  13. "os"
  14. "path/filepath"
  15. "time"
  16. "go.uber.org/zap"
  17. "golang.org/x/crypto/ssh"
  18. appsv1 "k8s.io/api/apps/v1"
  19. corev1 "k8s.io/api/core/v1"
  20. metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
  21. "k8s.io/apimachinery/pkg/util/intstr"
  22. "k8s.io/client-go/rest"
  23. "sigs.k8s.io/controller-runtime/pkg/client"
  24. tailscaleroot "tailscale.com"
  25. "tailscale.com/types/ptr"
  26. )
  27. const (
  28. keysFilePath = "/root/.ssh/authorized_keys"
  29. sshdConfig = `
  30. Port 8022
  31. # Allow reverse tunnels
  32. GatewayPorts yes
  33. AllowTcpForwarding yes
  34. # Auth
  35. PermitRootLogin yes
  36. PasswordAuthentication no
  37. PubkeyAuthentication yes
  38. AuthorizedKeysFile ` + keysFilePath
  39. )
  40. var privateKeyPath = filepath.Join(tmp, "id_ed25519")
  41. func connectClusterToDevcontrol(ctx context.Context, logger *zap.SugaredLogger, cl client.WithWatch, restConfig *rest.Config, privKey ed25519.PrivateKey, pubKey []byte) (clusterIP string, _ error) {
  42. logger.Info("Setting up SSH reverse tunnel from cluster to devcontrol...")
  43. var err error
  44. if clusterIP, err = applySSHResources(ctx, cl, tailscaleroot.AlpineDockerTag, pubKey); err != nil {
  45. return "", fmt.Errorf("failed to apply ssh-server resources: %w", err)
  46. }
  47. sshPodName, err := waitForPodReady(ctx, logger, cl, ns, client.MatchingLabels{"app": "ssh-server"})
  48. if err != nil {
  49. return "", fmt.Errorf("ssh-server Pod not ready: %w", err)
  50. }
  51. if err := forwardLocalPortToPod(ctx, logger, restConfig, ns, sshPodName, 8022); err != nil {
  52. return "", fmt.Errorf("failed to set up port forwarding to ssh-server: %w", err)
  53. }
  54. if err := reverseTunnel(ctx, logger, privKey, fmt.Sprintf("localhost:%d", 8022), 31544, "localhost:31544"); err != nil {
  55. return "", fmt.Errorf("failed to set up reverse tunnel: %w", err)
  56. }
  57. return clusterIP, nil
  58. }
  59. func reverseTunnel(ctx context.Context, logger *zap.SugaredLogger, privateKey ed25519.PrivateKey, sshHost string, remotePort uint16, fwdTo string) error {
  60. signer, err := ssh.NewSignerFromKey(privateKey)
  61. if err != nil {
  62. return fmt.Errorf("failed to create signer: %w", err)
  63. }
  64. config := &ssh.ClientConfig{
  65. User: "root",
  66. Auth: []ssh.AuthMethod{
  67. ssh.PublicKeys(signer),
  68. },
  69. HostKeyCallback: ssh.InsecureIgnoreHostKey(),
  70. Timeout: 30 * time.Second,
  71. }
  72. conn, err := ssh.Dial("tcp", sshHost, config)
  73. if err != nil {
  74. return fmt.Errorf("failed to connect to SSH server: %w", err)
  75. }
  76. logger.Infof("Connected to SSH server at %s\n", sshHost)
  77. go func() {
  78. defer conn.Close()
  79. // Start listening on remote port.
  80. remoteAddr := fmt.Sprintf("localhost:%d", remotePort)
  81. remoteLn, err := conn.Listen("tcp", remoteAddr)
  82. if err != nil {
  83. logger.Infof("Failed to listen on remote port %d: %v", remotePort, err)
  84. return
  85. }
  86. defer remoteLn.Close()
  87. logger.Infof("Reverse tunnel ready on remote addr %s -> local addr %s", remoteAddr, fwdTo)
  88. for {
  89. remoteConn, err := remoteLn.Accept()
  90. if err != nil {
  91. logger.Infof("Failed to accept remote connection: %v", err)
  92. return
  93. }
  94. go handleConnection(ctx, logger, remoteConn, fwdTo)
  95. }
  96. }()
  97. return nil
  98. }
  99. func handleConnection(ctx context.Context, logger *zap.SugaredLogger, remoteConn net.Conn, fwdTo string) {
  100. go func() {
  101. <-ctx.Done()
  102. remoteConn.Close()
  103. }()
  104. var d net.Dialer
  105. localConn, err := d.DialContext(ctx, "tcp", fwdTo)
  106. if err != nil {
  107. logger.Infof("Failed to connect to local service %s: %v", fwdTo, err)
  108. return
  109. }
  110. go func() {
  111. <-ctx.Done()
  112. localConn.Close()
  113. }()
  114. go func() {
  115. if _, err := io.Copy(localConn, remoteConn); err != nil {
  116. logger.Infof("Error copying remote->local: %v", err)
  117. }
  118. }()
  119. go func() {
  120. if _, err := io.Copy(remoteConn, localConn); err != nil {
  121. logger.Infof("Error copying local->remote: %v", err)
  122. }
  123. }()
  124. }
  125. func readOrGenerateSSHKey(tmp string) (ed25519.PrivateKey, []byte, error) {
  126. var privateKey ed25519.PrivateKey
  127. b, err := os.ReadFile(privateKeyPath)
  128. switch {
  129. case os.IsNotExist(err):
  130. _, privateKey, err = ed25519.GenerateKey(rand.Reader)
  131. if err != nil {
  132. return nil, nil, fmt.Errorf("failed to generate key: %w", err)
  133. }
  134. privKeyPEM, err := ssh.MarshalPrivateKey(privateKey, "")
  135. if err != nil {
  136. return nil, nil, fmt.Errorf("failed to marshal SSH private key: %w", err)
  137. }
  138. f, err := os.OpenFile(privateKeyPath, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0600)
  139. if err != nil {
  140. return nil, nil, fmt.Errorf("failed to open SSH private key file: %w", err)
  141. }
  142. defer f.Close()
  143. if err := pem.Encode(f, privKeyPEM); err != nil {
  144. return nil, nil, fmt.Errorf("failed to write SSH private key: %w", err)
  145. }
  146. case err != nil:
  147. return nil, nil, fmt.Errorf("failed to read SSH private key: %w", err)
  148. default:
  149. pKey, err := ssh.ParseRawPrivateKey(b)
  150. if err != nil {
  151. return nil, nil, fmt.Errorf("failed to parse SSH private key: %w", err)
  152. }
  153. pKeyPointer, ok := pKey.(*ed25519.PrivateKey)
  154. if !ok {
  155. return nil, nil, fmt.Errorf("SSH private key is not ed25519: %T", pKey)
  156. }
  157. privateKey = *pKeyPointer
  158. }
  159. sshPublicKey, err := ssh.NewPublicKey(privateKey.Public())
  160. if err != nil {
  161. return nil, nil, fmt.Errorf("failed to create SSH public key: %w", err)
  162. }
  163. return privateKey, ssh.MarshalAuthorizedKey(sshPublicKey), nil
  164. }
  165. func applySSHResources(ctx context.Context, cl client.Client, alpineTag string, pubKey []byte) (string, error) {
  166. owner := client.FieldOwner("k8s-test")
  167. if err := cl.Patch(ctx, sshDeployment(alpineTag, pubKey), client.Apply, owner); err != nil {
  168. return "", fmt.Errorf("failed to apply ssh-server Deployment: %w", err)
  169. }
  170. if err := cl.Patch(ctx, sshConfigMap(pubKey), client.Apply, owner); err != nil {
  171. return "", fmt.Errorf("failed to apply ssh-server ConfigMap: %w", err)
  172. }
  173. svc := sshService()
  174. if err := cl.Patch(ctx, svc, client.Apply, owner); err != nil {
  175. return "", fmt.Errorf("failed to apply ssh-server Service: %w", err)
  176. }
  177. return svc.Spec.ClusterIP, nil
  178. }
  179. func cleanupSSHResources(ctx context.Context, cl client.Client) error {
  180. noGrace := &client.DeleteOptions{
  181. GracePeriodSeconds: ptr.To[int64](0),
  182. }
  183. if err := cl.Delete(ctx, sshDeployment("", nil), noGrace); err != nil {
  184. return fmt.Errorf("failed to delete ssh-server Deployment: %w", err)
  185. }
  186. if err := cl.Delete(ctx, sshConfigMap(nil), noGrace); err != nil {
  187. return fmt.Errorf("failed to delete ssh-server ConfigMap: %w", err)
  188. }
  189. if err := cl.Delete(ctx, sshService(), noGrace); err != nil {
  190. return fmt.Errorf("failed to delete control Service: %w", err)
  191. }
  192. return nil
  193. }
  194. func sshDeployment(tag string, pubKey []byte) *appsv1.Deployment {
  195. return &appsv1.Deployment{
  196. TypeMeta: metav1.TypeMeta{
  197. Kind: "Deployment",
  198. APIVersion: "apps/v1",
  199. },
  200. ObjectMeta: metav1.ObjectMeta{
  201. Name: "ssh-server",
  202. Namespace: ns,
  203. },
  204. Spec: appsv1.DeploymentSpec{
  205. Replicas: ptr.To[int32](1),
  206. Selector: &metav1.LabelSelector{
  207. MatchLabels: map[string]string{
  208. "app": "ssh-server",
  209. },
  210. },
  211. Template: corev1.PodTemplateSpec{
  212. ObjectMeta: metav1.ObjectMeta{
  213. Labels: map[string]string{
  214. "app": "ssh-server",
  215. },
  216. Annotations: map[string]string{
  217. "pubkey": hex.EncodeToString(pubKey), // Ensure new key triggers rollout.
  218. },
  219. },
  220. Spec: corev1.PodSpec{
  221. Containers: []corev1.Container{
  222. {
  223. Name: "ssh-server",
  224. Image: fmt.Sprintf("alpine:%s", tag),
  225. Command: []string{
  226. "sh", "-c",
  227. "apk add openssh-server; ssh-keygen -A; /usr/sbin/sshd -D -e",
  228. },
  229. Ports: []corev1.ContainerPort{
  230. {
  231. Name: "ctrl-port-fwd",
  232. ContainerPort: 31544,
  233. Protocol: corev1.ProtocolTCP,
  234. },
  235. {
  236. Name: "ssh",
  237. ContainerPort: 8022,
  238. Protocol: corev1.ProtocolTCP,
  239. },
  240. },
  241. ReadinessProbe: &corev1.Probe{
  242. ProbeHandler: corev1.ProbeHandler{
  243. TCPSocket: &corev1.TCPSocketAction{
  244. Port: intstr.FromInt(8022),
  245. },
  246. },
  247. InitialDelaySeconds: 1,
  248. PeriodSeconds: 1,
  249. },
  250. VolumeMounts: []corev1.VolumeMount{
  251. {
  252. Name: "sshd-config",
  253. MountPath: "/etc/ssh/sshd_config.d/reverse-tunnel.conf",
  254. SubPath: "reverse-tunnel.conf",
  255. },
  256. {
  257. Name: "sshd-config",
  258. MountPath: keysFilePath,
  259. SubPath: "authorized_keys",
  260. },
  261. },
  262. },
  263. },
  264. Volumes: []corev1.Volume{
  265. {
  266. Name: "sshd-config",
  267. VolumeSource: corev1.VolumeSource{
  268. ConfigMap: &corev1.ConfigMapVolumeSource{
  269. LocalObjectReference: corev1.LocalObjectReference{
  270. Name: "ssh-server-config",
  271. },
  272. },
  273. },
  274. },
  275. },
  276. },
  277. },
  278. },
  279. }
  280. }
  281. func sshConfigMap(pubKey []byte) *corev1.ConfigMap {
  282. return &corev1.ConfigMap{
  283. TypeMeta: metav1.TypeMeta{
  284. Kind: "ConfigMap",
  285. APIVersion: "v1",
  286. },
  287. ObjectMeta: metav1.ObjectMeta{
  288. Name: "ssh-server-config",
  289. Namespace: ns,
  290. },
  291. Data: map[string]string{
  292. "reverse-tunnel.conf": sshdConfig,
  293. "authorized_keys": string(pubKey),
  294. },
  295. }
  296. }
  297. func sshService() *corev1.Service {
  298. return &corev1.Service{
  299. TypeMeta: metav1.TypeMeta{
  300. Kind: "Service",
  301. APIVersion: "v1",
  302. },
  303. ObjectMeta: metav1.ObjectMeta{
  304. Name: "control",
  305. Namespace: ns,
  306. },
  307. Spec: corev1.ServiceSpec{
  308. Type: corev1.ServiceTypeClusterIP,
  309. Selector: map[string]string{
  310. "app": "ssh-server",
  311. },
  312. Ports: []corev1.ServicePort{
  313. {
  314. Name: "tunnel",
  315. Port: 31544,
  316. Protocol: corev1.ProtocolTCP,
  317. },
  318. },
  319. },
  320. }
  321. }