├── .air.toml ├── go.mod ├── Dockerfile ├── .github └── workflows │ ├── go-test-build.yml │ └── go-bin-release.yml ├── .gitignore ├── docker-compose.yaml ├── lib ├── stringfs │ ├── safefile.go │ ├── stringfs.go │ ├── path.go │ └── path_test.go ├── netutils │ └── netutils.go ├── userin │ └── userin.go └── sshutils │ ├── handle.go │ └── config.go ├── LICENSE ├── CONTRIBUTING.md ├── Makefile ├── main.go ├── README.md ├── internal └── task.go └── go.sum /.air.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | cmd = "make -s init" 3 | bin = "bin" 4 | 5 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module coreunit.net/wgg 2 | 3 | go 1.23 4 | 5 | require ( 6 | github.com/joho/godotenv v1.5.1 7 | github.com/pkg/sftp v1.13.7 8 | golang.org/x/crypto v0.28.0 9 | golang.org/x/term v0.25.0 10 | ) 11 | 12 | require ( 13 | github.com/kr/fs v0.1.0 // indirect 14 | golang.org/x/sys v0.26.0 // indirect 15 | ) 16 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ### BASE 2 | FROM golang AS base 3 | 4 | EXPOSE 8080 5 | WORKDIR /app 6 | 7 | COPY go.mod* go.sum* ./ 8 | RUN go mod tidy 9 | 10 | ### LOCAL 11 | FROM base AS local 12 | 13 | RUN go install github.com/air-verse/air@v1 14 | 15 | ENTRYPOINT air 16 | 17 | ### BASE DEPLOY 18 | FROM base AS base-deploy 19 | COPY . . 20 | RUN make build 21 | 22 | ### DEPLOY 23 | FROM ubuntu:24.04 AS deploy 24 | 25 | RUN useradd -m appuser --uid 10000 26 | USER 10000 27 | 28 | COPY --from=base-deploy --chown=10000 /app/bin /usr/local/bin/appbin 29 | 30 | CMD ["appbin"] 31 | -------------------------------------------------------------------------------- /.github/workflows/go-test-build.yml: -------------------------------------------------------------------------------- 1 | name: Go build and test 2 | 3 | on: 4 | push: 5 | branches: ["main"] 6 | paths: 7 | - "**.go" 8 | - "go.*" 9 | - "Makefile" 10 | 11 | permissions: 12 | contents: write 13 | packages: write 14 | 15 | jobs: 16 | build: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@v4 20 | 21 | - name: Set up Go 22 | uses: actions/setup-go@v4 23 | with: 24 | go-version: "1.23" 25 | 26 | - name: Test 27 | run: make test 28 | 29 | - name: Build 30 | run: make build 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # If you prefer the allow list template instead of the deny list, see community template: 2 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 3 | # 4 | # Binaries for programs and plugins 5 | *.exe 6 | *.exe~ 7 | *.dll 8 | *.so 9 | *.dylib 10 | 11 | # Test binary, built with `go test -c` 12 | *.test 13 | 14 | # Output of the go coverage tool, specifically when used with LiteIDE 15 | *.out 16 | 17 | # Dependency directories (remove the comment below to include it) 18 | # vendor/ 19 | 20 | # Go workspace file 21 | go.work 22 | go.work.sum 23 | bin 24 | tmp 25 | config 26 | .env -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | local: 3 | build: 4 | dockerfile: "Dockerfile" 5 | target: "local" 6 | ports: 7 | - "127.0.0.1:${PORT}:8080" 8 | volumes: 9 | - type: bind 10 | source: . 11 | target: /app 12 | - type: bind 13 | source: ${CACHE_DIR} 14 | target: /root/.cache/go-build 15 | env_file: ".env" 16 | 17 | lint: 18 | image: golangci/golangci-lint:v1 19 | command: "golangci-lint run -v" 20 | working_dir: "/app" 21 | volumes: 22 | - type: bind 23 | source: . 24 | target: /app 25 | read_only: true 26 | 27 | deploy: 28 | build: 29 | dockerfile: "Dockerfile" 30 | target: "deploy" 31 | ports: 32 | - "127.0.0.1:${PORT}:8080" 33 | env_file: ".env" 34 | 35 | -------------------------------------------------------------------------------- /lib/stringfs/safefile.go: -------------------------------------------------------------------------------- 1 | package stringfs 2 | 3 | import ( 4 | "errors" 5 | "io/fs" 6 | "os" 7 | "path/filepath" 8 | ) 9 | 10 | func RemoveTmpSafeFile(path string) { 11 | dir, file := filepath.Split(path) 12 | 13 | RemoveFile(dir + ".tmp_" + file) 14 | } 15 | 16 | func SafeWriteFile(path string, content string, mode fs.FileMode) error { 17 | return SafeWriteFileBytes(path, []byte(content), mode) 18 | } 19 | 20 | func SafeWriteFileBytes(path string, content []byte, mode fs.FileMode) error { 21 | dir, file := filepath.Split(path) 22 | 23 | err := os.WriteFile( 24 | dir+".tmp_"+file, 25 | content, 26 | mode, 27 | ) 28 | 29 | if err != nil { 30 | return errors.New("Write file error: " + err.Error()) 31 | } 32 | 33 | err = os.Rename(dir+".tmp_"+file, path) 34 | 35 | if err != nil { 36 | return errors.New("Rename file error: " + err.Error()) 37 | } 38 | 39 | return nil 40 | } 41 | -------------------------------------------------------------------------------- /.github/workflows/go-bin-release.yml: -------------------------------------------------------------------------------- 1 | name: Go binary release 2 | 3 | on: 4 | release: 5 | types: [created, edited] 6 | 7 | permissions: 8 | contents: write 9 | packages: write 10 | 11 | jobs: 12 | releases-matrix: 13 | name: Release Go Binary 14 | runs-on: ubuntu-latest 15 | strategy: 16 | matrix: 17 | goos: ["linux", "darwin", "windows"] 18 | goarch: ["amd64", "386", "arm64"] 19 | exclude: 20 | - goarch: "386" 21 | goos: "darwin" 22 | - goarch: "arm64" 23 | goos: "windows" 24 | steps: 25 | - uses: actions/checkout@v4 26 | - uses: wangyoucao577/go-release-action@v1 27 | with: 28 | binary_name: "wgg" 29 | goversion: "1.23" 30 | extra_files: LICENSE README.md CONTRIBUTING.md 31 | github_token: ${{ secrets.GITHUB_TOKEN }} 32 | goos: ${{ matrix.goos }} 33 | goarch: ${{ matrix.goarch }} 34 | ldflags: "-X main.Version=${{ github.ref_name }} -X main.Commit=${{ github.sha }}" 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Majo Richter (NobleMajo) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /lib/stringfs/stringfs.go: -------------------------------------------------------------------------------- 1 | package stringfs 2 | 3 | import ( 4 | "io/fs" 5 | "os" 6 | ) 7 | 8 | func RemoveFile(path string) error { 9 | exists, isDir := IsDir(path) 10 | 11 | if !exists { 12 | return nil 13 | } 14 | 15 | if isDir { 16 | return os.RemoveAll(path) 17 | } 18 | 19 | return os.Remove(path) 20 | } 21 | 22 | func WriteFile(path string, content string, mode fs.FileMode) error { 23 | err := os.WriteFile( 24 | path, 25 | []byte(content), 26 | mode, 27 | ) 28 | 29 | if err != nil { 30 | return err 31 | } 32 | 33 | return nil 34 | } 35 | 36 | func ReadFile(path string) (string, error) { 37 | rawData, err := os.ReadFile(path) 38 | if err != nil { 39 | return "", err 40 | } 41 | 42 | return string(rawData), nil 43 | } 44 | 45 | func Exists(path string) bool { 46 | stat, err := os.Stat(path) 47 | 48 | if err != nil || stat == nil { 49 | return false 50 | } 51 | 52 | return true 53 | } 54 | 55 | func IsFile(path string) (bool, bool) { 56 | stat, err := os.Stat(path) 57 | 58 | if err != nil || stat == nil { 59 | return false, false 60 | } 61 | 62 | return true, !stat.IsDir() 63 | } 64 | 65 | func IsDir(path string) (bool, bool) { 66 | stat, err := os.Stat(path) 67 | 68 | if err != nil || stat == nil { 69 | return false, false 70 | } 71 | 72 | return true, stat.IsDir() 73 | } 74 | -------------------------------------------------------------------------------- /lib/netutils/netutils.go: -------------------------------------------------------------------------------- 1 | package netutils 2 | 3 | import ( 4 | "math/big" 5 | "net" 6 | ) 7 | 8 | func BroadcastAddress(subnet *net.IPNet) net.IP { 9 | n := len(subnet.IP) 10 | out := make(net.IP, n) 11 | var m byte 12 | for i := 0; i < n; i++ { 13 | m = subnet.Mask[i] ^ 0xff 14 | out[i] = subnet.IP[i] | m 15 | } 16 | return out 17 | } 18 | 19 | func NextSubnet(subnet *net.IPNet) *net.IPNet { 20 | n := len(subnet.IP) 21 | out := BroadcastAddress(subnet) 22 | var c byte = 1 23 | for i := n - 1; i >= 0; i-- { 24 | out[i] = out[i] + c 25 | if out[i] == 0 && c > 0 { 26 | c = 1 27 | } else { 28 | c = 0 29 | } 30 | 31 | } 32 | if c == 1 { 33 | return nil 34 | } 35 | return &net.IPNet{IP: out.Mask(subnet.Mask), Mask: subnet.Mask} 36 | } 37 | 38 | func IncrementIP(ip net.IP, increment int) net.IP { 39 | if ip == nil { 40 | return nil 41 | } 42 | 43 | if increment == 0 { 44 | return ip.To16() 45 | } 46 | 47 | ipv4 := ip.To4() 48 | if ipv4 != nil { 49 | ip = ipv4 50 | } 51 | 52 | ipInt := new(big.Int) 53 | ipInt.SetBytes(ip) 54 | 55 | incrementInt := big.NewInt(int64(increment)) 56 | 57 | result := new(big.Int).Add(ipInt, incrementInt) 58 | 59 | resultBytes := result.Bytes() 60 | 61 | if ip.To4() != nil { 62 | if len(resultBytes) > 4 { 63 | return nil 64 | } 65 | resultIP := make(net.IP, 4) 66 | copy(resultIP[4-len(resultBytes):], resultBytes) 67 | return resultIP 68 | } else { 69 | if len(resultBytes) > 16 { 70 | return nil 71 | } 72 | resultIP := make(net.IP, 16) 73 | copy(resultIP[16-len(resultBytes):], resultBytes) 74 | return resultIP 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | We welcome contributions from the community to help improve this project. 4 | Your input is valuable to us. 5 | 6 | You can contribute to the project in the following ways: 7 | 8 | ### **Create Issues** 9 | Feel free to create issues for: 10 | - feature requests, 11 | - bug fixes, 12 | - documentation improvements, 13 | - test cases, 14 | - general questions, 15 | - or any recommendations you may have. 16 | ### **Merge Requests** 17 | Please avoid multiple different changes in one merge request. 18 | 1. **Fork** the repository, 19 | 2. **commit and push** your changes, 20 | 3. and submit your **merge request**: 21 | - with a clear explanation of the changes you've made, 22 | - and a note with your thoughts about your tests. 23 | 24 | There are many reasons for submitting a merge request: 25 | - Fixing bugs, 26 | - implement new features, 27 | - improve documentation, 28 | - and adding tests. 29 | 30 | ## Rules for Contributions 31 | 32 | To ensure a smooth contribution process, please follow the rules below: 33 | 34 | - **License Awareness**: Be aware of and check the LICENSE file before contributing to understand the project's licence terms. 35 | - **Respect the Licence Terms**: Ensure that your contributions comply with the project's license terms. 36 | - **Avoid Plagiarism**: Do not plagiarise code or content from other sources. All contributions should be original work or properly attributed. 37 | - **Platform Rules** Also be sure to follow the rules of the provider and the platform. 38 | 39 | ## Thank you 40 | A big thank you, for considering a contribution. 41 | If anything is unclear, please contact us via a issue with your question. 42 | -------------------------------------------------------------------------------- /lib/stringfs/path.go: -------------------------------------------------------------------------------- 1 | package stringfs 2 | 3 | import ( 4 | "errors" 5 | "os" 6 | "path/filepath" 7 | "strings" 8 | ) 9 | 10 | func ParsePathRef(path *string) error { 11 | wd, err := os.Getwd() 12 | if err != nil { 13 | return errors.New("cant get current working dir:\n> " + err.Error()) 14 | } 15 | 16 | return ParsePathRefFrom(path, wd) 17 | } 18 | 19 | func ParsePathRefFrom(path *string, cwd string) error { 20 | if path == nil { 21 | return errors.New("path is nil") 22 | } 23 | 24 | *path = strings.TrimSpace(*path) 25 | 26 | if strings.HasPrefix(*path, "~") { 27 | userHome, err := os.UserHomeDir() 28 | if err != nil { 29 | return errors.New("cant get users home dir:\n> " + err.Error()) 30 | } 31 | 32 | *path = strings.Replace(*path, "~", userHome, 1) 33 | } 34 | 35 | if !strings.HasPrefix(*path, "/") { 36 | *path = cwd + "/" + *path 37 | } 38 | 39 | *path = filepath.Join(*path) 40 | 41 | return nil 42 | } 43 | 44 | func ParsePath(path string) (string, error) { 45 | wd, err := os.Getwd() 46 | if err != nil { 47 | return "", errors.New("cant get current working dir:\n> " + err.Error()) 48 | } 49 | 50 | return ParsePathFrom(path, wd) 51 | } 52 | 53 | func ParsePathFrom(path string, cwd string) (string, error) { 54 | path = strings.TrimSpace(path) 55 | 56 | if strings.HasPrefix(path, "~") { 57 | userHome, err := os.UserHomeDir() 58 | if err != nil { 59 | return "", errors.New("cant get users home dir:\n> " + err.Error()) 60 | } 61 | 62 | path = strings.Replace(path, "~", userHome, 1) 63 | } 64 | 65 | if !strings.HasPrefix(path, "/") { 66 | path = cwd + "/" + path 67 | } 68 | 69 | path = filepath.Join(path) 70 | 71 | return path, nil 72 | } 73 | -------------------------------------------------------------------------------- /lib/userin/userin.go: -------------------------------------------------------------------------------- 1 | package userin 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "os" 7 | "syscall" 8 | 9 | "golang.org/x/term" 10 | ) 11 | 12 | func PromptNewPassword() (string, error) { 13 | var newPassword string 14 | var newPassword2 string 15 | var err error 16 | 17 | for { 18 | fmt.Println("Enter your new password:") 19 | newPassword, err = ReadPassword() 20 | if err != nil { 21 | return "", err 22 | } 23 | 24 | if len(newPassword) < 4 { 25 | fmt.Println("Password too short! Use CRTL+C to abort.") 26 | continue 27 | } 28 | 29 | fmt.Println("Re-enter your new password:") 30 | 31 | newPassword2, err = ReadPassword() 32 | if err != nil { 33 | return "", err 34 | } 35 | 36 | if newPassword != newPassword2 { 37 | fmt.Println("Passwords do not match! Use CRTL+C to abort.") 38 | continue 39 | } 40 | 41 | break 42 | } 43 | 44 | return newPassword, nil 45 | } 46 | 47 | func PromptPassword() (string, error) { 48 | var newPassword string 49 | var err error 50 | 51 | for { 52 | fmt.Println("Enter your password:") 53 | newPassword, err = ReadPassword() 54 | if err != nil { 55 | return "", err 56 | } 57 | 58 | if len(newPassword) < 4 { 59 | fmt.Println("Password too short! Use CRTL+C to abort.") 60 | continue 61 | } 62 | 63 | break 64 | } 65 | 66 | return newPassword, nil 67 | } 68 | 69 | func ReadPassword() (string, error) { 70 | rawData, err := term.ReadPassword(int(syscall.Stdin)) 71 | 72 | if err != nil { 73 | return "", err 74 | } 75 | 76 | return string(rawData), nil 77 | } 78 | 79 | func ReadLine() (string, error) { 80 | fmt.Print("> ") 81 | buf := bufio.NewReader(os.Stdin) 82 | rawData, err := buf.ReadBytes('\n') 83 | 84 | if err != nil { 85 | return "", err 86 | } 87 | 88 | return string(rawData), nil 89 | } 90 | -------------------------------------------------------------------------------- /lib/stringfs/path_test.go: -------------------------------------------------------------------------------- 1 | package stringfs 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | ) 7 | 8 | func TestParsePath(t *testing.T) { 9 | cwd, err := os.Getwd() 10 | if err != nil { 11 | t.Errorf("cant get current working dir:\n> " + err.Error()) 12 | return 13 | } 14 | 15 | homeDir, err := os.UserHomeDir() 16 | if err != nil { 17 | t.Errorf("cant get users home dir:\n> " + err.Error()) 18 | return 19 | } 20 | 21 | tests := []struct { 22 | input string 23 | expected string 24 | expectError bool 25 | }{ 26 | {"", cwd, false}, 27 | {"~", homeDir, false}, 28 | {"~/test", homeDir + "/test", false}, 29 | {"/absolute/path", "/absolute/path", false}, 30 | {"/absolute/path/../test/test/../../path", "/absolute/path", false}, 31 | {"relative/path", cwd + "/relative/path", false}, 32 | } 33 | 34 | for _, test := range tests { 35 | t.Run(test.input, func(t *testing.T) { 36 | err := ParsePathRef(&test.input) 37 | if test.expectError { 38 | if err == nil { 39 | t.Errorf("expected error for path %s, but got none", test.input) 40 | return 41 | } 42 | } else { 43 | if err != nil { 44 | t.Errorf("did not expect error for path %s, but got %v", test.input, err) 45 | return 46 | } 47 | if test.input != test.expected { 48 | t.Errorf("expected %s, but got %s", test.expected, test.input) 49 | return 50 | } 51 | } 52 | }) 53 | } 54 | } 55 | 56 | func TestParsePathRefFrom(t *testing.T) { 57 | cwd := "/test/cwd" 58 | homeDir, err := os.UserHomeDir() 59 | if err != nil { 60 | t.Errorf("cant get users home dir:\n> " + err.Error()) 61 | return 62 | } 63 | 64 | tests := []struct { 65 | input string 66 | expected string 67 | expectError bool 68 | }{ 69 | {"", cwd, false}, 70 | {"~", homeDir, false}, 71 | {"~/test", homeDir + "/test", false}, 72 | {"/absolute/path", "/absolute/path", false}, 73 | {"/absolute/path/../test/test/../../path", "/absolute/path", false}, 74 | {"relative/path", cwd + "/relative/path", false}, 75 | } 76 | 77 | for _, test := range tests { 78 | t.Run(test.input, func(t *testing.T) { 79 | err := ParsePathRefFrom(&test.input, cwd) 80 | if test.expectError { 81 | if err == nil { 82 | t.Errorf("expected error for path %s, but got none", test.input) 83 | return 84 | } 85 | } else { 86 | if err != nil { 87 | t.Errorf("did not expect error for path %s, but got %v", test.input, err) 88 | return 89 | } 90 | if test.input != test.expected { 91 | t.Errorf("expected %s, but got %s", test.expected, test.input) 92 | return 93 | } 94 | } 95 | }) 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /lib/sshutils/handle.go: -------------------------------------------------------------------------------- 1 | package sshutils 2 | 3 | import ( 4 | "errors" 5 | "net" 6 | "strconv" 7 | "strings" 8 | 9 | "github.com/pkg/sftp" 10 | "golang.org/x/crypto/ssh" 11 | ) 12 | 13 | func HandleSftp( 14 | sshConfig SshConfig, 15 | handle func( 16 | *sftp.Client, 17 | *ssh.Session, 18 | ) error, 19 | ) error { 20 | // var hostkeyCallback ssh.HostKeyCallback 21 | // hostkeyCallback, err = knownhosts.New(homeDir + "/.ssh/known_hosts") 22 | // if err != nil { 23 | // return errors.New("error parsing known hosts: " + err.Error()) 24 | // } 25 | 26 | err := sshConfig.VerifySshConfig() 27 | if err != nil { 28 | return errors.New("error verifying ssh config: " + err.Error()) 29 | } 30 | 31 | authMethods := []ssh.AuthMethod{} 32 | 33 | if len(sshConfig.Password) > 0 { 34 | authMethods = append(authMethods, ssh.Password(sshConfig.Password)) 35 | } 36 | 37 | if len(sshConfig.PrivateKey) > 0 { 38 | signer, err := ssh.ParsePrivateKey([]byte(sshConfig.PrivateKey)) 39 | if err != nil { 40 | return errors.New("error parsing private key: " + err.Error()) 41 | } 42 | 43 | authMethods = append(authMethods, ssh.PublicKeys(signer)) 44 | } 45 | 46 | conf := &ssh.ClientConfig{ 47 | User: sshConfig.User, 48 | HostKeyCallback: func(hostname string, remote net.Addr, key ssh.PublicKey) error { 49 | return nil 50 | }, 51 | Auth: authMethods, 52 | } 53 | 54 | // sftp 55 | sftpSshClient, err := ssh.Dial("tcp", sshConfig.Host+":"+strconv.Itoa(sshConfig.Port), conf) 56 | if err != nil { 57 | return errors.New("error dialing: " + err.Error()) 58 | } 59 | defer sftpSshClient.Close() 60 | 61 | sftp, err := sftp.NewClient( 62 | sftpSshClient, 63 | ) 64 | if err != nil { 65 | return errors.New("error creating sftp client: " + err.Error()) 66 | } 67 | defer sftp.Close() 68 | 69 | // session 70 | sessionSshClient, err := ssh.Dial("tcp", sshConfig.Host+":"+strconv.Itoa(sshConfig.Port), conf) 71 | if err != nil { 72 | return errors.New("error dialing: " + err.Error()) 73 | } 74 | defer sessionSshClient.Close() 75 | 76 | session, err := sessionSshClient.NewSession() 77 | if err != nil { 78 | return errors.New("error creating ssh session: " + err.Error()) 79 | } 80 | defer session.Close() 81 | 82 | // handle 83 | err = handle(sftp, session) 84 | if err != nil { 85 | return errors.New("error handling: " + err.Error()) 86 | } 87 | 88 | return nil 89 | } 90 | 91 | func JoinPath(sftp *sftp.Client, path ...string) (string, error) { 92 | if len(path) != 0 && 93 | len(path[0]) != 0 { 94 | if strings.HasPrefix(path[0], "~/") || 95 | strings.HasPrefix(path[0], "./") { 96 | cwd, err := sftp.Getwd() 97 | if err != nil { 98 | return "", errors.New("error getting cwd: " + err.Error()) 99 | } 100 | 101 | if strings.HasPrefix(path[0], "../") { 102 | path[0] = cwd + "/" + path[0] 103 | } else { 104 | path[0] = cwd + path[0][1:] 105 | } 106 | } 107 | } 108 | 109 | return sftp.Join(path...), nil 110 | } 111 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | DISPLAY_NAME := DeployIT 2 | SHORT_NAME := dit 3 | VERSION := 1.0.0 4 | 5 | COMMIT := $(shell git rev-parse --short HEAD) 6 | BUILD_ARGS := "-X main.Version=$(VERSION) -X main.Commit=$(COMMIT) -X main.DisplayName=$(DISPLAY_NAME) -X main.ShortName=$(SHORT_NAME)" 7 | PORT ?= 8080 8 | 9 | -include .env 10 | export 11 | 12 | ## info: prints a project info message 13 | .PHONY: info 14 | info: 15 | @echo "$(DISPLAY_NAME) version $(VERSION), build $(COMMIT)" 16 | 17 | ## run: uses go to start the main.go 18 | .PHONY: run 19 | run: 20 | @go run main.go 21 | 22 | ## build: uses go to build the app with build args 23 | .PHONY: build 24 | build: 25 | @touch .env 26 | go build \ 27 | -ldflags=$(BUILD_ARGS) \ 28 | -o bin 29 | chmod +x bin 30 | 31 | ## install: installs the build binary globally 32 | .PHONY: install 33 | install: 34 | @cp ./bin /usr/local/bin/$(SHORT_NAME) 35 | 36 | ## uninstall: uninstalls the build binary globally 37 | .PHONY: uninstall 38 | uninstall: 39 | @rm -f /usr/local/bin/$(SHORT_NAME) 40 | 41 | ## clean: cleans up the tmp, build and docker cache 42 | .PHONY: clean 43 | clean: 44 | @rm -f bin 45 | @rm -fr ./tmp 46 | @if command -v go 2>&1 >/dev/null; then \ 47 | echo "cleanup go..."; \ 48 | go clean; \ 49 | go clean -cache -fuzzcache; \ 50 | fi 51 | @if command -v docker 2>&1 >/dev/null; then \ 52 | echo "cleanup docker..."; \ 53 | CACHE_DIR="" PORT="" docker compose down --remove-orphans --rmi all; \ 54 | docker image prune -f; \ 55 | fi 56 | @echo "cleanup done!" 57 | @echo "WARNING: the .env file still exists!" 58 | @echo "If installed also execute 'make uninstall' to uninstall the binary." 59 | 60 | ## update: updates dependencies 61 | .PHONY: update 62 | update: 63 | go get -t -u ./... 64 | 65 | ## test: runs all tests without coverage 66 | .PHONY: test 67 | test: 68 | go test ./... 69 | 70 | ## init: prepares ands builds 71 | .PHONY: init 72 | init: 73 | @touch .env 74 | @echo "update deps..." 75 | @go mod tidy 76 | @echo "testing..." 77 | @make -s test 78 | @echo "building..." 79 | @make -s build 80 | 81 | ## air: starts the go bin in air watch mode 82 | .PHONY: air 83 | air: 84 | @go install github.com/air-verse/air@v1 85 | @air 86 | 87 | ## dev: starts a dev docker container 88 | .PHONY: dev 89 | dev: 90 | @touch .env 91 | $(eval CACHE_DIR = .tmp/.cache/go-build) 92 | @if [ -d ~/.cache/go-build ]; then \ 93 | $(eval CACHE_DIR = ~/.cache/go-build) \ 94 | echo "Use users go-build cache dir."; \ 95 | else \ 96 | mkdir -p $(CACHE_DIR); \ 97 | echo "Use local go-build cache dir."; \ 98 | fi 99 | 100 | @docker rm -f $(SHORT_NAME)-local-dev > /dev/null 2>&1 101 | 102 | PORT=${PORT} \ 103 | CACHE_DIR=${CACHE_DIR} \ 104 | docker compose run --build --rm -it --name $(SHORT_NAME)-local-dev -P local 105 | 106 | ## exec: starts a bash in a dev container 107 | .PHONY: exec 108 | exec: 109 | @touch .env 110 | $(eval CACHE_DIR = .tmp/.cache/go-build) 111 | @if [ -d ~/.cache/go-build ]; then \ 112 | $(eval CACHE_DIR = ~/.cache/go-build) \ 113 | echo "Use users go-build cache dir."; \ 114 | else \ 115 | mkdir -p $(CACHE_DIR); \ 116 | echo "Use local go-build cache dir."; \ 117 | fi 118 | 119 | @docker rm -f $(SHORT_NAME)-local-bash > /dev/null 2>&1 120 | 121 | PORT=${PORT} \ 122 | CACHE_DIR=${CACHE_DIR} \ 123 | docker compose run --build --rm -it --name $(SHORT_NAME)-local-bash --entrypoint bash -P local -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "log" 7 | "os" 8 | "strconv" 9 | 10 | dit "coreunit.net/wgg/internal" 11 | "coreunit.net/wgg/lib/sshutils" 12 | "github.com/joho/godotenv" 13 | "github.com/pkg/sftp" 14 | "golang.org/x/crypto/ssh" 15 | ) 16 | 17 | var DisplayName string = "Unset" 18 | var ShortName string = "unset" 19 | var Version string = "?.?.?" 20 | var Commit string = "???????" 21 | 22 | func main() { 23 | fmt.Println(DisplayName + " version v" + Version + ", build " + Commit) 24 | 25 | err := godotenv.Load() 26 | if err == nil { 27 | fmt.Println("Environment variables from .env loaded") 28 | } 29 | 30 | sshTasks := map[string][]string{} 31 | 32 | var j int 33 | var i int = 0 34 | for { 35 | connecitonUrl := os.Getenv("DIT_NODE" + strconv.Itoa(i+1)) 36 | 37 | if len(connecitonUrl) <= 0 { 38 | if i == 0 { 39 | log.Fatalln("no ssh config for node " + strconv.Itoa(i+1)) 40 | } 41 | 42 | break 43 | } 44 | 45 | sshTasks[connecitonUrl] = []string{} 46 | 47 | j = 0 48 | for { 49 | sshTask := os.Getenv("DIT_NODE" + strconv.Itoa(i+1) + "_TASK" + strconv.Itoa(j+1)) 50 | 51 | if len(sshTask) <= 0 { 52 | if j == 0 { 53 | log.Fatalln("no ssh task for node " + strconv.Itoa(i+1) + " and task " + strconv.Itoa(j+1)) 54 | } 55 | 56 | break 57 | } 58 | 59 | sshTasks[connecitonUrl] = append(sshTasks[connecitonUrl], sshTask) 60 | 61 | j++ 62 | } 63 | 64 | i++ 65 | } 66 | 67 | hosts := []SshTaskHost{} 68 | 69 | i = 0 70 | for connecitonUrl, sshTaskList := range sshTasks { 71 | taskHost, err := NewSshTaskHost( 72 | i, 73 | connecitonUrl, 74 | sshTaskList, 75 | ) 76 | 77 | if err != nil { 78 | log.Fatalln(err) 79 | } 80 | 81 | hosts = append(hosts, taskHost) 82 | 83 | i++ 84 | } 85 | 86 | for _, host := range hosts { 87 | err := host.PrecheckAll() 88 | if err != nil { 89 | log.Fatalln(err) 90 | } 91 | } 92 | 93 | for _, host := range hosts { 94 | err := host.Deploy() 95 | if err != nil { 96 | log.Fatalln(err) 97 | } 98 | } 99 | 100 | fmt.Println("done") 101 | } 102 | 103 | type SshTaskHost struct { 104 | ID int 105 | connecitonUrl string 106 | sshConfig sshutils.SshConfig 107 | tasks []dit.Task 108 | } 109 | 110 | func NewSshTaskHost( 111 | id int, 112 | connecitonUrl string, 113 | rawTasks []string, 114 | ) (SshTaskHost, error) { 115 | sshConfig, err := sshutils.NewSshConfig(connecitonUrl) 116 | if err != nil { 117 | return SshTaskHost{}, err 118 | } 119 | 120 | tasks := []dit.Task{} 121 | var newTask dit.Task 122 | 123 | for _, rawTask := range rawTasks { 124 | newTask, err = dit.ParseTask(rawTask) 125 | if err != nil { 126 | return SshTaskHost{}, err 127 | } 128 | 129 | tasks = append(tasks, newTask) 130 | } 131 | 132 | return SshTaskHost{ 133 | ID: id, 134 | connecitonUrl: connecitonUrl, 135 | sshConfig: sshConfig, 136 | tasks: tasks, 137 | }, nil 138 | } 139 | 140 | func (taskHost *SshTaskHost) PrecheckAll() error { 141 | for _, task := range taskHost.tasks { 142 | err := task.Precheck() 143 | if err != nil { 144 | return errors.New( 145 | "precheck failed for '" + strconv.Itoa(taskHost.ID) + 146 | "' task '" + task.Raw() + "': " + 147 | err.Error(), 148 | ) 149 | } 150 | } 151 | 152 | return nil 153 | } 154 | 155 | func (taskHost *SshTaskHost) Deploy() error { 156 | return sshutils.HandleSftp( 157 | taskHost.sshConfig, 158 | func( 159 | sftp *sftp.Client, 160 | session *ssh.Session, 161 | ) error { 162 | for id, task := range taskHost.tasks { 163 | fmt.Println("Execute task " + task.Raw()) 164 | err := task.Execute(sftp, session) 165 | if err != nil { 166 | return errors.New( 167 | "error host-" + strconv.Itoa(taskHost.ID) + 168 | " executing task-" + strconv.Itoa(id) + ": " + 169 | err.Error(), 170 | ) 171 | } 172 | } 173 | 174 | return nil 175 | }, 176 | ) 177 | } 178 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # DeployIT 2 | ![CI/CD](https://github.com/CoreUnit-NET/deployit/actions/workflows/go-bin-release.yml/badge.svg) 3 | ![CI/CD](https://github.com/CoreUnit-NET/deployit/actions/workflows/go-test-build.yml/badge.svg) 4 | ![MIT](https://img.shields.io/badge/license-MIT-blue.svg) 5 | ![](https://img.shields.io/badge/dynamic/json?color=green&label=watchers&query=watchers&suffix=x&url=https%3A%2F%2Fapi.github.com%2Frepos%2Fnoblemajo%2Fdeployit) 6 | ![](https://img.shields.io/badge/dynamic/json?color=yellow&label=stars&query=stargazers_count&suffix=x&url=https%3A%2F%2Fapi.github.com%2Frepos%2Fnoblemajo%2Fdeployit) 7 | ![](https://img.shields.io/badge/dynamic/json?color=navy&label=forks&query=forks&suffix=x&url=https%3A%2F%2Fapi.github.com%2Frepos%2Fnoblemajo%2Fdeployit) 8 | 9 | Uses ssh + sftp to deploy configurations to Linux servers and can execute simple commands. 10 | 11 | # Config 12 | DeployIT is easily configured using environment variables or an .env file. 13 | Here is a wireguard example: 14 | ```bash 15 | DIT_NODE1=ssh://@* 16 | DIT_NODE1_TASK1=UPLOAD@./node1.wg0.conf@/etc/wireguard/wg0.conf 17 | DIT_NODE1_TASK2=CMD@sudo wg-quick down wg0 || true && sudo wg-quick up wg0 18 | DIT_NODE1_TASK3=DOWNLOAD@/etc/wireguard/wg0.conf@./test.node1.wg0.conf 19 | 20 | DIT_NODE2=ssh://@* 21 | DIT_NODE2_TASK1=UPLOAD@./node2.wg0.conf@/etc/wireguard/wg0.conf 22 | DIT_NODE2_TASK2=CMD@sudo wg-quick down wg0 || true && sudo wg-quick up wg0 23 | DIT_NODE2_TASK3=DOWNLOAD@/etc/wireguard/wg0.conf@./test.node2.wg0.conf 24 | ``` 25 | 26 | This example deploys 2 different local Wireguard configs from `./nodeX.wg0.conf` to the selected host. 27 | It then runs a wg-quick down and up on that interface to reload the config. 28 | To test if the config and deployment were successful, it downloads the config to `./test.node2.wg0.conf`. 29 | 30 | For this example, make sure the user you are using has permissions to access `/etc/wireguard` on the server. 31 | If a password is used, use `!your-password` instead of `*`. 32 | 33 | # Table of Contents 34 | - [DeployIT](#deployit) 35 | - [Config](#config) 36 | - [Table of Contents](#table-of-contents) 37 | - [Getting Started](#getting-started) 38 | - [Requirements](#requirements) 39 | - [Install via go](#install-via-go) 40 | - [Install via wget](#install-via-wget) 41 | - [Build](#build) 42 | - [Install go](#install-go) 43 | - [Contributing](#contributing) 44 | - [License](#license) 45 | - [Disclaimer](#disclaimer) 46 | 47 | # Getting Started 48 | 49 | ## Requirements 50 | None windows system with `go` or `wget & tar` installed. 51 | 52 | ## Install via go 53 | ###### *For this section go is required, check out the [install go guide](#install-go).* 54 | 55 | ```sh 56 | go install https://github.com/CoreUnit-NET/deployit 57 | ``` 58 | 59 | ## Install via wget 60 | ```sh 61 | BIN_DIR="/usr/local/bin" 62 | DIT_VERSION="1.3.3" 63 | 64 | rm -rf $BIN_DIR/deployit 65 | wget https://github.com/CoreUnit-NET/deployit/releases/download/v$DIT_VERSION/deployit-v$DIT_VERSION-linux-amd64.tar.gz -O /tmp/deployit.tar.gz 66 | tar -xzvf /tmp/deployit.tar.gz -C $BIN_DIR/ deployit 67 | rm /tmp/deployit.tar.gz 68 | ``` 69 | 70 | ## Build 71 | ###### *For this section go is required, check out the [install go guide](#install-go).* 72 | 73 | Clone the repo: 74 | ```sh 75 | git clone https://github.com/CoreUnit-NET/deployit.git 76 | cd deployit 77 | ``` 78 | 79 | Build the deployit binary from source code: 80 | ```sh 81 | make build 82 | ./deployit 83 | ``` 84 | 85 | ## Install go 86 | The required go version for this project is in the `go.mod` file. 87 | 88 | To install and update go, I can recommend the following repo: 89 | ```sh 90 | git clone git@github.com:udhos/update-golang.git golang-updater 91 | cd golang-updater 92 | sudo ./update-golang.sh 93 | ``` 94 | 95 | # Contributing 96 | Contributions to this project are welcome! 97 | Interested users can refer to the guidelines provided in the [CONTRIBUTING.md](CONTRIBUTING.md) file to contribute to the project and help improve its functionality and features. 98 | 99 | # License 100 | This project is licensed under the [MIT license](LICENSE), providing users with flexibility and freedom to use and modify the software according to their needs. 101 | 102 | # Disclaimer 103 | This project is provided without warranties. 104 | Users are advised to review the accompanying license for more information on the terms of use and limitations of liability. 105 | -------------------------------------------------------------------------------- /lib/sshutils/config.go: -------------------------------------------------------------------------------- 1 | package sshutils 2 | 3 | import ( 4 | "errors" 5 | "net/url" 6 | "os" 7 | "strconv" 8 | "strings" 9 | 10 | "coreunit.net/wgg/lib/stringfs" 11 | ) 12 | 13 | type SshConfig struct { 14 | User string 15 | Host string 16 | Port int 17 | TargetDir string // used as relative dir for "./example" on the target server 18 | PrivateKey string // can be empty if Password is set 19 | Password string // can be empty if PrivateKey is set 20 | } 21 | 22 | func NewSshConfig( 23 | rawConnecitonUrl string, 24 | ) (SshConfig, error) { 25 | var password string 26 | var privateKey string 27 | var connecitonUrl string 28 | 29 | // rawConnecitonUrl example "ssh://User@hostname:2222/path/to/directory@privateKeyPath!password" 30 | if strings.Contains(rawConnecitonUrl, "*") { 31 | if strings.Contains(rawConnecitonUrl, "!") { 32 | if strings.Index(rawConnecitonUrl, "*") > strings.Index(rawConnecitonUrl, "!") { 33 | return SshConfig{}, errors.New( 34 | "invalid path credentials, '*'-privateKey needs to be defined before '!'-password, as suffix '" + 35 | rawConnecitonUrl + "'", 36 | ) 37 | } 38 | 39 | splitted := strings.Split(rawConnecitonUrl, "*") 40 | 41 | connecitonUrl = strings.TrimSpace(splitted[0]) 42 | privateKey = strings.TrimSpace(strings.Join(splitted[1:], "*")) 43 | 44 | splitted = strings.Split(privateKey, "!") 45 | privateKey = strings.TrimSpace(splitted[0]) 46 | password = strings.TrimSpace(strings.Join(splitted[1:], "!")) 47 | } else { 48 | splitted := strings.Split(rawConnecitonUrl, "*") 49 | connecitonUrl = strings.TrimSpace(splitted[0]) 50 | password = "" 51 | privateKey = strings.TrimSpace(strings.Join(splitted[1:], "*")) 52 | } 53 | } else { 54 | if strings.Contains(rawConnecitonUrl, "!") { 55 | splitted := strings.Split(rawConnecitonUrl, "!") 56 | 57 | connecitonUrl = strings.TrimSpace(splitted[0]) 58 | password = strings.TrimSpace(strings.Join(splitted[1:], "!")) 59 | privateKey = "" 60 | } else { 61 | return SshConfig{}, errors.New( 62 | "invalid path credentials, need '*' for privateKey " + 63 | "or '!' for password, is '" + 64 | rawConnecitonUrl + "'", 65 | ) 66 | } 67 | } 68 | 69 | if len(privateKey) > 0 { 70 | if strings.HasPrefix(privateKey, "~/") || 71 | strings.HasPrefix(privateKey, "./") || 72 | strings.HasPrefix(privateKey, "../") || 73 | strings.HasPrefix(privateKey, "/") || 74 | strings.HasPrefix(privateKey, "file://") { 75 | privateKeyPath := &privateKey 76 | err := stringfs.ParsePathRef(privateKeyPath) 77 | if err != nil { 78 | return SshConfig{}, errors.New( 79 | "error parsing path: " + 80 | err.Error(), 81 | ) 82 | } 83 | 84 | privateKeyBytes, err := os.ReadFile(*privateKeyPath) 85 | if err != nil { 86 | return SshConfig{}, errors.New( 87 | "error reading private key: " + 88 | err.Error(), 89 | ) 90 | } 91 | 92 | privateKey = string(privateKeyBytes) 93 | 94 | if len(privateKey) <= 0 { 95 | return SshConfig{}, errors.New( 96 | "loaded private key from " + 97 | *privateKeyPath + " is empty", 98 | ) 99 | } 100 | } 101 | } 102 | 103 | // connecitonUrl example "ssh://User@hostname:2222/path/to/directory" 104 | parsedURL, err := url.Parse(connecitonUrl) 105 | if err != nil { 106 | return SshConfig{}, errors.New("ssh url parse error: " + err.Error()) 107 | } 108 | 109 | if parsedURL.Scheme != "ssh" { 110 | return SshConfig{}, errors.New("invalid url scheme, need 'ssh', is '" + parsedURL.Scheme + "'") 111 | } 112 | 113 | var port int 114 | portString := parsedURL.Port() 115 | if len(portString) == 0 { 116 | port = 22 117 | } else { 118 | port, err = strconv.Atoi(parsedURL.Port()) 119 | if err != nil { 120 | return SshConfig{}, errors.New( 121 | "cant parse port to int, value '" + 122 | parsedURL.Port() + "': " + 123 | err.Error(), 124 | ) 125 | } 126 | } 127 | 128 | if port < 1 || port > 65535 { 129 | return SshConfig{}, errors.New("invalid port, need 1-65535, is '" + parsedURL.Port() + "'") 130 | } 131 | 132 | sshConfig := SshConfig{ 133 | User: parsedURL.User.Username(), 134 | Host: parsedURL.Hostname(), 135 | Port: port, 136 | TargetDir: parsedURL.Path, 137 | PrivateKey: privateKey, 138 | Password: password, 139 | } 140 | 141 | err = sshConfig.VerifySshConfig() 142 | if err != nil { 143 | return SshConfig{}, err 144 | } 145 | 146 | return sshConfig, nil 147 | } 148 | 149 | func (sshConfig *SshConfig) VerifySshConfig() error { 150 | if sshConfig.Port < 1 || sshConfig.Port > 65535 { 151 | return errors.New("sshconfig verify error: invalid port number") 152 | } 153 | 154 | if len(sshConfig.Password) <= 0 && len(sshConfig.PrivateKey) <= 0 { 155 | return errors.New("sshconfig verify error: password or private key is required") 156 | } 157 | 158 | if len(sshConfig.User) <= 0 { 159 | return errors.New("sshconfig verify error: User is required") 160 | } 161 | 162 | if len(sshConfig.Host) <= 0 { 163 | return errors.New("sshconfig verify error: host is required") 164 | } 165 | 166 | return nil 167 | } 168 | -------------------------------------------------------------------------------- /internal/task.go: -------------------------------------------------------------------------------- 1 | package dit 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "io" 7 | "os" 8 | "path/filepath" 9 | "strings" 10 | 11 | "github.com/pkg/sftp" 12 | "golang.org/x/crypto/ssh" 13 | ) 14 | 15 | type Task interface { 16 | Execute( 17 | sftp *sftp.Client, 18 | session *ssh.Session, 19 | ) error 20 | Type() string 21 | Raw() string 22 | Precheck() error 23 | } 24 | 25 | type UploadTask struct { 26 | RawTask string 27 | FromPath string 28 | ToPath string 29 | } 30 | 31 | func (task *UploadTask) Type() string { 32 | return "UPLOAD" 33 | } 34 | 35 | func (task *UploadTask) Raw() string { 36 | return task.RawTask 37 | } 38 | 39 | func (task *UploadTask) Precheck() error { 40 | if task.FromPath == "" { 41 | return errors.New("upload source file is empty") 42 | } 43 | 44 | stats, err := os.Stat(task.FromPath) 45 | if err != nil { 46 | return err 47 | } 48 | 49 | if !stats.Mode().IsRegular() { 50 | return errors.New(task.FromPath + " is not a regular file") 51 | } 52 | 53 | return nil 54 | } 55 | 56 | func (task *UploadTask) Execute( 57 | sftp *sftp.Client, 58 | session *ssh.Session, 59 | ) error { 60 | srcFile, err := os.Open(task.FromPath) 61 | if err != nil { 62 | return errors.New("error opening source file: " + err.Error()) 63 | } 64 | defer srcFile.Close() 65 | 66 | dstFile, err := sftp.Create(task.ToPath) 67 | if err != nil { 68 | return errors.New("error creating destination file: " + err.Error()) 69 | } 70 | defer dstFile.Close() 71 | _, err = io.Copy(dstFile, srcFile) 72 | if err != nil { 73 | return errors.New("error copying file to remote: " + err.Error()) 74 | } 75 | 76 | return nil 77 | } 78 | 79 | type DownloadTask struct { 80 | RawTask string 81 | FromPath string 82 | ToPath string 83 | } 84 | 85 | func (task *DownloadTask) Type() string { 86 | return "DOWNLOAD" 87 | } 88 | 89 | func (task *DownloadTask) Raw() string { 90 | return task.RawTask 91 | } 92 | 93 | func (task *DownloadTask) Precheck() error { 94 | if task.FromPath == "" { 95 | return errors.New("download source file is empty") 96 | } 97 | 98 | parentDir := filepath.Dir(task.ToPath) 99 | 100 | stats, err := os.Stat(parentDir) 101 | if err != nil { 102 | return errors.New("cant stat parent dir of local target: '" + parentDir + "': " + err.Error()) 103 | } 104 | 105 | if !stats.IsDir() { 106 | return errors.New("local target dir is not a directory: '" + parentDir + "'") 107 | } 108 | 109 | return nil 110 | } 111 | 112 | func (task *DownloadTask) Execute( 113 | sftp *sftp.Client, 114 | session *ssh.Session, 115 | ) error { 116 | dstFile, err := os.Create(task.ToPath) 117 | if err != nil { 118 | return errors.New("error creating destination file: " + err.Error()) 119 | } 120 | defer dstFile.Close() 121 | 122 | srcFile, err := sftp.Open(task.FromPath) 123 | if err != nil { 124 | return errors.New("error opening source file: " + err.Error()) 125 | } 126 | defer srcFile.Close() 127 | 128 | _, err = io.Copy(dstFile, srcFile) 129 | if err != nil { 130 | return errors.New("error copying file to local: " + err.Error()) 131 | } 132 | 133 | return nil 134 | } 135 | 136 | type CommandTask struct { 137 | RawTask string 138 | Cmd string 139 | } 140 | 141 | func (task *CommandTask) Type() string { 142 | return "CMD" 143 | } 144 | 145 | func (task *CommandTask) Raw() string { 146 | return task.RawTask 147 | } 148 | 149 | func (task *CommandTask) Precheck() error { 150 | return nil 151 | } 152 | 153 | func (task *CommandTask) Execute( 154 | sftp *sftp.Client, 155 | session *ssh.Session, 156 | ) error { 157 | out, err := session.CombinedOutput(task.Cmd) 158 | if err != nil { 159 | out2 := string(out) 160 | if len(out2) > 0 { 161 | return errors.New( 162 | "error executing command '" + task.Cmd + 163 | "': output: '" + string(out) + 164 | "', error: " + err.Error(), 165 | ) 166 | } else { 167 | return errors.New( 168 | "error executing command '" + task.Cmd + 169 | "': empty output, error: " + err.Error(), 170 | ) 171 | } 172 | } 173 | fmt.Println("\nCommand output of '" + task.Cmd + "':\n" + string(out) + "") 174 | 175 | return nil 176 | } 177 | 178 | func ParseTask(task string) (Task, error) { 179 | if task == "" { 180 | return nil, errors.New("cmd task command is empty") 181 | } 182 | 183 | splitted := strings.Split(task, "@") 184 | if splitted[0] == "UPLOAD" { 185 | if len(splitted) != 3 { 186 | return nil, errors.New( 187 | "invalid upload task: task has invalid format: " + 188 | "UPLOAD@@ but is '" + 189 | task + "'", 190 | ) 191 | } 192 | return &UploadTask{ 193 | RawTask: task, 194 | FromPath: splitted[1], 195 | ToPath: splitted[2], 196 | }, nil 197 | } else if splitted[0] == "DOWNLOAD" { 198 | if len(splitted) != 3 { 199 | return nil, errors.New( 200 | "invalid download task: task has invalid format: " + 201 | "DOWNLOAD@@ but is '" + 202 | task + "'", 203 | ) 204 | } 205 | return &DownloadTask{ 206 | RawTask: task, 207 | FromPath: splitted[1], 208 | ToPath: splitted[2], 209 | }, nil 210 | } else if splitted[0] == "CMD" { 211 | if len(splitted) != 2 { 212 | return nil, errors.New( 213 | "invalid command task: task has invalid format: " + 214 | "COMMAND@ but is '" + 215 | task + "'", 216 | ) 217 | } 218 | return &CommandTask{ 219 | RawTask: task, 220 | Cmd: splitted[1], 221 | }, nil 222 | } else { 223 | return nil, errors.New("cant parse task: unknown task: '" + task + "'") 224 | } 225 | } 226 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 2 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 3 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 4 | github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= 5 | github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 6 | github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= 7 | github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= 8 | github.com/pkg/sftp v1.13.7 h1:uv+I3nNJvlKZIQGSr8JVQLNHFU9YhhNpvC14Y6KgmSM= 9 | github.com/pkg/sftp v1.13.7/go.mod h1:KMKI0t3T6hfA+lTR/ssZdunHo+uwq7ghoN09/FSu3DY= 10 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 11 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 12 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 13 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 14 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 15 | github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk= 16 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 17 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 18 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 19 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 20 | golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= 21 | golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw= 22 | golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U= 23 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 24 | golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 25 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 26 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 27 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 28 | golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 29 | golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= 30 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 31 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 32 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 33 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 34 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 35 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 36 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 37 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 38 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 39 | golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 40 | golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 41 | golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= 42 | golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 43 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 44 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 45 | golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 46 | golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= 47 | golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0= 48 | golang.org/x/term v0.25.0 h1:WtHI/ltw4NvSUig5KARz9h521QvRC8RmF/cuYqifU24= 49 | golang.org/x/term v0.25.0/go.mod h1:RPyXicDX+6vLxogjjRxjgD2TKtmAO6NZBsBRfrOLu7M= 50 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 51 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 52 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 53 | golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 54 | golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 55 | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 56 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 57 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 58 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 59 | golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= 60 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 61 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 62 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 63 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 64 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 65 | --------------------------------------------------------------------------------