├── internal ├── gui │ ├── assets │ │ ├── osx │ │ │ ├── icon.png │ │ │ ├── icon-1.png │ │ │ ├── icon-2.png │ │ │ ├── icon-3.png │ │ │ ├── icon-4.png │ │ │ ├── icon-5.png │ │ │ ├── icon-6.png │ │ │ ├── red-circle-icon.png │ │ │ └── green-circle-icon.png │ │ ├── win │ │ │ ├── icon.ico │ │ │ ├── icon-1.ico │ │ │ ├── icon-2.ico │ │ │ ├── icon-3.ico │ │ │ ├── icon-4.ico │ │ │ ├── icon-5.ico │ │ │ ├── icon-6.ico │ │ │ ├── red-circle-icon.ico │ │ │ └── green-circle-icon.ico │ │ ├── assets_osx.go │ │ └── assets_win.go │ └── systray.go ├── inventory │ ├── errors.go │ ├── overrides.go │ └── inventory.go ├── netbox │ ├── errors.go │ ├── netbox.go │ └── models.go └── config │ └── config.go ├── tools ├── assets │ └── securecrt-inventory.app │ │ └── Contents │ │ ├── Resources │ │ └── icon.icns │ │ └── Info.plist └── build.sh ├── .gitignore ├── pkg ├── securecrt │ ├── errors.go │ ├── config.go │ ├── securecrt.go │ └── session.go └── evaluator │ ├── environment.go │ └── evaluator.go ├── .vscode └── launch.json ├── .github └── workflows │ └── release.yaml ├── go.mod ├── LICENSE.md ├── go.sum ├── main.go └── README.md /internal/gui/assets/osx/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jysk-oss/netbox-securecrt-inventory/HEAD/internal/gui/assets/osx/icon.png -------------------------------------------------------------------------------- /internal/gui/assets/win/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jysk-oss/netbox-securecrt-inventory/HEAD/internal/gui/assets/win/icon.ico -------------------------------------------------------------------------------- /internal/gui/assets/osx/icon-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jysk-oss/netbox-securecrt-inventory/HEAD/internal/gui/assets/osx/icon-1.png -------------------------------------------------------------------------------- /internal/gui/assets/osx/icon-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jysk-oss/netbox-securecrt-inventory/HEAD/internal/gui/assets/osx/icon-2.png -------------------------------------------------------------------------------- /internal/gui/assets/osx/icon-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jysk-oss/netbox-securecrt-inventory/HEAD/internal/gui/assets/osx/icon-3.png -------------------------------------------------------------------------------- /internal/gui/assets/osx/icon-4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jysk-oss/netbox-securecrt-inventory/HEAD/internal/gui/assets/osx/icon-4.png -------------------------------------------------------------------------------- /internal/gui/assets/osx/icon-5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jysk-oss/netbox-securecrt-inventory/HEAD/internal/gui/assets/osx/icon-5.png -------------------------------------------------------------------------------- /internal/gui/assets/osx/icon-6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jysk-oss/netbox-securecrt-inventory/HEAD/internal/gui/assets/osx/icon-6.png -------------------------------------------------------------------------------- /internal/gui/assets/win/icon-1.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jysk-oss/netbox-securecrt-inventory/HEAD/internal/gui/assets/win/icon-1.ico -------------------------------------------------------------------------------- /internal/gui/assets/win/icon-2.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jysk-oss/netbox-securecrt-inventory/HEAD/internal/gui/assets/win/icon-2.ico -------------------------------------------------------------------------------- /internal/gui/assets/win/icon-3.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jysk-oss/netbox-securecrt-inventory/HEAD/internal/gui/assets/win/icon-3.ico -------------------------------------------------------------------------------- /internal/gui/assets/win/icon-4.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jysk-oss/netbox-securecrt-inventory/HEAD/internal/gui/assets/win/icon-4.ico -------------------------------------------------------------------------------- /internal/gui/assets/win/icon-5.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jysk-oss/netbox-securecrt-inventory/HEAD/internal/gui/assets/win/icon-5.ico -------------------------------------------------------------------------------- /internal/gui/assets/win/icon-6.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jysk-oss/netbox-securecrt-inventory/HEAD/internal/gui/assets/win/icon-6.ico -------------------------------------------------------------------------------- /internal/gui/assets/osx/red-circle-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jysk-oss/netbox-securecrt-inventory/HEAD/internal/gui/assets/osx/red-circle-icon.png -------------------------------------------------------------------------------- /internal/gui/assets/win/red-circle-icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jysk-oss/netbox-securecrt-inventory/HEAD/internal/gui/assets/win/red-circle-icon.ico -------------------------------------------------------------------------------- /internal/inventory/errors.go: -------------------------------------------------------------------------------- 1 | package inventory 2 | 3 | import "errors" 4 | 5 | var ( 6 | ErrorFailedToFindSite = errors.New("unable to get site") 7 | ) 8 | -------------------------------------------------------------------------------- /internal/gui/assets/osx/green-circle-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jysk-oss/netbox-securecrt-inventory/HEAD/internal/gui/assets/osx/green-circle-icon.png -------------------------------------------------------------------------------- /internal/gui/assets/win/green-circle-icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jysk-oss/netbox-securecrt-inventory/HEAD/internal/gui/assets/win/green-circle-icon.ico -------------------------------------------------------------------------------- /tools/assets/securecrt-inventory.app/Contents/Resources/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jysk-oss/netbox-securecrt-inventory/HEAD/tools/assets/securecrt-inventory.app/Contents/Resources/icon.icns -------------------------------------------------------------------------------- /internal/netbox/errors.go: -------------------------------------------------------------------------------- 1 | package netbox 2 | 3 | import "errors" 4 | 5 | var ( 6 | ErrFailedToQuerySites = errors.New("unable to get sites") 7 | ErrFailedToQueryDevices = errors.New("unable to get devices") 8 | ) 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist/* 2 | .DS_Store 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 -------------------------------------------------------------------------------- /pkg/securecrt/errors.go: -------------------------------------------------------------------------------- 1 | package securecrt 2 | 3 | import "errors" 4 | 5 | var ( 6 | ErrFailedToExpandHomeDir = errors.New("unable to expand user home dir") 7 | ErrFailedToLoadConfig = errors.New("failed to get securecrt config") 8 | ErrFailedToLoadCredentials = errors.New("failed to load default credentials") 9 | ErrFailedToCreateSession = errors.New("failed to create session") 10 | ErrFailedToReadSession = errors.New("failed to read session") 11 | ) 12 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Launch file", 9 | "type": "go", 10 | "request": "launch", 11 | "mode": "debug", 12 | "program": "main.go" 13 | } 14 | 15 | ] 16 | } -------------------------------------------------------------------------------- /internal/gui/assets/assets_osx.go: -------------------------------------------------------------------------------- 1 | //go:build darwin 2 | 3 | package assets 4 | 5 | import _ "embed" 6 | 7 | //go:embed osx/green-circle-icon.png 8 | var StatusIconGreen []byte 9 | 10 | //go:embed osx/red-circle-icon.png 11 | var StatusIconRed []byte 12 | 13 | //go:embed osx/icon.png 14 | var Icon []byte 15 | 16 | //go:embed osx/icon-1.png 17 | var AnimateIcon1 []byte 18 | 19 | //go:embed osx/icon-2.png 20 | var AnimateIcon2 []byte 21 | 22 | //go:embed osx/icon-3.png 23 | var AnimateIcon3 []byte 24 | 25 | //go:embed osx/icon-4.png 26 | var AnimateIcon4 []byte 27 | 28 | //go:embed osx/icon-5.png 29 | var AnimateIcon5 []byte 30 | 31 | //go:embed osx/icon-6.png 32 | var AnimateIcon6 []byte 33 | -------------------------------------------------------------------------------- /internal/gui/assets/assets_win.go: -------------------------------------------------------------------------------- 1 | //go:build windows 2 | 3 | package assets 4 | 5 | import _ "embed" 6 | 7 | //go:embed win/green-circle-icon.ico 8 | var StatusIconGreen []byte 9 | 10 | //go:embed win/red-circle-icon.ico 11 | var StatusIconRed []byte 12 | 13 | //go:embed win/icon.ico 14 | var Icon []byte 15 | 16 | //go:embed win/icon-1.ico 17 | var AnimateIcon1 []byte 18 | 19 | //go:embed win/icon-2.ico 20 | var AnimateIcon2 []byte 21 | 22 | //go:embed win/icon-3.ico 23 | var AnimateIcon3 []byte 24 | 25 | //go:embed win/icon-4.ico 26 | var AnimateIcon4 []byte 27 | 28 | //go:embed win/icon-5.ico 29 | var AnimateIcon5 []byte 30 | 31 | //go:embed win/icon-6.ico 32 | var AnimateIcon6 []byte 33 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release version 2 | 3 | on: 4 | push: 5 | branches: [ "master" ] 6 | # Publish semver tags as releases. 7 | tags: [ 'v*.*.*' ] 8 | 9 | jobs: 10 | build-release: 11 | runs-on: macos-latest 12 | permissions: 13 | contents: write 14 | steps: 15 | - uses: actions/checkout@v4 16 | 17 | - name: Setup Go 18 | uses: actions/setup-go@v4 19 | with: 20 | go-version: 1.21.1 21 | 22 | - name: Build release files 23 | run: | 24 | ./tools/build.sh 25 | 26 | - uses: ncipollo/release-action@v1 27 | with: 28 | artifacts: "dist/*" 29 | generateReleaseNotes: true -------------------------------------------------------------------------------- /pkg/securecrt/config.go: -------------------------------------------------------------------------------- 1 | package securecrt 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "runtime" 7 | ) 8 | 9 | func getConfigPath() (string, error) { 10 | appDataDir, err := os.UserConfigDir() 11 | if err != nil { 12 | return "", ErrFailedToExpandHomeDir 13 | } 14 | 15 | path := "VanDyke/SecureCRT/Config" 16 | if runtime.GOOS == "windows" { 17 | path = "VanDyke/Config" 18 | } 19 | 20 | return fmt.Sprintf("%s/%s", appDataDir, path), nil 21 | } 22 | 23 | func loadDefaultSessionConfig(sessionPath string) (string, error) { 24 | data, err := os.ReadFile(fmt.Sprintf("%s/Default.ini", sessionPath)) 25 | if err != nil { 26 | return "", ErrFailedToLoadConfig 27 | } 28 | 29 | return string(data), nil 30 | } 31 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/jysk-network/netbox-securecrt-inventory 2 | 3 | go 1.22.0 4 | 5 | toolchain go1.23.2 6 | 7 | require ( 8 | fyne.io/systray v1.11.0 9 | github.com/expr-lang/expr v1.17.0 10 | github.com/sqweek/dialog v0.0.0-20240226140203-065105509627 11 | golang.org/x/sync v0.8.0 12 | gopkg.in/yaml.v3 v3.0.1 13 | ) 14 | 15 | require ( 16 | github.com/TheTitanrain/w32 v0.0.0-20180517000239-4f5cfb03fabf // indirect 17 | github.com/godbus/dbus/v5 v5.1.0 // indirect 18 | github.com/kr/text v0.2.0 // indirect 19 | github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect 20 | golang.org/x/sys v0.26.0 // indirect 21 | gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect 22 | ) 23 | -------------------------------------------------------------------------------- /tools/build.sh: -------------------------------------------------------------------------------- 1 | mkdir -p dist/darwin/arm64 2 | mkdir -p dist/darwin/amd64 3 | 4 | cp -r tools/assets/securecrt-inventory.app dist/darwin/arm64 5 | cp -r tools/assets/securecrt-inventory.app dist/darwin/amd64 6 | 7 | CGO_ENABLED=1 GOOS=windows GOARCH=amd64 go build -ldflags "-H=windowsgui" -o dist/securecrt-inventory.exe main.go 8 | CGO_ENABLED=1 GOOS=darwin GOARCH=arm64 go build -o dist/darwin/arm64/securecrt-inventory.app/Contents/MacOS/securecrt-inventory main.go 9 | CGO_ENABLED=1 GOOS=darwin GOARCH=amd64 go build -o dist/darwin/amd64/securecrt-inventory.app/Contents/MacOS/securecrt-inventory main.go 10 | 11 | cd dist 12 | cd darwin/arm64 && zip -r ../../securecrt-inventory-darwin-arm64.zip securecrt-inventory.app 13 | cd ../amd64 && zip -r ../../securecrt-inventory-darwin-amd64.zip securecrt-inventory.app 14 | cd ../../ && zip -r -j securecrt-inventory-windows-amd64.zip securecrt-inventory.exe 15 | 16 | rm securecrt-inventory.exe 17 | rm -rf darwin 18 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 JYSK A/S 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. -------------------------------------------------------------------------------- /tools/assets/securecrt-inventory.app/Contents/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDisplayName 6 | SecureCRT NetBox Inventory 7 | CFBundleExecutable 8 | securecrt-inventory 9 | CFBundleIconFile 10 | icon.icns 11 | CFBundleIdentifier 12 | com.jysk.securecrt-inventory 13 | CFBundleInfoDictionaryVersion 14 | 6.0 15 | CFBundleName 16 | SecureCRT NetBox Inventory 17 | CFBundlePackageType 18 | APPL 19 | CFBundleShortVersionString 20 | 1.0.0 21 | CFBundleVersion 22 | 1.0.0 23 | NSHighResolutionCapable 24 | True 25 | NSRequiresAquaSystemAppearance 26 | No 27 | 28 | LSUIElement 29 | 1 30 | 31 | 32 | -------------------------------------------------------------------------------- /pkg/evaluator/environment.go: -------------------------------------------------------------------------------- 1 | package evaluator 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/jysk-network/netbox-securecrt-inventory/internal/netbox" 8 | ) 9 | 10 | type Environment struct { 11 | SessionType string `expr:"session_type"` 12 | Description string `expr:"description"` 13 | Credential string `expr:"credential"` 14 | Path string `expr:"path"` 15 | PathTemplate string `expr:"path_template"` 16 | DeviceName string `expr:"device_name"` 17 | DeviceNameTemplate string `expr:"device_name_template"` 18 | Firewall string `expr:"firewall"` 19 | FirewallTemplate string `expr:"firewall_template"` 20 | ConnectionProtocol string `expr:"connection_protocol"` 21 | ConnectionProtocolTemplate string `expr:"connection_protocol_template"` 22 | DeviceRole string `expr:"device_role"` 23 | DeviceType string `expr:"device_type"` 24 | DeviceIP string `expr:"device_ip"` 25 | DevicePort int `expr:"device_port"` 26 | RegionName string `expr:"region_name"` 27 | TenantName string `expr:"tenant_name"` 28 | SiteName string `expr:"site_name"` 29 | SiteGroup string `expr:"site_group"` 30 | SiteAddress string `expr:"site_address"` 31 | VirtualChassisName string `expr:"virtual_chassis_name"` 32 | IsConsoleSession bool `expr:"is_console_session"` 33 | ConsoleServerPort string `expr:"console_server_port"` 34 | 35 | Device interface{} `expr:"device"` 36 | Site interface{} `expr:"site"` 37 | } 38 | 39 | func (Environment) FindTag(tags []netbox.NestedTag, label string) *string { 40 | for i := 0; i < len(tags); i++ { 41 | if strings.Contains(tags[i].Name, label) { 42 | result := strings.TrimPrefix(tags[i].Name, fmt.Sprintf("%s:", label)) 43 | return &result 44 | } 45 | } 46 | 47 | return nil 48 | } 49 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | fyne.io/systray v1.11.0 h1:D9HISlxSkx+jHSniMBR6fCFOUjk1x/OOOJLa9lJYAKg= 2 | fyne.io/systray v1.11.0/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs= 3 | github.com/TheTitanrain/w32 v0.0.0-20180517000239-4f5cfb03fabf h1:FPsprx82rdrX2jiKyS17BH6IrTmUBYqZa/CXT4uvb+I= 4 | github.com/TheTitanrain/w32 v0.0.0-20180517000239-4f5cfb03fabf/go.mod h1:peYoMncQljjNS6tZwI9WVyQB3qZS6u79/N3mBOcnd3I= 5 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 6 | github.com/expr-lang/expr v1.17.0 h1:+vpszOyzKLQXC9VF+wA8cVA0tlA984/Wabc/1hF9Whg= 7 | github.com/expr-lang/expr v1.17.0/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4= 8 | github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= 9 | github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= 10 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 11 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 12 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 13 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 14 | github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= 15 | github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= 16 | github.com/sqweek/dialog v0.0.0-20240226140203-065105509627 h1:2JL2wmHXWIAxDofCK+AdkFi1KEg3dgkefCsm7isADzQ= 17 | github.com/sqweek/dialog v0.0.0-20240226140203-065105509627/go.mod h1:/qNPSY91qTz/8TgHEMioAUc6q7+3SOybeKczHMXFcXw= 18 | golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= 19 | golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 20 | golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= 21 | golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 22 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 23 | gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= 24 | gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 25 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 26 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 27 | -------------------------------------------------------------------------------- /pkg/evaluator/evaluator.go: -------------------------------------------------------------------------------- 1 | package evaluator 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "log/slog" 7 | "reflect" 8 | "strings" 9 | 10 | "github.com/expr-lang/expr" 11 | "github.com/expr-lang/expr/vm" 12 | ) 13 | 14 | var compiledConditions map[string]*vm.Program = make(map[string]*vm.Program) 15 | 16 | func EvaluateCondition(condition string, env *Environment) (bool, error) { 17 | output, err := EvaluateResult(condition, env) 18 | if err != nil { 19 | return false, err 20 | } 21 | 22 | val, ok := output.(bool) 23 | if !ok { 24 | slog.Error("Condition should return true or false", slog.Any("result", val)) 25 | return false, errors.New("condition should return true or false") 26 | } 27 | 28 | return val, nil 29 | } 30 | 31 | func EvaluateResult(condition string, env *Environment) (any, error) { 32 | if !strings.Contains(condition, "{{") { 33 | return ApplyTemplate(condition, env), nil 34 | } 35 | 36 | start, end := getStringBeforeAfter(condition, "{{", "}}") 37 | 38 | condition = getStringInBetween(condition, "{{", "}}") 39 | condition = strings.Trim(condition, " ") 40 | 41 | // compile and cache conditions 42 | if compiledConditions[condition] == nil { 43 | program, err := expr.Compile(condition) 44 | if err != nil { 45 | slog.Error("Failed to compile condition", slog.String("error", err.Error())) 46 | return false, err 47 | } 48 | 49 | compiledConditions[condition] = program 50 | } 51 | 52 | output, err := expr.Run(compiledConditions[condition], env) 53 | if err != nil { 54 | slog.Error("Failed to run condition", slog.String("error", err.Error())) 55 | return false, err 56 | } 57 | 58 | if start != "" || end != "" { 59 | output = fmt.Sprintf("%s%s%s", start, output, end) 60 | } 61 | 62 | slog.Debug("Evaluation Result", slog.String("device", env.DeviceName), slog.String("condition", condition), slog.Any("result", output)) 63 | return output, nil 64 | } 65 | 66 | func ApplyTemplate(template string, env *Environment) string { 67 | oldTemplate := template 68 | v := reflect.ValueOf(env).Elem() 69 | for i := 0; i < v.NumField(); i++ { 70 | if v.Field(i).Kind() == reflect.String { 71 | tag := v.Type().Field(i).Tag.Get("expr") 72 | template = strings.ReplaceAll(template, fmt.Sprintf("{%s}", tag), v.Field(i).String()) 73 | } 74 | } 75 | 76 | slog.Debug("Evaluation Template Result", slog.String("device", env.DeviceName), slog.String("template", oldTemplate), slog.String("result", template)) 77 | return template 78 | } 79 | 80 | func getStringBeforeAfter(str string, start string, end string) (string, string) { 81 | s := strings.Index(str, start) 82 | if s == -1 { 83 | return "", "" 84 | } 85 | s += len(start) 86 | e := strings.Index(str[s:], end) 87 | if e == -1 { 88 | return "", "" 89 | } 90 | return str[:s-2], str[s+e+2:] 91 | } 92 | 93 | func getStringInBetween(str string, start string, end string) string { 94 | s := strings.Index(str, start) 95 | if s == -1 { 96 | return "" 97 | } 98 | s += len(start) 99 | e := strings.Index(str[s:], end) 100 | if e == -1 { 101 | return "" 102 | } 103 | return str[s : s+e] 104 | } 105 | -------------------------------------------------------------------------------- /pkg/securecrt/securecrt.go: -------------------------------------------------------------------------------- 1 | package securecrt 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "io/fs" 7 | "log/slog" 8 | "os" 9 | "path/filepath" 10 | "slices" 11 | "strings" 12 | "sync" 13 | 14 | "golang.org/x/sync/errgroup" 15 | ) 16 | 17 | type SecureCRT struct { 18 | configPath string 19 | defaultConfig string 20 | rootPath string 21 | sessionPath string 22 | } 23 | 24 | func New(rootPath string) (*SecureCRT, error) { 25 | configPath, err := getConfigPath() 26 | if err != nil { 27 | return nil, err 28 | } 29 | 30 | defaultConfig, err := loadDefaultSessionConfig(fmt.Sprintf("%s/Sessions", configPath)) 31 | if err != nil { 32 | return nil, err 33 | } 34 | 35 | return &SecureCRT{ 36 | configPath: configPath, 37 | defaultConfig: defaultConfig, 38 | rootPath: rootPath, 39 | sessionPath: fmt.Sprintf("%s/Sessions/%s", configPath, rootPath), 40 | }, nil 41 | } 42 | 43 | func (scrt *SecureCRT) GetSessions() ([]*SecureCRTSession, error) { 44 | var mu sync.Mutex 45 | var eg errgroup.Group 46 | var sessions []*SecureCRTSession 47 | 48 | eg.SetLimit(50) 49 | err := filepath.WalkDir(scrt.sessionPath, func(path string, d fs.DirEntry, err error) error { 50 | if err != nil || d.IsDir() || !strings.HasSuffix(d.Name(), ".ini") { 51 | return err 52 | } 53 | 54 | fileName := strings.ReplaceAll(d.Name(), ".ini", "") 55 | if strings.HasPrefix(fileName, "__") && strings.HasSuffix(fileName, "__") { 56 | return nil 57 | } 58 | 59 | eg.Go(func() error { 60 | session := NewSession(path) 61 | err = session.read() 62 | if err != nil { 63 | return err 64 | } 65 | 66 | mu.Lock() 67 | sessions = append(sessions, session) 68 | mu.Unlock() 69 | return nil 70 | }) 71 | 72 | return nil 73 | }) 74 | 75 | if err != nil { 76 | return nil, ErrFailedToReadSession 77 | } 78 | 79 | err = eg.Wait() 80 | return sessions, err 81 | } 82 | 83 | func (scrt *SecureCRT) RemoveSessions(sessions []*SecureCRTSession) error { 84 | currentSessions, err := scrt.GetSessions() 85 | if err != nil { 86 | return err 87 | } 88 | 89 | for i := 0; i < len(currentSessions); i++ { 90 | found := slices.ContainsFunc(sessions, func(e *SecureCRTSession) bool { 91 | return currentSessions[i].DeviceName == e.DeviceName && currentSessions[i].fullPath == e.fullPath 92 | }) 93 | 94 | if !found { 95 | err = currentSessions[i].delete() 96 | if err != nil { 97 | return err 98 | } 99 | } 100 | } 101 | 102 | return err 103 | } 104 | 105 | func (scrt *SecureCRT) GetSessionPath() string { 106 | return scrt.sessionPath 107 | } 108 | 109 | func (scrt *SecureCRT) WriteSession(session *SecureCRTSession) error { 110 | info, err := os.Stat(scrt.configPath) 111 | if err != nil { 112 | slog.Error("Failed to load securecrt session file", slog.String("error", err.Error())) 113 | return err 114 | } 115 | 116 | err = session.write(scrt.defaultConfig, info.Mode()) 117 | if err != nil { 118 | slog.Error("Failed to write securecrt session", slog.String("error", err.Error())) 119 | return errors.Join(ErrFailedToCreateSession, err) 120 | } 121 | 122 | return nil 123 | } 124 | -------------------------------------------------------------------------------- /internal/inventory/overrides.go: -------------------------------------------------------------------------------- 1 | package inventory 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "log/slog" 8 | "strings" 9 | 10 | "github.com/jysk-network/netbox-securecrt-inventory/internal/config" 11 | "github.com/jysk-network/netbox-securecrt-inventory/pkg/evaluator" 12 | "github.com/jysk-network/netbox-securecrt-inventory/pkg/securecrt" 13 | ) 14 | 15 | func applyDefaultOverrides(env *evaluator.Environment) error { 16 | protocol, err := evaluator.EvaluateResult(env.ConnectionProtocolTemplate, env) 17 | if err != nil { 18 | return err 19 | } 20 | 21 | path, err := evaluator.EvaluateResult(env.PathTemplate, env) 22 | if err != nil { 23 | return err 24 | } 25 | 26 | device_name, err := evaluator.EvaluateResult(env.DeviceNameTemplate, env) 27 | if err != nil { 28 | return err 29 | } 30 | 31 | firewall, err := evaluator.EvaluateResult(env.FirewallTemplate, env) 32 | if err != nil { 33 | return err 34 | } 35 | 36 | //TODO: should we check for string type here? yeah.. 37 | env.ConnectionProtocol = protocol.(string) 38 | env.Path = path.(string) 39 | env.DeviceName = device_name.(string) 40 | env.Firewall = firewall.(string) 41 | 42 | address := strings.ReplaceAll(env.SiteAddress, "\n", ", ") 43 | env.Description = fmt.Sprintf("Site: %s", env.SiteName) + fmt.Sprintf("\nType: %s", env.DeviceType) + fmt.Sprintf("\nAdresse: %s", address) 44 | return nil 45 | } 46 | 47 | func applyOverrides(overrides []config.ConfigSessionOverride, env *evaluator.Environment) error { 48 | if slog.Default().Enabled(context.TODO(), slog.LevelDebug) { 49 | data, _ := json.Marshal(env) 50 | slog.Debug("Starting Override Evaluation", slog.String("device", env.DeviceName), slog.String("env", string(data))) 51 | } 52 | 53 | err := applyDefaultOverrides(env) 54 | if err != nil { 55 | return err 56 | } 57 | 58 | for _, override := range overrides { 59 | shouldOverride, err := evaluator.EvaluateCondition(override.Condition, env) 60 | if err != nil || !shouldOverride { 61 | continue 62 | } 63 | 64 | val, err := evaluator.EvaluateResult(override.Value, env) 65 | if err != nil { 66 | return err 67 | } 68 | 69 | sVal, ok := val.(string) 70 | if val != nil && ok { 71 | switch override.Target { 72 | case "path": 73 | env.Path = sVal 74 | case "device_name": 75 | env.DeviceName = sVal 76 | case "description": 77 | env.Description = sVal 78 | case "connection_protocol": 79 | env.ConnectionProtocol = sVal 80 | case "credential": 81 | env.Credential = sVal 82 | case "firewall": 83 | if sVal == "" || sVal == "None" { 84 | env.Firewall = "None" 85 | } else { 86 | env.Firewall = "Session:" + sVal 87 | } 88 | 89 | } 90 | } 91 | 92 | iVal, ok := val.(int) 93 | if val != nil && ok { 94 | switch override.Target { 95 | case "device_port": 96 | env.DevicePort = iVal 97 | } 98 | } 99 | } 100 | 101 | return err 102 | } 103 | 104 | func getSessionWithOverrides(fullPath string, env *evaluator.Environment) *securecrt.SecureCRTSession { 105 | session := securecrt.NewSession(fullPath) 106 | session.IP = env.DeviceIP 107 | session.Port = env.DevicePort 108 | session.Path = env.Path 109 | session.DeviceName = env.DeviceName 110 | session.CredentialName = env.Credential 111 | session.Description = env.Description 112 | session.Protocol = env.ConnectionProtocol 113 | session.Firewall = env.Firewall 114 | 115 | return session 116 | } 117 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log/slog" 7 | "os" 8 | "os/exec" 9 | "path/filepath" 10 | "runtime" 11 | 12 | "github.com/jysk-network/netbox-securecrt-inventory/internal/config" 13 | "github.com/jysk-network/netbox-securecrt-inventory/internal/gui" 14 | "github.com/jysk-network/netbox-securecrt-inventory/internal/inventory" 15 | "github.com/jysk-network/netbox-securecrt-inventory/internal/netbox" 16 | "github.com/jysk-network/netbox-securecrt-inventory/pkg/securecrt" 17 | "github.com/sqweek/dialog" 18 | ) 19 | 20 | func main() { 21 | // make sure our config is valid 22 | cfgPath, err := config.ParseFlags() 23 | if err != nil { 24 | dialog.Message("Error: %v", err).Title("Config Error").Error() 25 | return 26 | } 27 | 28 | cfg, err := config.NewConfig(cfgPath) 29 | if err != nil { 30 | dialog.Message("Error: %v", err).Title("Config Error").Error() 31 | return 32 | } 33 | 34 | // setup logging 35 | appDataDir, err := os.UserConfigDir() 36 | if err != nil { 37 | dialog.Message("Error: %v", err).Title("Config Error").Error() 38 | return 39 | } 40 | 41 | logPath := fmt.Sprintf("%s/%s/%s", appDataDir, "securecrt-inventory", "securecrt-inventory.log") 42 | err = os.MkdirAll(filepath.Dir(logPath), 0755) 43 | if err != nil { 44 | dialog.Message("Error: %v", err).Title("Logging Setup Error").Error() 45 | return 46 | } 47 | 48 | file, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755) 49 | if err != nil { 50 | dialog.Message("Error: %v", err).Title("Logging Setup Error").Error() 51 | return 52 | } 53 | 54 | logLevel := slog.LevelError 55 | if cfg.LogLevel == "DEBUG" { 56 | logLevel = slog.LevelDebug 57 | } 58 | if cfg.LogLevel == "INFO" { 59 | logLevel = slog.LevelInfo 60 | } 61 | logger := slog.New(slog.NewTextHandler(file, &slog.HandlerOptions{Level: logLevel})) 62 | slog.SetDefault(logger) 63 | 64 | // setup securecrt config builder, and validate it's installed 65 | scrt, err := securecrt.New(cfg.RootPath) 66 | if err != nil { 67 | slog.Error("Failed to load securecrt config", slog.String("error", err.Error())) 68 | dialog.Message("Error: %v", err).Title("Config Error").Error() 69 | return 70 | } 71 | 72 | // setup the systray, and all menu items 73 | systray := gui.New(cfg) 74 | syncCallback := func(state string, message string) { 75 | if state == inventory.STATE_RUNNING { 76 | systray.SetSyncButtonStatus(false) 77 | systray.StartAnimateIcon() 78 | systray.SetStatus(true) 79 | } else { 80 | systray.SetSyncButtonStatus(true) 81 | systray.StopAnimateIcon() 82 | } 83 | 84 | if state == inventory.STATE_ERROR { 85 | systray.SetStatus(false) 86 | } 87 | 88 | systray.SetStatusMessage(message) 89 | } 90 | 91 | // setup our netbox client, and the inventory client to combine them all 92 | ctx, cancelCtx := context.WithCancel(context.Background()) 93 | nb := netbox.New(cfg.NetboxUrl, cfg.NetboxToken, ctx) 94 | invClient := inventory.New(cfg, nb, scrt, syncCallback) 95 | 96 | // handle periodic sync if enabled 97 | go invClient.SetupPeriodicSync() 98 | 99 | // handle click events 100 | go func() { 101 | for menuItem := range systray.ClickedCh { 102 | if menuItem == "sync" { 103 | go func() { 104 | slog.Info("Running manual sync") 105 | invClient.RunSync() 106 | }() 107 | } 108 | 109 | if menuItem == "open-log" { 110 | err := openFile(logPath) 111 | if err != nil { 112 | slog.Error("failed to open log") 113 | } 114 | } 115 | 116 | if menuItem == "quit" { 117 | systray.Quit() 118 | } 119 | } 120 | }() 121 | 122 | // show the systray in a blocking way 123 | systray.Run() 124 | cancelCtx() 125 | } 126 | 127 | func openFile(file string) error { 128 | var err error 129 | switch runtime.GOOS { 130 | case "linux": 131 | err = exec.Command("xdg-open", file).Start() 132 | case "windows": 133 | err = exec.Command("rundll32", "url.dll,FileProtocolHandler", file).Start() 134 | case "darwin": 135 | err = exec.Command("open", file).Start() 136 | default: 137 | err = fmt.Errorf("unsupported platform") 138 | } 139 | 140 | return err 141 | } 142 | -------------------------------------------------------------------------------- /internal/gui/systray.go: -------------------------------------------------------------------------------- 1 | package gui 2 | 3 | import ( 4 | "log/slog" 5 | "time" 6 | 7 | "fyne.io/systray" 8 | "github.com/jysk-network/netbox-securecrt-inventory/internal/config" 9 | "github.com/jysk-network/netbox-securecrt-inventory/internal/gui/assets" 10 | ) 11 | 12 | type SysTray struct { 13 | mStatus *systray.MenuItem 14 | mSyncNow *systray.MenuItem 15 | mQuit *systray.MenuItem 16 | mLogOpen *systray.MenuItem 17 | mPeriodicSync *systray.MenuItem 18 | cfg *config.Config 19 | animationTicker *time.Ticker 20 | ClickedCh chan string 21 | } 22 | 23 | func New(cfg *config.Config) *SysTray { 24 | return &SysTray{ 25 | ClickedCh: make(chan string), 26 | cfg: cfg, 27 | animationTicker: time.NewTicker(time.Second / 5), 28 | } 29 | } 30 | 31 | func (s *SysTray) onExit() { 32 | close(s.ClickedCh) 33 | } 34 | 35 | func (s *SysTray) onStartup() { 36 | systray.SetTemplateIcon(assets.Icon, assets.Icon) 37 | systray.SetTooltip("Sync devices from NetBox to SecureCRT") 38 | 39 | s.mStatus = systray.AddMenuItem("", "Sync Status") 40 | s.mStatus.Disable() 41 | systray.AddSeparator() 42 | 43 | s.mSyncNow = systray.AddMenuItem("Sync Inventory Now", "Start a manual sync now") 44 | s.mLogOpen = systray.AddMenuItem("Open Log", "Open log file") 45 | 46 | systray.AddSeparator() 47 | mSettings := systray.AddMenuItem("Settings", "View Settings") 48 | s.mPeriodicSync = mSettings.AddSubMenuItemCheckbox("Periodic Sync", "Toggle periodic sync on/off", s.cfg.EnablePeriodicSync) 49 | systray.AddSeparator() 50 | 51 | s.mQuit = systray.AddMenuItem("Quit", "Quit the whole app") 52 | 53 | s.StopAnimateIcon() 54 | go s.setupIconSpinner() 55 | 56 | s.SetStatus(true) 57 | s.SetStatusMessage("Status: Not synced yet") 58 | s.togglePeriodicSync() 59 | s.handleClicks() 60 | } 61 | 62 | func (s *SysTray) handleClicks() { 63 | for { 64 | select { 65 | case <-s.mQuit.ClickedCh: 66 | s.ClickedCh <- "quit" 67 | case <-s.mSyncNow.ClickedCh: 68 | s.ClickedCh <- "sync" 69 | case <-s.mLogOpen.ClickedCh: 70 | s.ClickedCh <- "open-log" 71 | case <-s.mPeriodicSync.ClickedCh: 72 | s.ClickedCh <- "periodic-sync" 73 | s.cfg.EnablePeriodicSync = !s.cfg.EnablePeriodicSync 74 | s.cfg.Save() 75 | s.togglePeriodicSync() 76 | } 77 | } 78 | } 79 | 80 | func (s *SysTray) togglePeriodicSync() { 81 | slog.Info("Periodic sync changed", slog.Bool("value", s.cfg.EnablePeriodicSync)) 82 | 83 | if s.cfg.EnablePeriodicSync { 84 | s.mPeriodicSync.SetTitle("Periodic Sync: Enabled") 85 | s.mPeriodicSync.Check() 86 | } else { 87 | s.mPeriodicSync.SetTitle("Periodic Sync: Disabled") 88 | s.mPeriodicSync.Uncheck() 89 | } 90 | } 91 | 92 | func (s *SysTray) setupIconSpinner() { 93 | imageCount := 6 94 | currentFrame := 0 95 | icons := [][]byte{ 96 | assets.AnimateIcon1, 97 | assets.AnimateIcon2, 98 | assets.AnimateIcon3, 99 | assets.AnimateIcon4, 100 | assets.AnimateIcon5, 101 | assets.AnimateIcon6, 102 | } 103 | 104 | for range s.animationTicker.C { 105 | if currentFrame == imageCount-1 { 106 | currentFrame = 0 107 | } 108 | 109 | systray.SetTemplateIcon(icons[currentFrame], icons[currentFrame]) 110 | currentFrame = currentFrame + 1 111 | } 112 | } 113 | 114 | func (s *SysTray) Run() { 115 | systray.Run(s.onStartup, s.onExit) 116 | } 117 | 118 | func (s *SysTray) Quit() { 119 | if s.animationTicker != nil { 120 | s.animationTicker.Stop() 121 | } 122 | 123 | systray.Quit() 124 | } 125 | 126 | func (s *SysTray) StartAnimateIcon() { 127 | if s.animationTicker != nil { 128 | s.animationTicker.Reset(time.Second / 5) 129 | } 130 | } 131 | 132 | func (s *SysTray) StopAnimateIcon() { 133 | if s.animationTicker != nil { 134 | s.animationTicker.Stop() 135 | } 136 | 137 | systray.SetTemplateIcon(assets.Icon, assets.Icon) 138 | } 139 | 140 | func (s *SysTray) SetStatus(status bool) { 141 | if status { 142 | s.mStatus.SetIcon(assets.StatusIconGreen) 143 | } else { 144 | s.mStatus.SetIcon(assets.StatusIconRed) 145 | } 146 | } 147 | 148 | func (s *SysTray) SetSyncButtonStatus(status bool) { 149 | if status { 150 | s.mSyncNow.Enable() 151 | } else { 152 | s.mSyncNow.Disable() 153 | } 154 | } 155 | 156 | func (s *SysTray) SetStatusMessage(message string) { 157 | s.mStatus.SetTitle(message) 158 | } 159 | -------------------------------------------------------------------------------- /internal/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "errors" 5 | "flag" 6 | "fmt" 7 | "net/url" 8 | "os" 9 | "os/user" 10 | "path/filepath" 11 | "strings" 12 | 13 | "gopkg.in/yaml.v3" 14 | ) 15 | 16 | type ConfigFilter struct { 17 | Condition string `yaml:"condition"` 18 | } 19 | 20 | type ConfigSessionOverride struct { 21 | Target string `yaml:"target"` 22 | Condition string `yaml:"condition"` 23 | Value string `yaml:"value"` 24 | } 25 | 26 | type ConfigNameOverwrite struct { 27 | Regex string `yaml:"regex"` 28 | Value string `yaml:"value"` 29 | } 30 | 31 | type ConfigSessionOptions struct { 32 | ConnectionProtocol string `yaml:"connection_protocol"` 33 | Credential string `yaml:"credential"` 34 | Firewall string `yaml:"firewall"` 35 | } 36 | 37 | type ConfigSession struct { 38 | Path string `yaml:"path"` 39 | DeviceName string `yaml:"device_name"` 40 | SessionOptions ConfigSessionOptions `yaml:"session_options"` 41 | Overrides []ConfigSessionOverride `yaml:"overrides"` 42 | } 43 | 44 | type Config struct { 45 | configPath string 46 | LogLevel string `yaml:"log_level"` 47 | NetboxUrl string `yaml:"netbox_url"` 48 | NetboxToken string `yaml:"netbox_token"` 49 | RootPath string `yaml:"root_path"` 50 | Filters []ConfigFilter `yaml:"filters"` 51 | Session ConfigSession `yaml:"session"` 52 | EnableConsoleServerSync bool `yaml:"console_server_sync_enable"` 53 | EnablePeriodicSync bool `yaml:"periodic_sync_enable"` 54 | PeriodicSyncInterval *int `yaml:"periodic_sync_interval"` 55 | } 56 | 57 | func NewConfig(configPath string) (*Config, error) { 58 | config := &Config{ 59 | configPath: configPath, 60 | } 61 | 62 | file, err := os.Open(configPath) 63 | if err != nil { 64 | return nil, err 65 | } 66 | defer file.Close() 67 | 68 | d := yaml.NewDecoder(file) 69 | if err := d.Decode(&config); err != nil { 70 | return nil, err 71 | } 72 | 73 | err = config.SetDefaultsAndValidate() 74 | if err != nil { 75 | return nil, err 76 | } 77 | 78 | config.Save() 79 | return config, nil 80 | } 81 | 82 | func (c *Config) SetDefaultsAndValidate() error { 83 | // setup defaults 84 | if c.LogLevel == "" { 85 | c.LogLevel = "ERROR" 86 | } 87 | 88 | if c.PeriodicSyncInterval == nil { 89 | defaultTime := 60 90 | c.PeriodicSyncInterval = &defaultTime 91 | } 92 | 93 | if c.Session.SessionOptions.ConnectionProtocol == "" { 94 | c.Session.SessionOptions.ConnectionProtocol = "SSH" 95 | } 96 | 97 | if c.Session.SessionOptions.Firewall == "" { 98 | c.Session.SessionOptions.Firewall = "None" 99 | } 100 | 101 | if c.Session.DeviceName == "" { 102 | c.Session.DeviceName = "{device_name}" 103 | } 104 | 105 | if c.Session.Path == "" { 106 | c.Session.Path = "{tenant_name}/{region_name}/{site_name}/{device_role}" 107 | } 108 | 109 | // validate overrides 110 | for _, override := range c.Session.Overrides { 111 | if override.Target == "" { 112 | return errors.New("override target can not be empty") 113 | } 114 | 115 | if override.Condition == "" { 116 | return errors.New("override condition can not be empty") 117 | } 118 | } 119 | 120 | // validate the netbox url, and allows us to strip http/https etc 121 | url, err := parseRawURL(c.NetboxUrl) 122 | if err != nil { 123 | return err 124 | } 125 | 126 | c.NetboxUrl = url.Host 127 | return nil 128 | } 129 | 130 | func (c *Config) Save() error { 131 | data, err := yaml.Marshal(c) 132 | if err != nil { 133 | return err 134 | } 135 | 136 | err = os.WriteFile(c.configPath, data, 0) 137 | if err != nil { 138 | return err 139 | } 140 | 141 | return nil 142 | } 143 | 144 | func ParseFlags() (string, error) { 145 | // String that contains the configured configuration path 146 | var configPath string 147 | 148 | // Set up a CLI flag called "-config" to allow users 149 | // to supply the configuration file 150 | flag.StringVar(&configPath, "config", "~/.securecrt-inventory.yaml", "path to config file") 151 | 152 | // handle the users home dir 153 | usr, _ := user.Current() 154 | dir := usr.HomeDir 155 | if configPath == "~" { 156 | // In case of "~", which won't be caught by the "else if" 157 | configPath = dir 158 | } else if strings.HasPrefix(configPath, "~/") { 159 | // Use strings.HasPrefix so we don't match paths like 160 | // "/something/~/something/" 161 | configPath = filepath.Join(dir, configPath[2:]) 162 | } 163 | 164 | // Actually parse the flags 165 | flag.Parse() 166 | 167 | // Validate the path first, and if empty create the config file 168 | s, err := os.Stat(configPath) 169 | if err != nil { 170 | _, err = os.Create(configPath) 171 | if err != nil { 172 | return "", err 173 | } 174 | } 175 | if s.IsDir() { 176 | return "", fmt.Errorf("'%s' is a directory, not a normal file", configPath) 177 | } 178 | 179 | // Return the configuration path 180 | return configPath, nil 181 | } 182 | 183 | func parseRawURL(rawurl string) (u *url.URL, err error) { 184 | u, err = url.ParseRequestURI(rawurl) 185 | if err != nil || u.Host == "" { 186 | u, repErr := url.ParseRequestURI("https://" + rawurl) 187 | if repErr != nil { 188 | return nil, err 189 | } 190 | return u, nil 191 | } 192 | 193 | return u, nil 194 | } 195 | -------------------------------------------------------------------------------- /pkg/securecrt/session.go: -------------------------------------------------------------------------------- 1 | package securecrt 2 | 3 | import ( 4 | "bufio" 5 | "errors" 6 | "fmt" 7 | "io/fs" 8 | "log/slog" 9 | "os" 10 | "path/filepath" 11 | "reflect" 12 | "regexp" 13 | "strings" 14 | ) 15 | 16 | type SecureCRTSession struct { 17 | DeviceName string 18 | Path string 19 | IP string `session:"Hostname" type:"S"` 20 | Port int `session:"[SSH2] Port" type:"D"` 21 | Protocol string `session:"Protocol Name" type:"S"` 22 | Description string `session:"Description" type:"Z"` 23 | CredentialName string `session:"Credential Title" type:"S"` 24 | Firewall string `session:"Firewall Name" type:"S"` 25 | fullPath string 26 | } 27 | 28 | func NewSession(fullPath string) *SecureCRTSession { 29 | return &SecureCRTSession{ 30 | Firewall: "None", 31 | CredentialName: "", 32 | fullPath: fullPath, 33 | } 34 | } 35 | 36 | func (s *SecureCRTSession) read() error { 37 | f, err := os.OpenFile(s.fullPath, os.O_RDONLY, os.ModePerm) 38 | if err != nil { 39 | return err 40 | } 41 | defer f.Close() 42 | 43 | pattern, err := regexp.Compile("^([A-Z]):\\\"(.*)\"=(.*)$") 44 | if err != nil { 45 | return err 46 | } 47 | 48 | sc := bufio.NewScanner(f) 49 | 50 | for sc.Scan() { 51 | result := pattern.FindStringSubmatch(sc.Text()) 52 | if result == nil { 53 | continue 54 | } 55 | 56 | // Z: indicates multiline content 57 | if result[1] == "Z" { 58 | multiLineContent := "" 59 | for sc.Scan() { 60 | result := pattern.FindAllString(sc.Text(), -1) 61 | if result == nil { 62 | multiLineContent += "\n" + sc.Text() 63 | continue 64 | } 65 | break 66 | } 67 | 68 | s.setInternalValue(result[2], multiLineContent) 69 | continue 70 | } 71 | 72 | // all other types are single line 73 | s.setInternalValue(result[2], result[3]) 74 | } 75 | if err := sc.Err(); err != nil { 76 | return err 77 | } 78 | 79 | // set DeviceName and Path manually as they are file names 80 | configPath, _ := getConfigPath() 81 | configPath = configPath + "/Sessions" 82 | path, fileName := filepath.Split(s.fullPath) 83 | s.DeviceName = strings.ReplaceAll(fileName, ".ini", "") 84 | s.Path = strings.ReplaceAll(path, configPath, "") 85 | 86 | return nil 87 | } 88 | 89 | func (s *SecureCRTSession) setInternalValue(key string, value string) { 90 | val := reflect.ValueOf(s).Elem() 91 | for i := 0; i < val.NumField(); i++ { 92 | tag := val.Type().Field(i).Tag.Get("session") 93 | 94 | if tag == key && val.CanSet() { 95 | if val.Field(i).Kind() == reflect.String { 96 | val.Field(i).SetString(value) 97 | } 98 | 99 | if val.Field(i).Kind() == reflect.Pointer { 100 | val.Field(i).Set(reflect.ValueOf(&value)) 101 | } 102 | } 103 | } 104 | } 105 | 106 | func (s *SecureCRTSession) write(defaultConfig string, mode fs.FileMode) error { 107 | var data strings.Builder 108 | data.WriteString(defaultConfig) 109 | 110 | // based on the tags we can generate the correct securecrt config format 111 | val := reflect.ValueOf(s).Elem() 112 | for i := 0; i < val.NumField(); i++ { 113 | itemType := val.Type().Field(i).Tag.Get("type") 114 | key := val.Type().Field(i).Tag.Get("session") 115 | if key == "" { 116 | continue 117 | } 118 | 119 | value := val.Field(i).String() 120 | if val.Field(i).Kind() == reflect.Pointer { 121 | value = val.Field(i).Elem().String() 122 | } 123 | 124 | if itemType == "Z" { 125 | items := strings.Split(value, "\n") 126 | itemsLengthPadded := fmt.Sprintf("%08d", len(items)) 127 | data.WriteString(fmt.Sprintf("%s:\"%s\"=%s\n", itemType, key, itemsLengthPadded)) 128 | for _, v := range items { 129 | if v != "" { 130 | data.WriteString(" " + v + "\n") 131 | } 132 | } 133 | } else if itemType == "D" { 134 | data.WriteString(fmt.Sprintf("%s:\"%s\"=%08X\n", itemType, key, val.Field(i).Int())) 135 | } else { 136 | data.WriteString(fmt.Sprintf("%s:\"%s\"=%s\n", itemType, key, value)) 137 | } 138 | } 139 | 140 | err := os.MkdirAll(filepath.Dir(s.fullPath), mode) 141 | if err != nil { 142 | slog.Error("failed to create securecrt session directory", slog.String("error", err.Error())) 143 | return errors.Join(ErrFailedToCreateSession, err) 144 | } 145 | 146 | err = os.WriteFile(s.fullPath, []byte(data.String()), mode) 147 | if err != nil { 148 | slog.Error("failed to write securecrt session", slog.String("error", err.Error())) 149 | return errors.Join(ErrFailedToCreateSession, err) 150 | } 151 | 152 | return nil 153 | } 154 | 155 | func (s *SecureCRTSession) delete() error { 156 | err := os.Remove(s.fullPath) 157 | if err != nil { 158 | return err 159 | } 160 | 161 | // find the highest-level folder that is “empty” (ignoring this child folder and some ignored files) 162 | folder, err := getFolderToDelete(filepath.Dir(s.fullPath)) 163 | if err != nil { 164 | return err 165 | } 166 | 167 | if folder != "" { 168 | return os.RemoveAll(folder) 169 | } 170 | 171 | return nil 172 | } 173 | 174 | // getFolderToDelete climbs the directory tree starting at 'folder'. It 175 | // returns the highest ancestor directory that is “empty” except for a single 176 | // subdirectory (the one we just deleted) and optionally a few ignored files. 177 | func getFolderToDelete(folder string) (string, error) { 178 | // Start by ignoring the folder’s own basename. 179 | base := filepath.Base(folder) 180 | empty, err := isDirEmpty(folder, base) 181 | if err != nil { 182 | return "", err 183 | } 184 | 185 | if !empty { 186 | return "", nil 187 | } 188 | 189 | // Climb up the tree. In each parent directory, ignore the subdirectory (child) 190 | // that we came from. 191 | for { 192 | parent := filepath.Clean(filepath.Join(folder, "..")) 193 | empty, err := isDirEmpty(parent, filepath.Base(folder)) 194 | if err != nil { 195 | return "", err 196 | } 197 | if !empty { 198 | break 199 | } 200 | folder = parent 201 | } 202 | return folder, nil 203 | } 204 | 205 | // isDirEmpty checks if the directory 'dir' is empty except for an entry 206 | // named 'ignore' (and a couple of extra files that we want to ignore). 207 | func isDirEmpty(dir, ignore string) (bool, error) { 208 | entries, err := os.ReadDir(dir) 209 | if err != nil { 210 | return false, err 211 | } 212 | 213 | for _, entry := range entries { 214 | // Skip the ignored directory entry. 215 | if entry.IsDir() && entry.Name() == ignore { 216 | continue 217 | } 218 | 219 | // Skip known ignorable files. 220 | if entry.Name() == ".DS_Store" || entry.Name() == "__FolderData__.ini" { 221 | continue 222 | } 223 | 224 | // Any other file or directory means 'dir' is not empty. 225 | return false, nil 226 | } 227 | 228 | return true, nil 229 | } 230 | -------------------------------------------------------------------------------- /internal/netbox/netbox.go: -------------------------------------------------------------------------------- 1 | package netbox 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "log/slog" 9 | "net/http" 10 | "strings" 11 | ) 12 | 13 | type NetBoxRespone[T any] struct { 14 | Count int `json:"count"` 15 | Next string `json:"next"` 16 | Previous string `json:"previous"` 17 | Results []T `json:"results"` 18 | } 19 | 20 | type NetBox struct { 21 | url string 22 | token string 23 | limit int32 24 | ctx context.Context 25 | httpClient *http.Client 26 | } 27 | 28 | func New(url string, token string, ctx context.Context) *NetBox { 29 | schema := "https://" 30 | if strings.Contains(url, "http://") || strings.Contains(url, "https://") { 31 | schema = "" 32 | } 33 | 34 | url = fmt.Sprintf("%s%s", schema, url) 35 | var limit int32 = 1000 36 | 37 | return &NetBox{ 38 | url: url, 39 | token: token, 40 | limit: limit, 41 | ctx: ctx, 42 | httpClient: &http.Client{}, 43 | } 44 | } 45 | 46 | func (nb *NetBox) PrepareRequest(method string, url string) (*http.Request, error) { 47 | url = fmt.Sprintf("%s/api%s", nb.url, url) 48 | req, err := http.NewRequest(method, url, nil) 49 | if err != nil { 50 | return nil, err 51 | } 52 | 53 | req.Header.Add("Authorization", fmt.Sprintf("Token %s", nb.token)) 54 | req.Header.Add("Content-Type", "application/json") 55 | req.Header.Add("Accept", "application/json") 56 | return req, nil 57 | } 58 | 59 | func (nb *NetBox) TestConnection() error { 60 | req, err := nb.PrepareRequest("GET", "/status") 61 | if err != nil { 62 | return err 63 | } 64 | 65 | _, err = nb.httpClient.Do(req) 66 | if err != nil { 67 | slog.Error("Unable to connect to netbox", slog.String("url", nb.url), slog.String("error", err.Error())) 68 | return fmt.Errorf("unable to connect: %s", nb.url) 69 | } 70 | 71 | return nil 72 | } 73 | 74 | func (nb *NetBox) GetSites() ([]Site, error) { 75 | var results = make([]Site, 0) 76 | hasMorePages := true 77 | for hasMorePages { 78 | currentCount := len(results) 79 | req, err := nb.PrepareRequest("GET", fmt.Sprintf("/dcim/sites/?limit=%d&offset=%d", nb.limit, currentCount)) 80 | if err != nil { 81 | return results, err 82 | } 83 | 84 | response, err := nb.httpClient.Do(req) 85 | if err != nil { 86 | slog.Error("Failed to get sites from netbox", slog.String("error", err.Error())) 87 | return nil, ErrFailedToQuerySites 88 | } 89 | defer response.Body.Close() 90 | body, err := io.ReadAll(response.Body) 91 | if err != nil { 92 | slog.Error("Failed to read body from sites request", slog.String("error", err.Error())) 93 | return nil, ErrFailedToQuerySites 94 | } 95 | 96 | var data NetBoxRespone[Site] 97 | err = json.Unmarshal(body, &data) 98 | if err != nil { 99 | slog.Error("Failed to parse sites from netbox", slog.String("error", err.Error())) 100 | return nil, ErrFailedToQuerySites 101 | } 102 | 103 | results = append(results, data.Results...) 104 | if len(data.Results) < int(nb.limit) { 105 | hasMorePages = false 106 | } 107 | } 108 | 109 | slog.Info("Retrieved sites", slog.Int("count", len(results))) 110 | return results, nil 111 | } 112 | 113 | func (nb *NetBox) GetDevices() ([]DeviceWithConfigContext, error) { 114 | var results = make([]DeviceWithConfigContext, 0) 115 | hasMorePages := true 116 | for hasMorePages { 117 | currentCount := len(results) 118 | req, err := nb.PrepareRequest("GET", fmt.Sprintf("/dcim/devices/?has_primary_ip=true&limit=%d&offset=%d", nb.limit, currentCount)) 119 | if err != nil { 120 | return results, err 121 | } 122 | 123 | response, err := nb.httpClient.Do(req) 124 | if err != nil { 125 | slog.Error("Failed to get sites from netbox", slog.String("error", err.Error())) 126 | return nil, ErrFailedToQuerySites 127 | } 128 | defer response.Body.Close() 129 | body, err := io.ReadAll(response.Body) 130 | if err != nil { 131 | slog.Error("Failed to read body from sites request", slog.String("error", err.Error())) 132 | return nil, ErrFailedToQuerySites 133 | } 134 | 135 | var data NetBoxRespone[DeviceWithConfigContext] 136 | err = json.Unmarshal(body, &data) 137 | if err != nil { 138 | slog.Error("Failed to parse sites from netbox", slog.String("error", err.Error())) 139 | return nil, ErrFailedToQuerySites 140 | } 141 | 142 | results = append(results, data.Results...) 143 | if len(data.Results) < int(nb.limit) { 144 | hasMorePages = false 145 | } 146 | } 147 | 148 | slog.Info("Retrieved devices", slog.Int("count", len(results))) 149 | return results, nil 150 | } 151 | 152 | func (nb *NetBox) GetVirtualMachines() ([]VirtualMachineWithConfigContext, error) { 153 | var results = make([]VirtualMachineWithConfigContext, 0) 154 | hasMorePages := true 155 | for hasMorePages { 156 | currentCount := len(results) 157 | req, err := nb.PrepareRequest("GET", fmt.Sprintf("/virtualization/virtual-machines/?has_primary_ip=true&limit=%d&offset=%d", nb.limit, currentCount)) 158 | if err != nil { 159 | return results, err 160 | } 161 | 162 | response, err := nb.httpClient.Do(req) 163 | if err != nil { 164 | slog.Error("Failed to get sites from netbox", slog.String("error", err.Error())) 165 | return nil, ErrFailedToQuerySites 166 | } 167 | defer response.Body.Close() 168 | body, err := io.ReadAll(response.Body) 169 | if err != nil { 170 | slog.Error("Failed to read body from sites request", slog.String("error", err.Error())) 171 | return nil, ErrFailedToQuerySites 172 | } 173 | 174 | var data NetBoxRespone[VirtualMachineWithConfigContext] 175 | err = json.Unmarshal(body, &data) 176 | if err != nil { 177 | slog.Error("Failed to parse sites from netbox", slog.String("error", err.Error())) 178 | return nil, ErrFailedToQuerySites 179 | } 180 | 181 | results = append(results, data.Results...) 182 | if len(data.Results) < int(nb.limit) { 183 | hasMorePages = false 184 | } 185 | } 186 | 187 | slog.Info("Retrieved virtual machines", slog.Int("count", len(results))) 188 | return results, nil 189 | } 190 | 191 | func (nb *NetBox) GetConsoleServerPorts() ([]ConsoleServerPort, error) { 192 | var results = make([]ConsoleServerPort, 0) 193 | hasMorePages := true 194 | for hasMorePages { 195 | currentCount := len(results) 196 | req, err := nb.PrepareRequest("GET", fmt.Sprintf("/dcim/console-server-ports?limit=%d&offset=%d", nb.limit, currentCount)) 197 | if err != nil { 198 | return results, err 199 | } 200 | 201 | response, err := nb.httpClient.Do(req) 202 | if err != nil { 203 | slog.Error("Failed to get sites from netbox", slog.String("error", err.Error())) 204 | return nil, ErrFailedToQuerySites 205 | } 206 | defer response.Body.Close() 207 | body, err := io.ReadAll(response.Body) 208 | if err != nil { 209 | slog.Error("Failed to read body from sites request", slog.String("error", err.Error())) 210 | return nil, ErrFailedToQuerySites 211 | } 212 | 213 | var data NetBoxRespone[ConsoleServerPort] 214 | err = json.Unmarshal(body, &data) 215 | if err != nil { 216 | slog.Error("Failed to parse sites from netbox", slog.String("error", err.Error())) 217 | return nil, ErrFailedToQuerySites 218 | } 219 | 220 | results = append(results, data.Results...) 221 | if len(data.Results) < int(nb.limit) { 222 | hasMorePages = false 223 | } 224 | } 225 | 226 | slog.Info("Retrieved console server ports", slog.Int("count", len(results))) 227 | return results, nil 228 | } 229 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # NetBox SecureCRT Inventory 2 | 3 | Have you always wanted the ability to synchronize your NetBox devices with your SecureCRT client? Then this is for you! 4 | 5 | This tool will automatically run in the background and perform periodic synchronization or run on-demand depending on your needs. It's highly flexible in terms of how the device structure is organized. 6 | 7 | ## Installation 8 | 9 | 1. Download the latest release zip file to your computer and unzip it. 10 | 2. Create a config file named `.securecrt-inventory.yaml` in your home directory (see below for a full config example): 11 | - Change `netbox_url`, and `netbox_token` as needed. 12 | - Update `overrides` as needed. 13 | - NB: It's also possible to create the config file by running the program as it will be created with defaults if missing. 14 | 3. Run the program. It should now start running as a systray program. 15 | 4. Optional: Set it to start automatically on Windows/macOS startup. 16 | 17 | *Note:* On macOS, you might need to run `xattr -cr securecrt-inventory` to be able to execute it, as the binary is not signed. Alternatively, consider building the code yourself as a workaround. 18 | 19 | ## Templates and Expressions 20 | 21 | The config supports two special types: templates and expressions. In this section, we'll cover the differences and how to use them. 22 | 23 | ### Templates 24 | 25 | Templates provide a simple way to describe what value should be placed in a field. A good example is how it's used for the default path. 26 | 27 | A template is a string with one or more `{}` placeholders inside. For example: `NetBox/{tenant_name}/{site_name}`. 28 | If the tenant is "Example" and the site is "Test", the template would evaluate to `"NetBox/Example/Test`. 29 | 30 | Templates have access to the following variables: 31 | ``` 32 | session_type: Either device or virtual_machine 33 | credential: The default session credential name 34 | path_template: The default path template 35 | device_name_template: The default device name template 36 | firewall_template: The default firewall template 37 | connection_protocol_template: The default connection protocol template 38 | device_name: Device name from NetBox 39 | device_role: Device role name from NetBox 40 | device_type: Device type name from NetBox 41 | device_ip: Device IP without subnet/prefix 42 | region_name: Region name from NetBox 43 | tenant_name: Tenant name from NetBox 44 | site_name: Site name from NetBox 45 | site_group: Site Group slug from NetBox 46 | site_address: Site address from NetBox 47 | virtual_chassis_name: Virtual Chassis name from NetBox 48 | ``` 49 | 50 | ### Expressions 51 | 52 | Expressions are powered by https://expr-lang.org/ and should always start with `{{` and end with `}}`. They are used extensively to define overrides and manipulate the session output. 53 | 54 | Here are a few sample expressions to get you started: 55 | ``` 56 | # Returns true if the NetBox site group is adm 57 | {{ site_group == 'adm' }} 58 | 59 | # Returns the value of the tag "connection_protocol" if found, otherwise "SSH" 60 | {{ FindTag(device.Tags, 'connection_protocol') ?? 'SSH' }} 61 | 62 | # Returns true if the device name ends with example.com 63 | {{ device_name endsWith '.example.com' }} 64 | ``` 65 | 66 | Expressions have access to the same variables as templates, but they can also access the following: 67 | ``` 68 | device: The device object (go struct, most fields are CamelCase, ex: device.Tags) 69 | site: The site object (go struct, most fields are CamelCase, ex: site.Slug) 70 | ``` 71 | 72 | Expressions have access to all expr functions and the following: 73 | ``` 74 | FindTag(, ) 75 | ``` 76 | 77 | ### Debug 78 | It's possible to debug expressions and templates, by enabling debug in the config file and examining the log file. The log file can be opened by clicking the icon and selecting "Open Log", when debug is enabled all variables will be output together with templates, and result. 79 | 80 | **IMPORTANT**: This should not be enabled always as the log file is not rotated, and debug WILL output a lot of data. 81 | 82 | ## Config Example 83 | 84 | ``` 85 | # ERROR/DEBUG/INFO, default is ERROR. DEBUG logs a lot and should not be used in day-to-day operations as the log is not cleared. 86 | log_level: ERROR 87 | netbox_url: 88 | netbox_token: 89 | root_path: NetBox 90 | 91 | # Enable / Disable sync of console server ports 92 | # WARNING: By default the sessions will have the same name as the device, we suggest to override them (see below for an example) 93 | # This is by design, as to not force a preference on users. 94 | console_server_sync_enable: false 95 | 96 | # Enable/Disable periodic sync (note: SecureCRT needs to be restarted for changes to take effect) 97 | periodic_sync_enable: true 98 | periodic_sync_interval: 120 99 | 100 | # Filter what is synced, default is sync everything 101 | # All filters are evaluated for each item, and they all need to return true, 102 | # if any of the filters return false it will not be synced. 103 | filters: 104 | #- condition: "{{ site_group != 'external' }}" # uncomment to skip sync of one site group 105 | #- condition: "{{ site_group in ['external', 'admin'] }}" # uncomment to only sync the defined site groups 106 | 107 | # Session settings 108 | session: 109 | # path: is the default session path template 110 | path: "{tenant_name}/{region_name}/{site_name}/{device_role}" 111 | # device_name: allows you to override the device name at a global level; supports templates and expressions 112 | device_name: "{device_name}" 113 | 114 | # Global Session Options 115 | session_options: 116 | # Allows you to override the connection protocol; supports templates and expressions 117 | connection_protocol: "{{ FindTag(device.Tags, 'connection_protocol') ?? 'SSH' }}" 118 | # Set default credentials; they should be defined in SecureCRT beforehand under "Preferences -> General -> Credentials" 119 | credential: 120 | # Set a firewall; supports templates and expressions 121 | firewall: "{{ FindTag(device.Tags, 'connection_firewall') ?? ''None'' }}" 122 | 123 | # Overrides based on conditions 124 | # target can be one of: path, device_name, description, connection_protocol, credential, firewall 125 | # condition should always be an expression that evaluates to true or false 126 | # value is what to replace the target with; it can be a template or expression that returns a value 127 | overrides: 128 | - target: path 129 | condition: "{{ site_group == 'adm' }}" 130 | value: _Stores/{region_name}/{site_name} 131 | 132 | - target: path 133 | condition: "{{ device_type == 'virtual_machine' }}" 134 | value: _Servers/{region_name} 135 | 136 | # device_name override use cases include removing domain names, extra values like .1, and so on 137 | # note that this example could also be done with device_name and just using replace 138 | - target: device_name 139 | condition: "{{ device_name endsWith '.1' }}" 140 | value: "{{ replace(device_name, '.1', '') }}" 141 | 142 | # if console_server_sync_enable is enabled, we can use is_console_session to check if its a console server, and console_server_port is set to the port name 143 | # in the example below we transform "Port 1" to 3001 and change the device name to append (console) 144 | - target: device_port 145 | condition: '{{ is_console_session == true }}' 146 | value: '{{ 3000 + int(replace(console_server_port, "Port ", "")) }}' 147 | - target: device_name 148 | condition: '{{ is_console_session == true }}' 149 | value: '{{ device_name }} (console)' 150 | ``` 151 | 152 | ## Development 153 | Pull requests and issues are welcome. 154 | 155 | A VSCode launch file has been included for debugging the code directly. -------------------------------------------------------------------------------- /internal/netbox/models.go: -------------------------------------------------------------------------------- 1 | package netbox 2 | 3 | type Region struct { 4 | Id int32 `json:"id"` 5 | Name string `json:"name"` 6 | Slug string `json:"slug"` 7 | } 8 | 9 | type SiteGroup struct { 10 | Id int32 `json:"id"` 11 | Name string `json:"name"` 12 | Slug string `json:"slug"` 13 | } 14 | 15 | type Site struct { 16 | Id int32 `json:"id"` 17 | Url string `json:"url"` 18 | Display string `json:"display"` 19 | // Full name of the site 20 | Name string `json:"name"` 21 | Slug string `json:"slug"` 22 | PhysicalAddress string `json:"physical_address"` 23 | Description *string `json:"description,omitempty"` 24 | Region *Region `json:"region,omitempty"` 25 | Group *SiteGroup `json:"group,omitempty"` 26 | } 27 | 28 | type Manufacturer struct { 29 | Id int32 `json:"id"` 30 | Url string `json:"url"` 31 | Display string `json:"display"` 32 | Name string `json:"name"` 33 | Slug string `json:"slug"` 34 | Description *string `json:"description,omitempty"` 35 | } 36 | 37 | type DeviceType struct { 38 | Id int32 `json:"id"` 39 | Url string `json:"url"` 40 | Display string `json:"display"` 41 | Manufacturer Manufacturer `json:"manufacturer"` 42 | Model string `json:"model"` 43 | Slug string `json:"slug"` 44 | Description *string `json:"description,omitempty"` 45 | } 46 | 47 | type DeviceRole struct { 48 | Id int32 `json:"id"` 49 | Url string `json:"url"` 50 | Display string `json:"display"` 51 | Name string `json:"name"` 52 | Slug string `json:"slug"` 53 | Description *string `json:"description,omitempty"` 54 | VirtualmachineCount int64 `json:"virtualmachine_count"` 55 | } 56 | 57 | type VirtualChassis struct { 58 | Id int32 `json:"id"` 59 | Url string `json:"url"` 60 | Display string `json:"display"` 61 | Name string `json:"name"` 62 | Description *string `json:"description,omitempty"` 63 | } 64 | 65 | type Tenant struct { 66 | Id int32 `json:"id"` 67 | Url string `json:"url"` 68 | Display string `json:"display"` 69 | Name string `json:"name"` 70 | Slug string `json:"slug"` 71 | Description *string `json:"description,omitempty"` 72 | } 73 | 74 | type Platform struct { 75 | Id int32 `json:"id"` 76 | Url string `json:"url"` 77 | Display string `json:"display"` 78 | Name string `json:"name"` 79 | Slug string `json:"slug"` 80 | Description *string `json:"description,omitempty"` 81 | VirtualmachineCount int64 `json:"virtualmachine_count"` 82 | } 83 | 84 | type Location struct { 85 | Id int32 `json:"id"` 86 | Url string `json:"url"` 87 | Display string `json:"display"` 88 | Name string `json:"name"` 89 | Slug string `json:"slug"` 90 | Description *string `json:"description,omitempty"` 91 | Depth int32 `json:"_depth"` 92 | } 93 | 94 | type Rack struct { 95 | Id int32 `json:"id"` 96 | Url string `json:"url"` 97 | Display string `json:"display"` 98 | Name string `json:"name"` 99 | Description *string `json:"description,omitempty"` 100 | } 101 | 102 | type DeviceStatus struct { 103 | Value *string `json:"value,omitempty"` 104 | Label *string `json:"label,omitempty"` 105 | } 106 | 107 | type IPAddress struct { 108 | Id int32 `json:"id"` 109 | Url string `json:"url"` 110 | Display string `json:"display"` 111 | Address string `json:"address"` 112 | Description *string `json:"description,omitempty"` 113 | } 114 | 115 | type NestedTag struct { 116 | Id int32 `json:"id"` 117 | Url string `json:"url"` 118 | Display string `json:"display"` 119 | Name string `json:"name"` 120 | Slug string `json:"slug"` 121 | Color *string `json:"color,omitempty"` 122 | } 123 | 124 | type DeviceWithConfigContext struct { 125 | Id int32 `json:"id"` 126 | Url string `json:"url"` 127 | Display string `json:"display"` 128 | Name string `json:"name,omitempty"` 129 | DeviceType DeviceType `json:"device_type"` 130 | Role DeviceRole `json:"role"` 131 | Tenant *Tenant `json:"tenant,omitempty"` 132 | Platform *Platform `json:"platform,omitempty"` 133 | Serial *string `json:"serial,omitempty"` 134 | AssetTag *string `json:"asset_tag,omitempty"` 135 | Site Site `json:"site"` 136 | Location *Location `json:"location,omitempty"` 137 | Rack *Rack `json:"rack,omitempty"` 138 | Position *float64 `json:"position,omitempty"` 139 | // GPS coordinate in decimal format (xx.yyyyyy) 140 | Latitude *float64 `json:"latitude,omitempty"` 141 | // GPS coordinate in decimal format (xx.yyyyyy) 142 | Longitude *float64 `json:"longitude,omitempty"` 143 | Status *DeviceStatus `json:"status,omitempty"` 144 | PrimaryIp *IPAddress `json:"primary_ip"` 145 | PrimaryIp4 *IPAddress `json:"primary_ip4,omitempty"` 146 | PrimaryIp6 *IPAddress `json:"primary_ip6,omitempty"` 147 | OobIp *IPAddress `json:"oob_ip,omitempty"` 148 | Cluster *IPAddress `json:"cluster,omitempty"` 149 | VirtualChassis *VirtualChassis `json:"virtual_chassis,omitempty"` 150 | VcPosition *int32 `json:"vc_position,omitempty"` 151 | // Virtual chassis master election priority 152 | VcPriority *int32 `json:"vc_priority,omitempty"` 153 | Description *string `json:"description,omitempty"` 154 | Comments *string `json:"comments,omitempty"` 155 | ConfigContext interface{} `json:"config_context"` 156 | // Local config context data takes precedence over source contexts in the final rendered config context 157 | LocalContextData interface{} `json:"local_context_data,omitempty"` 158 | Tags []NestedTag `json:"tags,omitempty"` 159 | CustomFields map[string]interface{} `json:"custom_fields,omitempty"` 160 | AdditionalProperties map[string]interface{} 161 | } 162 | 163 | type Cluster struct { 164 | Id int32 `json:"id"` 165 | Url string `json:"url"` 166 | Display string `json:"display"` 167 | Name string `json:"name"` 168 | Description *string `json:"description,omitempty"` 169 | } 170 | 171 | type VirtualMachineWithConfigContext struct { 172 | Id int32 `json:"id"` 173 | Url string `json:"url"` 174 | Display string `json:"display"` 175 | Name string `json:"name"` 176 | Status *DeviceStatus `json:"status,omitempty"` 177 | Site *Site `json:"site,omitempty"` 178 | Cluster *Cluster `json:"cluster,omitempty"` 179 | Role *DeviceRole `json:"role,omitempty"` 180 | Tenant *Tenant `json:"tenant,omitempty"` 181 | Platform *Platform `json:"platform,omitempty"` 182 | PrimaryIp *IPAddress `json:"primary_ip"` 183 | PrimaryIp4 *IPAddress `json:"primary_ip4,omitempty"` 184 | PrimaryIp6 *IPAddress `json:"primary_ip6,omitempty"` 185 | Vcpus *float64 `json:"vcpus,omitempty"` 186 | Memory *int32 `json:"memory,omitempty"` 187 | Disk *int32 `json:"disk,omitempty"` 188 | Description *string `json:"description,omitempty"` 189 | Comments *string `json:"comments,omitempty"` 190 | // Local config context data takes precedence over source contexts in the final rendered config context 191 | LocalContextData interface{} `json:"local_context_data,omitempty"` 192 | Tags []NestedTag `json:"tags,omitempty"` 193 | CustomFields map[string]interface{} `json:"custom_fields,omitempty"` 194 | ConfigContext interface{} `json:"config_context"` 195 | AdditionalProperties map[string]interface{} 196 | } 197 | 198 | type NestedDevice struct { 199 | Id int32 `json:"id"` 200 | Url string `json:"url"` 201 | Display string `json:"display"` 202 | Name string `json:"name"` 203 | Description string `json:"description"` 204 | } 205 | 206 | type ConnectedEndpoint struct { 207 | Id int32 `json:"id"` 208 | Display string `json:"display"` 209 | Device NestedDevice `json:"device"` 210 | } 211 | 212 | type ConsoleServerPort struct { 213 | Id int32 `json:"id"` 214 | Url string `json:"url"` 215 | Display string `json:"display"` 216 | Name string `json:"name"` 217 | Tags []NestedTag `json:"tags,omitempty"` 218 | CustomFields map[string]interface{} `json:"custom_fields,omitempty"` 219 | Device NestedDevice `json:"device"` 220 | ConnectedEndpoints *[]ConnectedEndpoint `json:"connected_endpoints,omitempty"` 221 | } 222 | -------------------------------------------------------------------------------- /internal/inventory/inventory.go: -------------------------------------------------------------------------------- 1 | package inventory 2 | 3 | import ( 4 | "fmt" 5 | "log/slog" 6 | "path/filepath" 7 | "regexp" 8 | "strings" 9 | "time" 10 | 11 | "github.com/jysk-network/netbox-securecrt-inventory/internal/config" 12 | "github.com/jysk-network/netbox-securecrt-inventory/internal/netbox" 13 | "github.com/jysk-network/netbox-securecrt-inventory/pkg/evaluator" 14 | "github.com/jysk-network/netbox-securecrt-inventory/pkg/securecrt" 15 | ) 16 | 17 | const ( 18 | STATE_RUNNING = "running" 19 | STATE_DONE = "done" 20 | STATE_ERROR = "error" 21 | ) 22 | 23 | type InventorySync struct { 24 | cfg *config.Config 25 | nb *netbox.NetBox 26 | scrt *securecrt.SecureCRT 27 | stateLogger func(state string, message string) 28 | periodicTicker *time.Ticker 29 | stripRe *regexp.Regexp 30 | } 31 | 32 | func New(cfg *config.Config, nb *netbox.NetBox, scrt *securecrt.SecureCRT, stateLogger func(state string, message string)) *InventorySync { 33 | inv := InventorySync{ 34 | cfg: cfg, 35 | nb: nb, 36 | scrt: scrt, 37 | stateLogger: stateLogger, 38 | periodicTicker: time.NewTicker(time.Minute * time.Duration(*cfg.PeriodicSyncInterval)), 39 | stripRe: regexp.MustCompile(`[\\/\?]`), 40 | } 41 | 42 | return &inv 43 | } 44 | 45 | func (i *InventorySync) getSite(sites []netbox.Site, siteID int32) (*netbox.Site, error) { 46 | for x := 0; x < len(sites); x++ { 47 | if sites[x].Id == siteID { 48 | return &sites[x], nil 49 | } 50 | } 51 | 52 | return nil, ErrorFailedToFindSite 53 | } 54 | 55 | func (i *InventorySync) getRegionName(site *netbox.Site) string { 56 | if site.Region != nil { 57 | return site.Region.Name 58 | } 59 | return "No Region" 60 | } 61 | 62 | func (i *InventorySync) getTenant(device interface{}) string { 63 | nd, ok := device.(netbox.DeviceWithConfigContext) 64 | if ok && nd.Tenant != nil { 65 | return nd.Tenant.Name 66 | } 67 | 68 | vm, ok := device.(netbox.VirtualMachineWithConfigContext) 69 | if ok && vm.Tenant != nil { 70 | return vm.Tenant.Name 71 | } 72 | 73 | return "No Tenant" 74 | } 75 | 76 | func (i *InventorySync) findDevice(devices []netbox.DeviceWithConfigContext, id int32) *netbox.DeviceWithConfigContext { 77 | for _, device := range devices { 78 | if device.Id == id { 79 | return &device 80 | } 81 | } 82 | 83 | return nil 84 | } 85 | 86 | func (i *InventorySync) getPrimaryIP(primaryIP *netbox.IPAddress) *string { 87 | if primaryIP != nil { 88 | address := primaryIP.Address 89 | address = strings.Split(address, "/")[0] 90 | return &address 91 | } 92 | 93 | return nil 94 | } 95 | 96 | func (i *InventorySync) writeSession(session *securecrt.SecureCRTSession) error { 97 | err := i.scrt.WriteSession(session) 98 | if err != nil { 99 | return err 100 | } 101 | 102 | return nil 103 | } 104 | 105 | func (i *InventorySync) checkFilters(env *evaluator.Environment) bool { 106 | for _, filter := range i.cfg.Filters { 107 | result, err := evaluator.EvaluateCondition(filter.Condition, env) 108 | if err == nil && result { 109 | continue 110 | } 111 | 112 | slog.Debug("filtering device", slog.String("device_name", env.DeviceName), slog.String("filter", filter.Condition)) 113 | return false 114 | } 115 | 116 | return true 117 | } 118 | 119 | func (i *InventorySync) getCommonEnvironment(sync_type string) *evaluator.Environment { 120 | return &evaluator.Environment{ 121 | SessionType: sync_type, 122 | Credential: i.cfg.Session.SessionOptions.Credential, 123 | PathTemplate: i.cfg.Session.Path, 124 | DeviceNameTemplate: i.cfg.Session.DeviceName, 125 | FirewallTemplate: i.cfg.Session.SessionOptions.Firewall, 126 | ConnectionProtocolTemplate: i.cfg.Session.SessionOptions.ConnectionProtocol, 127 | } 128 | } 129 | 130 | func (i *InventorySync) getConsoleSessions(devices []netbox.DeviceWithConfigContext, consolePorts []netbox.ConsoleServerPort, sites []netbox.Site) ([]*securecrt.SecureCRTSession, error) { 131 | var sessions []*securecrt.SecureCRTSession 132 | for _, port := range consolePorts { 133 | if port.ConnectedEndpoints == nil || len(*port.ConnectedEndpoints) == 0 { 134 | continue 135 | } 136 | 137 | oobDevice := i.findDevice(devices, port.Device.Id) 138 | if oobDevice == nil { 139 | return nil, fmt.Errorf("failed to find device for %s", port.Device.Name) 140 | } 141 | 142 | endDevice := i.findDevice(devices, (*port.ConnectedEndpoints)[0].Device.Id) 143 | if endDevice == nil { 144 | slog.Warn("failed to find console device", slog.String("device_name", (*port.ConnectedEndpoints)[0].Device.Name)) 145 | continue 146 | } 147 | 148 | site, err := i.getSite(sites, endDevice.Site.Id) 149 | if err != nil { 150 | return nil, err 151 | } 152 | 153 | ipAddress := i.getPrimaryIP(oobDevice.PrimaryIp) 154 | if ipAddress == nil { 155 | return nil, fmt.Errorf("primary ip is not set on %s", oobDevice.Name) 156 | } 157 | 158 | tenant := i.getTenant(*endDevice) 159 | regionName := i.getRegionName(site) 160 | siteAddress := strings.ReplaceAll(site.PhysicalAddress, "\r\n", ", ") 161 | deviceType := endDevice.DeviceType.Display 162 | siteGroup := "" 163 | if site.Group != nil { 164 | siteGroup = site.Group.Slug 165 | } 166 | 167 | virtualChassisName := "" 168 | if endDevice.VirtualChassis != nil { 169 | virtualChassisName = endDevice.VirtualChassis.Name 170 | } 171 | 172 | env := i.getCommonEnvironment("device") 173 | env.Device = endDevice 174 | env.DevicePort = 22 175 | env.DeviceName = endDevice.Name 176 | env.DeviceRole = endDevice.Role.Name 177 | env.DeviceType = deviceType 178 | env.DeviceIP = *ipAddress 179 | env.RegionName = strings.ReplaceAll(regionName, "/", "") 180 | env.TenantName = strings.ReplaceAll(tenant, "/", "") 181 | env.Site = site 182 | env.SiteName = site.Display 183 | env.SiteGroup = siteGroup 184 | env.SiteAddress = siteAddress 185 | env.VirtualChassisName = virtualChassisName 186 | env.IsConsoleSession = true 187 | env.ConsoleServerPort = port.Name 188 | 189 | err = applyOverrides(i.cfg.Session.Overrides, env) 190 | if err != nil { 191 | return nil, err 192 | } 193 | 194 | // Check if the device should be filtered 195 | shouldSaveSession := i.checkFilters(env) 196 | if shouldSaveSession { 197 | path := filepath.Clean(fmt.Sprintf("%s/%s/%s.ini", i.scrt.GetSessionPath(), env.Path, env.DeviceName)) 198 | session := getSessionWithOverrides(path, env) 199 | sessions = append(sessions, session) 200 | err = i.writeSession(session) 201 | if err != nil { 202 | return nil, err 203 | } 204 | } 205 | } 206 | 207 | return sessions, nil 208 | } 209 | 210 | func (i *InventorySync) getDeviceSessions(devices []netbox.DeviceWithConfigContext, sites []netbox.Site) ([]*securecrt.SecureCRTSession, error) { 211 | var sessions []*securecrt.SecureCRTSession 212 | for _, device := range devices { 213 | site, err := i.getSite(sites, device.Site.Id) 214 | if err != nil { 215 | return nil, err 216 | } 217 | 218 | ipAddress := i.getPrimaryIP(device.PrimaryIp) 219 | if ipAddress == nil { 220 | return nil, fmt.Errorf("primary ip is not set on %s", device.Name) 221 | } 222 | 223 | tenant := i.getTenant(device) 224 | regionName := i.getRegionName(site) 225 | siteAddress := strings.ReplaceAll(site.PhysicalAddress, "\r\n", ", ") 226 | deviceType := device.DeviceType.Display 227 | siteGroup := "" 228 | if site.Group != nil { 229 | siteGroup = site.Group.Slug 230 | } 231 | 232 | virtualChassisName := "" 233 | if device.VirtualChassis != nil { 234 | virtualChassisName = device.VirtualChassis.Name 235 | } 236 | 237 | env := i.getCommonEnvironment("device") 238 | env.Device = device 239 | env.DevicePort = 22 240 | env.DeviceName = device.Display 241 | env.DeviceRole = device.Role.Name 242 | env.DeviceType = deviceType 243 | env.DeviceIP = *ipAddress 244 | env.RegionName = strings.ReplaceAll(regionName, "/", "") 245 | env.TenantName = strings.ReplaceAll(tenant, "/", "") 246 | env.Site = site 247 | env.SiteName = site.Display 248 | env.SiteGroup = siteGroup 249 | env.SiteAddress = siteAddress 250 | env.VirtualChassisName = virtualChassisName 251 | 252 | err = applyOverrides(i.cfg.Session.Overrides, env) 253 | if err != nil { 254 | return nil, err 255 | } 256 | 257 | // Check if the device should be filtered 258 | shouldSaveSession := i.checkFilters(env) 259 | if shouldSaveSession { 260 | path := filepath.Clean(fmt.Sprintf("%s/%s/%s.ini", i.scrt.GetSessionPath(), env.Path, env.DeviceName)) 261 | session := getSessionWithOverrides(path, env) 262 | sessions = append(sessions, session) 263 | err = i.writeSession(session) 264 | if err != nil { 265 | return nil, err 266 | } 267 | } 268 | } 269 | 270 | return sessions, nil 271 | } 272 | 273 | func (i *InventorySync) getVirtualMachineSessions(devices []netbox.VirtualMachineWithConfigContext, sites []netbox.Site) ([]*securecrt.SecureCRTSession, error) { 274 | var sessions []*securecrt.SecureCRTSession 275 | for _, device := range devices { 276 | if device.Site == nil { 277 | return nil, fmt.Errorf("site is not set on: %s", device.Name) 278 | } 279 | 280 | site, err := i.getSite(sites, device.Site.Id) 281 | if err != nil { 282 | return nil, err 283 | } 284 | 285 | ipAddress := i.getPrimaryIP(device.PrimaryIp) 286 | if ipAddress == nil { 287 | return nil, fmt.Errorf("primary ip is not set on: %s", device.Name) 288 | } 289 | 290 | tenant := i.getTenant(device) 291 | regionName := i.getRegionName(site) 292 | siteAddress := strings.ReplaceAll(site.PhysicalAddress, "\r\n", ", ") 293 | deviceType := "" 294 | if device.Platform != nil { 295 | deviceType = device.Platform.Display 296 | } 297 | 298 | siteGroup := "" 299 | if site.Group != nil { 300 | siteGroup = site.Group.Slug 301 | } 302 | 303 | env := i.getCommonEnvironment("virtual_machine") 304 | env.Device = device 305 | env.DevicePort = 22 306 | env.DeviceName = device.Display 307 | env.DeviceRole = "Virtual Machine" 308 | env.DeviceType = deviceType 309 | env.DeviceIP = *ipAddress 310 | env.RegionName = strings.ReplaceAll(regionName, "/", "") 311 | env.TenantName = strings.ReplaceAll(tenant, "/", "") 312 | env.Site = site 313 | env.SiteName = site.Display 314 | env.SiteGroup = siteGroup 315 | env.SiteAddress = siteAddress 316 | 317 | err = applyOverrides(i.cfg.Session.Overrides, env) 318 | if err != nil { 319 | return nil, err 320 | } 321 | 322 | // Check if the device should be filtered 323 | shouldSaveSession := i.checkFilters(env) 324 | if shouldSaveSession { 325 | path := filepath.Clean(fmt.Sprintf("%s/%s/%s.ini", i.scrt.GetSessionPath(), env.Path, env.DeviceName)) 326 | session := getSessionWithOverrides(path, env) 327 | sessions = append(sessions, session) 328 | err = i.writeSession(session) 329 | if err != nil { 330 | return nil, err 331 | } 332 | } 333 | } 334 | 335 | return sessions, nil 336 | } 337 | 338 | func (i *InventorySync) runSync() error { 339 | err := i.nb.TestConnection() 340 | if err != nil { 341 | return err 342 | } 343 | 344 | i.stateLogger(STATE_RUNNING, "Running: Getting sites") 345 | sites, err := i.nb.GetSites() 346 | if err != nil { 347 | return err 348 | } 349 | 350 | i.stateLogger(STATE_RUNNING, "Running: Getting devices") 351 | devices, err := i.nb.GetDevices() 352 | if err != nil { 353 | return err 354 | } 355 | 356 | var consolePorts []netbox.ConsoleServerPort 357 | if i.cfg.EnableConsoleServerSync { 358 | i.stateLogger(STATE_RUNNING, "Running: Getting Console Server Ports") 359 | consolePorts, err = i.nb.GetConsoleServerPorts() 360 | if err != nil { 361 | return err 362 | } 363 | } 364 | 365 | i.stateLogger(STATE_RUNNING, "Running: Getting Virtual Machines") 366 | vms, err := i.nb.GetVirtualMachines() 367 | if err != nil { 368 | return err 369 | } 370 | 371 | i.stateLogger(STATE_RUNNING, "Running: Writing sessions") 372 | deviceSessions, err := i.getDeviceSessions(devices, sites) 373 | if err != nil { 374 | return err 375 | } 376 | 377 | vmSessions, err := i.getVirtualMachineSessions(vms, sites) 378 | if err != nil { 379 | return err 380 | } 381 | 382 | var consoleSessions []*securecrt.SecureCRTSession 383 | if i.cfg.EnableConsoleServerSync { 384 | consoleSessions, err = i.getConsoleSessions(devices, consolePorts, sites) 385 | if err != nil { 386 | return err 387 | } 388 | } 389 | 390 | i.stateLogger(STATE_RUNNING, "Running: Removing old sessions") 391 | allSessions := append(deviceSessions, vmSessions...) 392 | allSessions = append(allSessions, consoleSessions...) 393 | i.scrt.RemoveSessions(allSessions) 394 | 395 | return nil 396 | } 397 | 398 | func (i *InventorySync) RunSync() { 399 | lastSync := time.Now() 400 | err := i.runSync() 401 | if err != nil { 402 | i.stateLogger(STATE_ERROR, err.Error()) 403 | } else { 404 | i.stateLogger(STATE_DONE, fmt.Sprintf("Status: Last sync @ %s", lastSync.Format("15:04"))) 405 | } 406 | } 407 | 408 | func (i *InventorySync) SetupPeriodicSync() { 409 | for range i.periodicTicker.C { 410 | if i.cfg.EnablePeriodicSync { 411 | i.RunSync() 412 | } 413 | } 414 | } 415 | --------------------------------------------------------------------------------