├── 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 |
--------------------------------------------------------------------------------