easyssh-proxy provides a simple implementation of some SSH protocol features in Go

easyssh-proxy

GoDoc Build Status codecov Go Report Card Sourcegraph

easyssh-proxy provides a simple implementation of some SSH protocol features in Go.

Feature

This project is forked from easyssh but add some features as the following.

  • Support plain text of user private key.
  • Support key path of user private key.
  • Support Timeout for the TCP connection to establish.
  • Support SSH ProxyCommand.
     +--------+       +----------+      +-----------+
     | Laptop | <-->  | Jumphost | <--> | FooServer |
     +--------+       +----------+      +-----------+

                         OR

     +--------+       +----------+      +-----------+
     | Laptop | <-->  | Firewall | <--> | FooServer |
     +--------+       +----------+      +-----------+
     192.168.1.5       121.1.2.3         10.10.29.68

Usage

You can see ssh, scp, ProxyCommand on examples folder.

ssh

See example/ssh/ssh.go

package main

import (
	"fmt"
	"time"

	"github.com/appleboy/easyssh-proxy"
)

func main() {
	// Create MakeConfig instance with remote username, server address and path to private key.
	ssh := &easyssh.MakeConfig{
		User:   "appleboy",
		Server: "example.com",
		// Optional key or Password without either we try to contact your agent SOCKET
		//Password: "password",
		// Paste your source content of private key
		// Key: `-----BEGIN RSA PRIVATE KEY-----
		// MIIEpAIBAAKCAQEA4e2D/qPN08pzTac+a8ZmlP1ziJOXk45CynMPtva0rtK/RB26
		// 7XC9wlRna4b3Ln8ew3q1ZcBjXwD4ppbTlmwAfQIaZTGJUgQbdsO9YA==
		// -----END RSA PRIVATE KEY-----
		// `,
		KeyPath: "/Users/username/.ssh/id_rsa",
		Port:    "22",
		Timeout: 60 * time.Second,

		// Parse PrivateKey With Passphrase
		Passphrase: "1234",

		// Optional fingerprint SHA256 verification
		// Get Fingerprint: ssh.FingerprintSHA256(key)
		//Fingerprint: "SHA256:mVPwvezndPv/ARoIadVY98vAC0g+P/5633yTC4d/wXE"

		// Enable the use of insecure ciphers and key exchange methods.
		// This enables the use of the the following insecure ciphers and key exchange methods:
		// - aes128-cbc
		// - aes192-cbc
		// - aes256-cbc
		// - 3des-cbc
		// - diffie-hellman-group-exchange-sha256
		// - diffie-hellman-group-exchange-sha1
		// Those algorithms are insecure and may allow plaintext data to be recovered by an attacker.
		// UseInsecureCipher: true,
	}

	// Call Run method with command you want to run on remote server.
	stdout, stderr, done, err := ssh.Run("ls -al", 60*time.Second)
	// Handle errors
	if err != nil {
		panic("Can't run remote command: " + err.Error())
	} else {
		fmt.Println("don is :", done, "stdout is :", stdout, ";   stderr is :", stderr)
	}

}

scp

See example/scp/scp.go

package main

import (
	"fmt"

	"github.com/appleboy/easyssh-proxy"
)

func main() {
	// Create MakeConfig instance with remote username, server address and path to private key.
	ssh := &easyssh.MakeConfig{
		User:     "appleboy",
		Server:   "example.com",
		Password: "123qwe",
		Port:     "22",
	}

	// Call Scp method with file you want to upload to remote server.
	// Please make sure the `tmp` floder exists.
	err := ssh.Scp("/root/source.csv", "/tmp/target.csv")

	// Handle errors
	if err != nil {
		panic("Can't run remote command: " + err.Error())
	} else {
		fmt.Println("success")
	}
}

SSH ProxyCommand

See example/proxy/proxy.go

	ssh := &easyssh.MakeConfig{
		User:    "drone-scp",
		Server:  "localhost",
		Port:    "22",
		KeyPath: "./tests/.ssh/id_rsa",
		Proxy: easyssh.DefaultConfig{
			User:    "drone-scp",
			Server:  "localhost",
			Port:    "22",
			KeyPath: "./tests/.ssh/id_rsa",
		},
	}

SSH Stream Log

See example/stream/stream.go

func main() {
	// Create MakeConfig instance with remote username, server address and path to private key.
	ssh := &easyssh.MakeConfig{
		Server:  "localhost",
		User:    "drone-scp",
		KeyPath: "./tests/.ssh/id_rsa",
		Port:    "22",
		Timeout: 60 * time.Second,
	}

	// Call Run method with command you want to run on remote server.
	stdoutChan, stderrChan, doneChan, errChan, err := ssh.Stream("for i in {1..5}; do echo ${i}; sleep 1; done; exit 2;", 60*time.Second)
	// Handle errors
	if err != nil {
		panic("Can't run remote command: " + err.Error())
	} else {
		// read from the output channel until the done signal is passed
		isTimeout := true
	loop:
		for {
			select {
			case isTimeout = <-doneChan:
				break loop
			case outline := <-stdoutChan:
				fmt.Println("out:", outline)
			case errline := <-stderrChan:
				fmt.Println("err:", errline)
			case err = <-errChan:
			}
		}

		// get exit code or command error.
		if err != nil {
			fmt.Println("err: " + err.Error())
		}

		// command time out
		if !isTimeout {
			fmt.Println("Error: command timeout")
		}
	}
}
Owner
Bo-Yi Wu
I really believe committing every day on an open source project is the best practice.
Bo-Yi Wu
Comments
  • Getting error with the following code

    Getting error with the following code

    panic: Can't run remote command: dial tcp: i/o timeout

    package main
    
    import (
            "fmt"
    
            "github.com/appleboy/easyssh-proxy"
    )
    
    func main() {
            // Create MakeConfig instance with remote username, server address and path to private key.
            ssh := &easyssh.MakeConfig{
                    Server: "localhost",
                    // Optional key or Password without either we try to contact your agent SOCKET
                    //Password: "password",
                    Key:     "/.ssh/id_rsa",
                    Port:    "22",
                    Timeout: 60,
            }
    
            // Call Run method with command you want to run on remote server.
            stdout, stderr, done, err := ssh.Run("ls -al", 60)
            // Handle errors
            if err != nil {
                    panic("Can't run remote command: " + err.Error())
            } else {
                    fmt.Println("don is :", done, "stdout is :", stdout, ";   stderr is :", stderr)
            }
    
    }
    
  • fix: report timeout as error instead of writing message to stderr

    fix: report timeout as error instead of writing message to stderr

    I have observed panics on some specific commands:

    panic: send on closed channel
    
    goroutine 2017 [running]:
    github.com/appleboy/easyssh-proxy.(*MakeConfig).Stream.func1(0xc0000ca0a0, 0xc00013e090, 0xc000fea168, 0x1, 0x1, 0xc000de4b80, 0xc000de4c00, 0xc000690ea0, 0xc000690f00, 0xc000690f60, ...)
    	/home/allen/go/pkg/mod/github.com/appleboy/[email protected]/easyssh.go:340 +0x385
    created by github.com/appleboy/easyssh-proxy.(*MakeConfig).Stream
    	/home/allen/go/pkg/mod/github.com/appleboy/[email protected]/easyssh.go:297 +0x4a5
    

    That the stderrChan is closed before timeout message is written to it. However, if closing the channel in the upper level goroutine, it's possible that stderrScanner continues to read data after timeout reached, if the channel is closed in timeout case, it still panics.

    So use the errChan to report timeout and let stderrChan purely be with the stderr of command output, to avoid writing and closing the channel in two different goroutines.

  • Timeout type

    Timeout type

    Hello Again,

    In the MakeConfig struct Timeout is a time.Duration but in Run and Stream method timeout parameter is an int, don't you think it will be better to make every timeout a time.Duration? If you want I can create a Pull Request.

    Sebastien

  • Fix data race of Scp

    Fix data race of Scp

    fix DATA RACE

        Fix data race
    
        ==================
        WARNING: DATA RACE
        Write at 0x00c000397240 by goroutine 83:
          golang.org/x/crypto/ssh.(*Session).start()
              /go/pkg/mod/golang.org/x/[email protected]/ssh/session.go:373 +0x42
          golang.org/x/crypto/ssh.(*Session).Start()
              /go/pkg/mod/golang.org/x/[email protected]/ssh/session.go:293 +0x28f
          golang.org/x/crypto/ssh.(*Session).Run()
              /go/pkg/mod/golang.org/x/[email protected]/ssh/session.go:310 +0x50
          github.com/appleboy/easyssh-proxy.(*MakeConfig).Scp()
              /go/pkg/mod/github.com/appleboy/[email protected]/easyssh.go:356 +0x2e0
        ....
    
        Previous read at 0x00c000397240 by goroutine 72:
          golang.org/x/crypto/ssh.(*Session).StdinPipe()
              /go/pkg/mod/golang.org/x/[email protected]/ssh/session.go:545 +0x3d7
          github.com/appleboy/easyssh-proxy.(*MakeConfig).Scp.func1()
              /go/pkg/mod/github.com/appleboy/[email protected]/easyssh.go:339 +0x7c
    

    We must call session.StdinPipe before session.Run or session.StdinPipe will return error if it's started.

    The origin implementation will just silently quit, Scp() returning nil, but the file IS NOT copy to remote actually.

    This PR also check all write to the write and returning the error if we failed to do this.

  • Scan stdout and stderr in Stream in seperate goroutines.

    Scan stdout and stderr in Stream in seperate goroutines.

    Fixes #40.

    Splits stdout and stderr scanning into seperate goroutines, and waits on both to finish through use of a wait group.

    Also changed res chan to use empty structs as they take up less memory.

  • chore(deps): bump github.com/stretchr/testify from 1.6.1 to 1.8.1

    chore(deps): bump github.com/stretchr/testify from 1.6.1 to 1.8.1

    Bumps github.com/stretchr/testify from 1.6.1 to 1.8.1.

    Release notes

    Sourced from github.com/stretchr/testify's releases.

    Minor improvements and bug fixes

    Minor feature improvements and bug fixes

    Commits

    Dependabot compatibility score

    Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting @dependabot rebase.


    Dependabot commands and options

    You can trigger Dependabot actions by commenting on this PR:

    • @dependabot rebase will rebase this PR
    • @dependabot recreate will recreate this PR, overwriting any edits that have been made to it
    • @dependabot merge will merge this PR after your CI passes on it
    • @dependabot squash and merge will squash and merge this PR after your CI passes on it
    • @dependabot cancel merge will cancel a previously requested merge and block automerging
    • @dependabot reopen will reopen this PR if it is closed
    • @dependabot close will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually
    • @dependabot ignore this major version will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself)
    • @dependabot ignore this minor version will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself)
    • @dependabot ignore this dependency will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
  • Fix send on closed channel bug in command stream function

    Fix send on closed channel bug in command stream function

    I met a panic when using easyssh as a lib

    panic: send on closed channel goroutine 734 [running]: 
    github.com/appleboy/easyssh-proxy.(*MakeConfig).Stream.func1.1(0xc00032b400, 0xc0009cc360, 0xc000674c90)
    github.com/appleboy/[email protected]/easyssh.go:255 +0x7c created by 
    github.com/appleboy/easyssh-proxy.(*MakeConfig).Stream.func1 
    github.com/appleboy/[email protected]/easyssh.go:253 +0x285 
    Error: run `/root/.tiup/components/cluster/v1.3.2/tiup-cluster` (wd:/root/.tiup/data/SQgIeNO) failed: exit status 2
    

    When the command is timeout, defer close(stdoutChan) may be called before stdoutScanner read is finished.

    https://github.com/appleboy/easyssh-proxy/blob/ce04db7eefa6ac5a02c4a0ce337317761eaafee0/easyssh.go#L339

    One general principle of using Go channels is we should only close a channel in a sender goroutine if the sender is the only sender of the channel.

  • support Passphrase

    support Passphrase

    Fixed #50

    Use https://github.com/ScaleFT/sshkeys package.

    ref: https://github.com/golang/go/issues/18692

    maybe next go1.14 will fix the issue. See the commit: https://github.com/FiloSottile/go/commit/9090b284250b3ba27f7f7c63671f3c9e2611a2db

    Signed-off-by: Bo-Yi Wu [email protected]

  • fix: panic when using ssh-agent

    fix: panic when using ssh-agent

    Before changes:

    $ # This is what `keychain --eval --inherit any` does on Ubuntu.
    $ # https://www.funtoo.org/Keychain
    $ export SSH_AUTH_SOCK=/run/user/1000/keyring/ssh
    $ go build example/ssh/ssh.go
    $ ./ssh
    panic: Can't run remote command: ssh: handshake failed: agent: client error: write unix @->/run/user/1000/keyring/ssh: use of closed network connection
    
    goroutine 1 [running]:
    main.main()
    	/home/wsh/go/src/github.com/wataash/easyssh-proxy/example/ssh/ssh.go:32 +0x2d3
    

    Error message write unix @->/run/user/1000/keyring/ssh: use of closed network connection comes from Write() where SSH_AUTH_SOCK is already Close()ed.

    After changes:

    $ export SSH_AUTH_SOCK=/run/user/1000/keyring/ssh
    $ go build example/ssh/ssh.go
    $ ./ssh
    github.com/wataash/easyssh-proxy
    command-line-arguments
    don is : true stdout is : total 640
    drwxr-xr-x  68 wsh  wsh   4096 10月 16 21:25 .
    drwxr-xr-x   3 root root  4096  9月 12 08:39 ..
    drwxr-xr-x   2 wsh  wsh   4096  9月 20 20:48 .android
    ...
    -rw-rw-r--   1 wsh  wsh    202  9月 12 19:51 .zshrc
     ;   stderr is : Identity added: /home/wsh/.ssh/id_rsa (/home/wsh/.ssh/id_rsa)
    Identity added: /home/wsh/.ssh/id_ed25519 (wsh@wsh9b)
    
    
  • Modify AuthMethod order

    Modify AuthMethod order

    I have modified the AuthMethod order in getSSHConfig method to be sure that the user configuration is put first in the list because ssh.Dial seems to try only the first method in the list

  • fix: Can't get return exit value

    fix: Can't get return exit value

    fix #21 ref: https://github.com/appleboy/drone-ssh/issues/75 https://github.com/appleboy/drone-ssh/issues/66

    Signed-off-by: Bo-Yi Wu [email protected]

  • How can I use sshproxy path in windows?

    How can I use sshproxy path in windows?

    How can I use sshproxy to set path to rsa key in windows?

    ssh := &easyssh.MakeConfig{
    ...
    KeyPath: "/Users/username/.ssh/id_rsa",
    ...
    }
    

    Thanks for answer!

  • The default size buf was used to read the command execution result, resulting in an incomplete command execution result.

    The default size buf was used to read the command execution result, resulting in an incomplete command execution result.

    func (ssh_conf *MakeConfig) Stream(command string, timeout ...time.Duration) (<-chan string, <-chan string, <-chan bool, <-chan error, error) {
    	... ...
    	// combine outputs, create a line-by-line scanner
    	stdoutReader := io.MultiReader(outReader)
    	stderrReader := io.MultiReader(errReader)
    	stdoutScanner := bufio.NewScanner(stdoutReader)
    	stderrScanner := bufio.NewScanner(stderrReader)
    	... ...
    

    The default buf size is 4096. This results in an incomplete output when the command stdout or stderr is greater than this value

  • Handshake failed: ssh: unable to authenticate

    Handshake failed: ssh: unable to authenticate

    Hi, I'm trying to run a remote command to a host but it fails with the following error message and I can't find what is wrong:

    ssh: handshake failed: ssh: unable to authenticate, attempted methods [none publickey], no supported methods remain
    

    Here is the code:

    const (
        PKEY = "/root/.ssh/id_rsa"
    )
    ...
    output, err := utils.RunSSHCommand(user, host, PKEY, "cat /some/file")
    ...
    func RunSSHCommand(user, addr, key, cmd string) (string, error) {
    	ssh := &easyssh.MakeConfig{
    		User:    user,
    		Server:  addr,
    		KeyPath: key,
    		Port:    "22",
    		Timeout: 60 * time.Second,
    	}
    	stdout, stderr, done, err := ssh.Run(cmd, 60*time.Second)
    	if err != nil {
    		color.Error.Printf("[SSH Error] Cant run remote command (%s), error msg: %s\n", cmd, err.Error())
    	} else {
    		if done {
    			return stdout, err
    		} else {
    			color.Error.Printf("[SSH Error] Command error execution msg: %s\n", stderr)
    		}
    	}
    	return "", err
    }
    

    Any ideas of what could be the problem here? I've already check that the key exists. I'm running this code from a docker container, and I pass the private key from the server running the container to the container it self on the docker-compose file like these:

    ...
    volumes:
    - /root/.ssh/id_rsa:/root/.ssh/id_rsa
    ...
    
  • Improve host key verification

    Improve host key verification

    Currently host key verification is done by checking if the host key fingerprint matches the expected one. This has a flaw in that it assumes that the server host key type will always be the one we are expecting. Servers can have multiple different host keys of different types. The host key selection is done based on the SSH protocol specification, from RFC4253 section 7.1:

          server_host_key_algorithms
             A name-list of the algorithms supported for the server host
             key.  The server lists the algorithms for which it has host
             keys; the client lists the algorithms that it is willing to
             accept.  There MAY be multiple host keys for a host, possibly
             with different algorithms.
    
             Some host keys may not support both signatures and encryption
             (this can be determined from the algorithm), and thus not all
             host keys are valid for all key exchange methods.
    
             Algorithm selection depends on whether the chosen key exchange
             algorithm requires a signature or an encryption-capable host
             key.  It MUST be possible to determine this from the public key
             algorithm name.  The first algorithm on the client's name-list
             that satisfies the requirements and is also supported by the
             server MUST be chosen.  If there is no such algorithm, both
             sides MUST disconnect.
    

    This means that the client can choose which host key will be selected based on the sorted list of host key algorithms it sends to the server. This is also described here: https://www.bitvise.com/ssh-server-guide-host-keys

    crypto/ssh has a way to set this list in ClientConfig:

    	// HostKeyAlgorithms lists the key types that the client will
    	// accept from the server as host key, in order of
    	// preference. If empty, a reasonable default is used. Any
    	// string returned from PublicKey.Type method may be used, or
    	// any of the CertAlgoXxxx and KeyAlgoXxxx constants.
    	HostKeyAlgorithms []string
    

    easyssh doesn't set this list so it relies on crypto/ssh defaults which may change in the future. This means a crypto/ssh version update may break existing programs that use easyssh with fingerprint verification.

    An easy improvement would be to allow specifying this list via MakeConfig. "Any string returned from PublicKey.Type method may be used", these are probably constant. Thus the host key type would always be guaranteed to match the key fingerprint.

    Another improvement would be to be able to use func FixedHostKey as HostKeyCallback to allow specifying the full host public key to verify, not just the fingerprint, which would also be more secure.

  • Add WriteFile and use it for Scp

    Add WriteFile and use it for Scp

    • WriteFile takes io.Reader as input so we have a more generic copy mechanism
    • Let Scp method use WriteFile
    • Possible fix for #61 as Scp did not close the source
This is a SSH CA that allows you to retrieve a signed SSH certificate by authenticating to Duo.

github-duo-ssh-ca Authenticate to GitHub Enterprise in a secure way by requiring users to go through a Duo flow to get a short-lived SSH certificate t

Jan 7, 2022
Integrated ssh-agent for windows. (pageant compatible. openSSH ssh-agent etc ..)
Integrated ssh-agent for windows. (pageant compatible. openSSH ssh-agent etc ..)

OmniSSHAgent About The chaotic windows ssh-agent has been integrated into one program. Chaos Map of SSH-Agent on Windows There are several different c

Dec 19, 2022
Sesame: an Ingress controller for Kubernetes that works by deploying the Envoy proxy as a reverse proxy and load balancer

Sesame Overview Sesame is an Ingress controller for Kubernetes that works by dep

Dec 28, 2021
A simple and powerful SSH keys manager
A simple and powerful SSH keys manager

SKM is a simple and powerful SSH Keys Manager. It helps you to manage your multiple SSH keys easily! Features Create, List, Delete your SSH key(s) Man

Dec 17, 2022
Open Service Mesh (OSM) is a lightweight, extensible, cloud native service mesh that allows users to uniformly manage, secure, and get out-of-the-box observability features for highly dynamic microservice environments.
Open Service Mesh (OSM) is a lightweight, extensible, cloud native service mesh that allows users to uniformly manage, secure, and get out-of-the-box observability features for highly dynamic microservice environments.

Open Service Mesh (OSM) Open Service Mesh (OSM) is a lightweight, extensible, Cloud Native service mesh that allows users to uniformly manage, secure,

Jan 2, 2023
Raspberry Pi Archlinux Automated Offline Installer with Wi-Fi. Windows, Mac and more features coming.
Raspberry Pi Archlinux Automated Offline Installer with Wi-Fi. Windows, Mac and more features coming.

Raspberry Pi Archlinux Automated Installer with Wi-Fi. Windows, Mac and more features coming. Download Go to releases page and download the zip file f

Nov 22, 2022
Go-indexeddb - This expirement will attempt to implement IndexedDB features for Go

go-indexeddb This expirement will attempt to implement IndexedDB features for Go

May 29, 2022
An operator which complements grafana-operator for custom features which are not feasible to be merged into core operator

Grafana Complementary Operator A grafana which complements grafana-operator for custom features which are not feasible to be merged into core operator

Aug 16, 2022
Sample Driver that provides reference implementation for Container Object Storage Interface (COSI) API

cosi-driver-minio Sample Driver that provides reference implementation for Container Object Storage Interface (COSI) API Community, discussion, contri

Oct 10, 2022
PolarDB Stack is a DBaaS implementation for PolarDB-for-Postgres, as an operator creates and manages PolarDB/PostgreSQL clusters running in Kubernetes. It provides re-construct, failover swtich-over, scale up/out, high-available capabilities for each clusters.
PolarDB Stack is a DBaaS implementation for PolarDB-for-Postgres, as an operator creates and manages PolarDB/PostgreSQL clusters running in Kubernetes. It provides re-construct, failover swtich-over, scale up/out, high-available capabilities for each clusters.

PolarDB Stack开源版生命周期 1 系统概述 PolarDB是阿里云自研的云原生关系型数据库,采用了基于Shared-Storage的存储计算分离架构。数据库由传统的Share-Nothing,转变成了Shared-Storage架构。由原来的N份计算+N份存储,转变成了N份计算+1份存储

Nov 8, 2022
A simple tool to extract Fronius solar data logger output and output Influx line protocol

telegraf-exec-fronius This is a simple tool to extract Fronius solar data logger output and output Influx line protocol; it is designed to be used wit

Jan 8, 2022
A tool to automate some of my tasks in ECS/ECR.

severinoctl A tool to automate some tasks in ECS/ECR. Work in progress... Prerequisites awscli working aws credentials environment AWS_REGION exported

Feb 19, 2022
Golang CRUD using database PostgreSQL, adding some fremework like mux and pq.

Golang CRUD with PostgreSQL Table of contents ?? General info Technologies Blog Setup General info GOPOST or Go-Post is a Golang REST API made to show

Nov 27, 2021
Just a playground with some interesting concepts like pipelines aka middleware, handleFuncs, request validations etc. Check it out.

Pipeline a.k.a middleware in Go Just a playground with some interesting concepts like pipelines aka middleware, handleFuncs, request validations etc.

Dec 9, 2021
I'd like to share random apps in the spare times. Thus, I'm going to try learning some concepts of Go and as much as I can I try to clarify each line.

go-samples I'd like to share random apps in the spare times. Thus, I'm going to try learning some concepts of Go and as much as I can I try to clarify

Mar 16, 2022
Parallel processing through go routines, copy and delete thousands of key within some minutes

redis-dumper CLI Parallel processing through go routines, copy and delete thousands of key within some minutes copy data by key pattern from one redis

Dec 26, 2021
This repo houses some Golang introductory files, sample codes and implementations

This repo houses some Golang introductory files, sample codes and implementations. I will be updating it as I keep getting a hang of the language.

Aug 27, 2022
Homestead - Some tools to help with my homesteading.

Some tools to help with my homesteading. Since I'm based out of the US, I used existing government APIs to develop this. I will likely augment some of

Jan 8, 2022
Lists some Kubernetes resources in cluster or at hosts.

k8s-native-app Containerized this: go build After building this we have binary files to dockerize. Create Dockerfile. docker build -t project-clientgo

Feb 12, 2022