├── LICENSE ├── README.md ├── backend ├── local.go ├── ssh.go └── types.go ├── middleware ├── session.go ├── session_config.go ├── types.go └── utf8.go ├── shell.go └── utils ├── quote.go ├── quote_test.go ├── rand.go └── rand_test.go /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017, Gorillalabs 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | 21 | http://www.opensource.org/licenses/MIT 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # go-powershell 2 | 3 | This package is inspired by [jPowerShell](https://github.com/profesorfalken/jPowerShell) 4 | and allows one to run and remote-control a PowerShell session. Use this if you 5 | don't have a static script that you want to execute, bur rather run dynamic 6 | commands. 7 | 8 | ## Installation 9 | 10 | go get github.com/bhendo/go-powershell 11 | 12 | ## Usage 13 | 14 | To start a PowerShell shell, you need a backend. Backends take care of starting 15 | and controlling the actual powershell.exe process. In most cases, you will want 16 | to use the Local backend, which just uses ``os/exec`` to start the process. 17 | 18 | ```go 19 | package main 20 | 21 | import ( 22 | "fmt" 23 | 24 | ps "github.com/bhendo/go-powershell" 25 | "github.com/bhendo/go-powershell/backend" 26 | ) 27 | 28 | func main() { 29 | // choose a backend 30 | back := &backend.Local{} 31 | 32 | // start a local powershell process 33 | shell, err := ps.New(back) 34 | if err != nil { 35 | panic(err) 36 | } 37 | defer shell.Exit() 38 | 39 | // ... and interact with it 40 | stdout, stderr, err := shell.Execute("Get-WmiObject -Class Win32_Processor") 41 | if err != nil { 42 | panic(err) 43 | } 44 | 45 | fmt.Println(stdout) 46 | } 47 | ``` 48 | 49 | ## Remote Sessions 50 | 51 | You can use an existing PS shell to use PSSession cmdlets to connect to remote 52 | computers. Instead of manually handling that, you can use the Session middleware, 53 | which takes care of authentication. Note that you can still use the "raw" shell 54 | to execute commands on the computer where the powershell host process is running. 55 | 56 | ```go 57 | package main 58 | 59 | import ( 60 | "fmt" 61 | 62 | ps "github.com/bhendo/go-powershell" 63 | "github.com/bhendo/go-powershell/backend" 64 | "github.com/bhendo/go-powershell/middleware" 65 | ) 66 | 67 | func main() { 68 | // choose a backend 69 | back := &backend.Local{} 70 | 71 | // start a local powershell process 72 | shell, err := ps.New(back) 73 | if err != nil { 74 | panic(err) 75 | } 76 | 77 | // prepare remote session configuration 78 | config := middleware.NewSessionConfig() 79 | config.ComputerName = "remote-pc-1" 80 | 81 | // create a new shell by wrapping the existing one in the session middleware 82 | session, err := middleware.NewSession(shell, config) 83 | if err != nil { 84 | panic(err) 85 | } 86 | defer session.Exit() // will also close the underlying ps shell! 87 | 88 | // everything run via the session is run on the remote machine 89 | stdout, stderr, err = session.Execute("Get-WmiObject -Class Win32_Processor") 90 | if err != nil { 91 | panic(err) 92 | } 93 | 94 | fmt.Println(stdout) 95 | } 96 | ``` 97 | 98 | Note that a single shell instance is not safe for concurrent use, as are remote 99 | sessions. You can have as many remote sessions using the same shell as you like, 100 | but you must execute commands serially. If you need concurrency, you can just 101 | spawn multiple PowerShell processes (i.e. call ``.New()`` multiple times). 102 | 103 | Also, note that all commands that you execute are wrapped in special echo 104 | statements to delimit the stdout/stderr streams. After ``.Execute()``ing a command, 105 | you can therefore not access ``$LastExitCode`` anymore and expect meaningful 106 | results. 107 | 108 | ## License 109 | 110 | MIT, see LICENSE file. 111 | -------------------------------------------------------------------------------- /backend/local.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2017 Gorillalabs. All rights reserved. 2 | 3 | package backend 4 | 5 | import ( 6 | "io" 7 | "os/exec" 8 | 9 | "github.com/juju/errors" 10 | ) 11 | 12 | type Local struct{} 13 | 14 | func (b *Local) StartProcess(cmd string, args ...string) (Waiter, io.Writer, io.Reader, io.Reader, error) { 15 | command := exec.Command(cmd, args...) 16 | 17 | stdin, err := command.StdinPipe() 18 | if err != nil { 19 | return nil, nil, nil, nil, errors.Annotate(err, "Could not get hold of the PowerShell's stdin stream") 20 | } 21 | 22 | stdout, err := command.StdoutPipe() 23 | if err != nil { 24 | return nil, nil, nil, nil, errors.Annotate(err, "Could not get hold of the PowerShell's stdout stream") 25 | } 26 | 27 | stderr, err := command.StderrPipe() 28 | if err != nil { 29 | return nil, nil, nil, nil, errors.Annotate(err, "Could not get hold of the PowerShell's stderr stream") 30 | } 31 | 32 | err = command.Start() 33 | if err != nil { 34 | return nil, nil, nil, nil, errors.Annotate(err, "Could not spawn PowerShell process") 35 | } 36 | 37 | return command, stdin, stdout, stderr, nil 38 | } 39 | -------------------------------------------------------------------------------- /backend/ssh.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2017 Gorillalabs. All rights reserved. 2 | 3 | package backend 4 | 5 | import ( 6 | "fmt" 7 | "io" 8 | "regexp" 9 | "strings" 10 | 11 | "github.com/juju/errors" 12 | ) 13 | 14 | // sshSession exists so we don't create a hard dependency on crypto/ssh. 15 | type sshSession interface { 16 | Waiter 17 | 18 | StdinPipe() (io.WriteCloser, error) 19 | StdoutPipe() (io.Reader, error) 20 | StderrPipe() (io.Reader, error) 21 | Start(string) error 22 | } 23 | 24 | type SSH struct { 25 | Session sshSession 26 | } 27 | 28 | func (b *SSH) StartProcess(cmd string, args ...string) (Waiter, io.Writer, io.Reader, io.Reader, error) { 29 | stdin, err := b.Session.StdinPipe() 30 | if err != nil { 31 | return nil, nil, nil, nil, errors.Annotate(err, "Could not get hold of the SSH session's stdin stream") 32 | } 33 | 34 | stdout, err := b.Session.StdoutPipe() 35 | if err != nil { 36 | return nil, nil, nil, nil, errors.Annotate(err, "Could not get hold of the SSH session's stdout stream") 37 | } 38 | 39 | stderr, err := b.Session.StderrPipe() 40 | if err != nil { 41 | return nil, nil, nil, nil, errors.Annotate(err, "Could not get hold of the SSH session's stderr stream") 42 | } 43 | 44 | err = b.Session.Start(b.createCmd(cmd, args)) 45 | if err != nil { 46 | return nil, nil, nil, nil, errors.Annotate(err, "Could not spawn process via SSH") 47 | } 48 | 49 | return b.Session, stdin, stdout, stderr, nil 50 | } 51 | 52 | func (b *SSH) createCmd(cmd string, args []string) string { 53 | parts := []string{cmd} 54 | simple := regexp.MustCompile(`^[a-z0-9_/.~+-]+$`) 55 | 56 | for _, arg := range args { 57 | if !simple.MatchString(arg) { 58 | arg = b.quote(arg) 59 | } 60 | 61 | parts = append(parts, arg) 62 | } 63 | 64 | return strings.Join(parts, " ") 65 | } 66 | 67 | func (b *SSH) quote(s string) string { 68 | return fmt.Sprintf(`"%s"`, s) 69 | } 70 | -------------------------------------------------------------------------------- /backend/types.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2017 Gorillalabs. All rights reserved. 2 | 3 | package backend 4 | 5 | import "io" 6 | 7 | type Waiter interface { 8 | Wait() error 9 | } 10 | 11 | type Starter interface { 12 | StartProcess(cmd string, args ...string) (Waiter, io.Writer, io.Reader, io.Reader, error) 13 | } 14 | -------------------------------------------------------------------------------- /middleware/session.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2017 Gorillalabs. All rights reserved. 2 | 3 | package middleware 4 | 5 | import ( 6 | "fmt" 7 | "strings" 8 | 9 | "github.com/bhendo/go-powershell/utils" 10 | "github.com/juju/errors" 11 | ) 12 | 13 | type session struct { 14 | upstream Middleware 15 | name string 16 | } 17 | 18 | func NewSession(upstream Middleware, config *SessionConfig) (Middleware, error) { 19 | asserted, ok := config.Credential.(credential) 20 | if ok { 21 | credentialParamValue, err := asserted.prepare(upstream) 22 | if err != nil { 23 | return nil, errors.Annotate(err, "Could not setup credentials") 24 | } 25 | 26 | config.Credential = credentialParamValue 27 | } 28 | 29 | name := "goSess" + utils.CreateRandomString(8) 30 | args := strings.Join(config.ToArgs(), " ") 31 | 32 | _, _, err := upstream.Execute(fmt.Sprintf("$%s = New-PSSession %s", name, args)) 33 | if err != nil { 34 | return nil, errors.Annotate(err, "Could not create new PSSession") 35 | } 36 | 37 | return &session{upstream, name}, nil 38 | } 39 | 40 | func (s *session) Execute(cmd string) (string, string, error) { 41 | return s.upstream.Execute(fmt.Sprintf("Invoke-Command -Session $%s -Script {%s}", s.name, cmd)) 42 | } 43 | 44 | func (s *session) Exit() { 45 | s.upstream.Execute(fmt.Sprintf("Disconnect-PSSession -Session $%s", s.name)) 46 | s.upstream.Exit() 47 | } 48 | -------------------------------------------------------------------------------- /middleware/session_config.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2017 Gorillalabs. All rights reserved. 2 | 3 | package middleware 4 | 5 | import ( 6 | "fmt" 7 | "strconv" 8 | 9 | "github.com/bhendo/go-powershell/utils" 10 | "github.com/juju/errors" 11 | ) 12 | 13 | const ( 14 | HTTPPort = 5985 15 | HTTPSPort = 5986 16 | ) 17 | 18 | type SessionConfig struct { 19 | ComputerName string 20 | AllowRedirection bool 21 | Authentication string 22 | CertificateThumbprint string 23 | Credential interface{} 24 | Port int 25 | UseSSL bool 26 | } 27 | 28 | func NewSessionConfig() *SessionConfig { 29 | return &SessionConfig{} 30 | } 31 | 32 | func (c *SessionConfig) ToArgs() []string { 33 | args := make([]string, 0) 34 | 35 | if c.ComputerName != "" { 36 | args = append(args, "-ComputerName") 37 | args = append(args, utils.QuoteArg(c.ComputerName)) 38 | } 39 | 40 | if c.AllowRedirection { 41 | args = append(args, "-AllowRedirection") 42 | } 43 | 44 | if c.Authentication != "" { 45 | args = append(args, "-Authentication") 46 | args = append(args, utils.QuoteArg(c.Authentication)) 47 | } 48 | 49 | if c.CertificateThumbprint != "" { 50 | args = append(args, "-CertificateThumbprint") 51 | args = append(args, utils.QuoteArg(c.CertificateThumbprint)) 52 | } 53 | 54 | if c.Port > 0 { 55 | args = append(args, "-Port") 56 | args = append(args, strconv.Itoa(c.Port)) 57 | } 58 | 59 | if asserted, ok := c.Credential.(string); ok { 60 | args = append(args, "-Credential") 61 | args = append(args, asserted) // do not quote, as it contains a variable name when using password auth 62 | } 63 | 64 | if c.UseSSL { 65 | args = append(args, "-UseSSL") 66 | } 67 | 68 | return args 69 | } 70 | 71 | type credential interface { 72 | prepare(Middleware) (interface{}, error) 73 | } 74 | 75 | type UserPasswordCredential struct { 76 | Username string 77 | Password string 78 | } 79 | 80 | func (c *UserPasswordCredential) prepare(s Middleware) (interface{}, error) { 81 | name := "goCred" + utils.CreateRandomString(8) 82 | pwname := "goPass" + utils.CreateRandomString(8) 83 | 84 | _, _, err := s.Execute(fmt.Sprintf("$%s = ConvertTo-SecureString -String %s -AsPlainText -Force", pwname, utils.QuoteArg(c.Password))) 85 | if err != nil { 86 | return nil, errors.Annotate(err, "Could not convert password to secure string") 87 | } 88 | 89 | _, _, err = s.Execute(fmt.Sprintf("$%s = New-Object -TypeName 'System.Management.Automation.PSCredential' -ArgumentList %s, $%s", name, utils.QuoteArg(c.Username), pwname)) 90 | if err != nil { 91 | return nil, errors.Annotate(err, "Could not create PSCredential object") 92 | } 93 | 94 | return fmt.Sprintf("$%s", name), nil 95 | } 96 | -------------------------------------------------------------------------------- /middleware/types.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2017 Gorillalabs. All rights reserved. 2 | 3 | package middleware 4 | 5 | type Middleware interface { 6 | Execute(cmd string) (string, string, error) 7 | Exit() 8 | } 9 | -------------------------------------------------------------------------------- /middleware/utf8.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2017 Gorillalabs. All rights reserved. 2 | 3 | package middleware 4 | 5 | import ( 6 | "encoding/base64" 7 | "fmt" 8 | 9 | "github.com/bhendo/go-powershell/utils" 10 | ) 11 | 12 | // utf8 implements a primitive middleware that encodes all outputs 13 | // as base64 to prevent encoding issues between remote PowerShell 14 | // shells and the receiver. Just setting $OutputEncoding does not 15 | // work reliably enough, sadly. 16 | type utf8 struct { 17 | upstream Middleware 18 | wrapper string 19 | } 20 | 21 | func NewUTF8(upstream Middleware) (Middleware, error) { 22 | wrapper := "goUTF8" + utils.CreateRandomString(8) 23 | 24 | _, _, err := upstream.Execute(fmt.Sprintf(`function %s { process { if ($_) { [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($_)) } else { '' } } }`, wrapper)) 25 | 26 | return &utf8{upstream, wrapper}, err 27 | } 28 | 29 | func (u *utf8) Execute(cmd string) (string, string, error) { 30 | // Out-String to concat all lines into a single line, 31 | // Write-Host to prevent line breaks at the "window width" 32 | cmd = fmt.Sprintf(`%s | Out-String | %s | Write-Host`, cmd, u.wrapper) 33 | 34 | stdout, stderr, err := u.upstream.Execute(cmd) 35 | if err != nil { 36 | return stdout, stderr, err 37 | } 38 | 39 | decoded, err := base64.StdEncoding.DecodeString(stdout) 40 | if err != nil { 41 | return stdout, stderr, err 42 | } 43 | 44 | return string(decoded), stderr, nil 45 | } 46 | 47 | func (u *utf8) Exit() { 48 | u.upstream.Exit() 49 | } 50 | -------------------------------------------------------------------------------- /shell.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2017 Gorillalabs. All rights reserved. 2 | 3 | package powershell 4 | 5 | import ( 6 | "fmt" 7 | "io" 8 | "strings" 9 | "sync" 10 | 11 | "github.com/bhendo/go-powershell/backend" 12 | "github.com/bhendo/go-powershell/utils" 13 | "github.com/juju/errors" 14 | ) 15 | 16 | const newline = "\r\n" 17 | 18 | type Shell interface { 19 | Execute(cmd string) (string, string, error) 20 | Exit() 21 | } 22 | 23 | type shell struct { 24 | handle backend.Waiter 25 | stdin io.Writer 26 | stdout io.Reader 27 | stderr io.Reader 28 | } 29 | 30 | func New(backend backend.Starter) (Shell, error) { 31 | handle, stdin, stdout, stderr, err := backend.StartProcess("powershell.exe", "-NoExit", "-Command", "-") 32 | if err != nil { 33 | return nil, err 34 | } 35 | 36 | return &shell{handle, stdin, stdout, stderr}, nil 37 | } 38 | 39 | func (s *shell) Execute(cmd string) (string, string, error) { 40 | if s.handle == nil { 41 | return "", "", errors.Annotate(errors.New(cmd), "Cannot execute commands on closed shells.") 42 | } 43 | 44 | outBoundary := createBoundary() 45 | errBoundary := createBoundary() 46 | 47 | // wrap the command in special markers so we know when to stop reading from the pipes 48 | full := fmt.Sprintf("%s; echo '%s'; [Console]::Error.WriteLine('%s')%s", cmd, outBoundary, errBoundary, newline) 49 | 50 | _, err := s.stdin.Write([]byte(full)) 51 | if err != nil { 52 | return "", "", errors.Annotate(errors.Annotate(err, cmd), "Could not send PowerShell command") 53 | } 54 | 55 | // read stdout and stderr 56 | sout := "" 57 | serr := "" 58 | 59 | waiter := &sync.WaitGroup{} 60 | waiter.Add(2) 61 | 62 | go streamReader(s.stdout, outBoundary, &sout, waiter) 63 | go streamReader(s.stderr, errBoundary, &serr, waiter) 64 | 65 | waiter.Wait() 66 | 67 | if len(serr) > 0 { 68 | return sout, serr, errors.Annotate(errors.New(cmd), serr) 69 | } 70 | 71 | return sout, serr, nil 72 | } 73 | 74 | func (s *shell) Exit() { 75 | s.stdin.Write([]byte("exit" + newline)) 76 | 77 | // if it's possible to close stdin, do so (some backends, like the local one, 78 | // do support it) 79 | closer, ok := s.stdin.(io.Closer) 80 | if ok { 81 | closer.Close() 82 | } 83 | 84 | s.handle.Wait() 85 | 86 | s.handle = nil 87 | s.stdin = nil 88 | s.stdout = nil 89 | s.stderr = nil 90 | } 91 | 92 | func streamReader(stream io.Reader, boundary string, buffer *string, signal *sync.WaitGroup) error { 93 | // read all output until we have found our boundary token 94 | output := "" 95 | bufsize := 64 96 | marker := boundary + newline 97 | 98 | for { 99 | buf := make([]byte, bufsize) 100 | read, err := stream.Read(buf) 101 | if err != nil { 102 | return err 103 | } 104 | 105 | output = output + string(buf[:read]) 106 | 107 | if strings.HasSuffix(output, marker) { 108 | break 109 | } 110 | } 111 | 112 | *buffer = strings.TrimSuffix(output, marker) 113 | signal.Done() 114 | 115 | return nil 116 | } 117 | 118 | func createBoundary() string { 119 | return "$gorilla" + utils.CreateRandomString(12) + "$" 120 | } 121 | -------------------------------------------------------------------------------- /utils/quote.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2017 Gorillalabs. All rights reserved. 2 | 3 | package utils 4 | 5 | import "strings" 6 | 7 | func QuoteArg(s string) string { 8 | return "'" + strings.Replace(s, "'", "\"", -1) + "'" 9 | } 10 | -------------------------------------------------------------------------------- /utils/quote_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2017 Gorillalabs. All rights reserved. 2 | 3 | package utils 4 | 5 | import "testing" 6 | 7 | func TestQuotingArguments(t *testing.T) { 8 | testcases := [][]string{ 9 | {"", "''"}, 10 | {"test", "'test'"}, 11 | {"two words", "'two words'"}, 12 | {"quo\"ted", "'quo\"ted'"}, 13 | {"quo'ted", "'quo\"ted'"}, 14 | {"quo\\'ted", "'quo\\\"ted'"}, 15 | {"quo\"t'ed", "'quo\"t\"ed'"}, 16 | {"es\\caped", "'es\\caped'"}, 17 | {"es`caped", "'es`caped'"}, 18 | {"es\\`caped", "'es\\`caped'"}, 19 | } 20 | 21 | for i, testcase := range testcases { 22 | quoted := QuoteArg(testcase[0]) 23 | 24 | if quoted != testcase[1] { 25 | t.Errorf("test %02d failed: input '%s', expected %s, actual %s", i+1, testcase[0], testcase[1], quoted) 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /utils/rand.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2017 Gorillalabs. All rights reserved. 2 | 3 | package utils 4 | 5 | import ( 6 | "crypto/rand" 7 | "encoding/hex" 8 | ) 9 | 10 | func CreateRandomString(bytes int) string { 11 | c := bytes 12 | b := make([]byte, c) 13 | 14 | _, err := rand.Read(b) 15 | if err != nil { 16 | panic(err) 17 | } 18 | 19 | return hex.EncodeToString(b) 20 | } 21 | -------------------------------------------------------------------------------- /utils/rand_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2017 Gorillalabs. All rights reserved. 2 | 3 | package utils 4 | 5 | import "testing" 6 | 7 | func TestRandomStrings(t *testing.T) { 8 | r1 := CreateRandomString(8) 9 | r2 := CreateRandomString(8) 10 | 11 | if r1 == r2 { 12 | t.Error("Failed to create random strings: The two generated strings are identical.") 13 | } else if len(r1) != 16 { 14 | t.Errorf("Expected the random string to contain 16 characters, but got %d.", len(r1)) 15 | } 16 | } 17 | --------------------------------------------------------------------------------