├── .gitignore
├── opnsense.go
├── tt.xml
├── go.mod
├── .goreleaser.yaml
├── internal
├── PrintDocument.go
├── checkos.go
├── EtreeToJSON.go
├── color_map.go
├── LoadXMLFile.go
├── setflags.go
├── sshAgent_unix.go
├── sshAgent_windows.go
├── log.go
├── SaveXMLFile.go
├── executecmd.go
├── ConfigToOutput.go
├── sftpCmd.go
├── FocusEtree.go
├── PatchXML.go
├── getSSHClient.go
├── EtreeToTTY.go
└── DiffXML.go
├── .github
├── make_manifest.sh
└── workflows
│ ├── old_bsd.yml
│ └── build.yml
├── README.MD
├── cmd
├── save.go
├── show.go
├── load.go
├── export.go
├── import.go
├── compare.go
├── backup.go
├── root.go
├── delete.go
├── run.go
├── discard.go
├── commit.go
├── sysinfo.go
└── set.go
├── Makefile
├── doc
└── scope.md
├── go.sum
└── LICENSE
/.gitignore:
--------------------------------------------------------------------------------
1 | opnsense.exe
2 |
3 | test/
4 | build/
5 |
6 | dist/
7 |
--------------------------------------------------------------------------------
/opnsense.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "github.com/mihakralj/opnsense/cmd"
5 | )
6 |
7 | func main() {
8 | cmd.Execute()
9 | }
10 |
--------------------------------------------------------------------------------
/tt.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | dracula
4 |
5 |
6 | wifi
7 |
8 |
9 |
10 |
11 |
12 | value
13 |
14 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/mihakralj/opnsense
2 |
3 | go 1.20
4 |
5 | require (
6 | github.com/Microsoft/go-winio v0.6.1
7 | github.com/beevik/etree v1.2.0
8 | github.com/clbanning/mxj v1.8.4
9 | github.com/pkg/sftp v1.13.6
10 | github.com/spf13/cobra v1.7.0
11 | golang.org/x/crypto v0.11.0
12 | golang.org/x/term v0.10.0
13 | gopkg.in/yaml.v3 v3.0.1
14 | )
15 |
16 | require (
17 | github.com/inconshreveable/mousetrap v1.1.0 // indirect
18 | github.com/kr/fs v0.1.0 // indirect
19 | github.com/spf13/pflag v1.0.5 // indirect
20 | golang.org/x/mod v0.12.0 // indirect
21 | golang.org/x/sys v0.10.0 // indirect
22 | golang.org/x/tools v0.11.1 // indirect
23 | )
24 |
--------------------------------------------------------------------------------
/.goreleaser.yaml:
--------------------------------------------------------------------------------
1 | before:
2 | hooks:
3 | - go mod tidy
4 | - go generate ./...
5 |
6 | builds:
7 | - env:
8 | - GOARCH=amd64
9 | - GOOS=freebsd
10 | binary: opnsense
11 | goos:
12 | - linux
13 | goarch:
14 | - amd64
15 | hooks:
16 | post:
17 | - ./.github/make_manifest.sh {{.Version}}
18 |
19 | checksum:
20 | name_template: 'checksums.txt'
21 | snapshot:
22 | name_template: "{{ incpatch .Version }}"
23 |
24 |
25 |
26 | # The lines beneath this are called `modelines`. See `:help modeline`
27 | # Feel free to remove those if you don't want/use them.
28 | # yaml-language-server: $schema=https://goreleaser.com/static/schema.json
29 | # vim: set ts=2 sw=2 tw=0 fo=cnqoj
30 |
--------------------------------------------------------------------------------
/internal/PrintDocument.go:
--------------------------------------------------------------------------------
1 | /*
2 | Copyright © 2023 Miha miha.kralj@outlook.com
3 |
4 | Licensed under the Apache License, Version 2.0 (the "License");
5 | you may not use this file except in compliance with the License.
6 | You may obtain a copy of the License at
7 |
8 | http://www.apache.org/licenses/LICENSE-2.0
9 |
10 | Unless required by applicable law or agreed to in writing, software
11 | distributed under the License is distributed on an "AS IS" BASIS,
12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | See the License for the specific language governing permissions and
14 | limitations under the License.
15 | */
16 | package internal
17 |
18 | import (
19 | "fmt"
20 |
21 | "github.com/beevik/etree"
22 | )
23 |
24 | func PrintDocument(doc *etree.Document, path string) {
25 | var output string
26 | switch {
27 | case xmlFlag:
28 | output = ConfigToXML(doc, path)
29 | case jsonFlag:
30 | output = ConfigToJSON(doc, path)
31 | case yamlFlag:
32 | output = ConfigToYAML(doc, path)
33 | default:
34 | output = ConfigToTTY(doc, path)
35 | }
36 | fmt.Println(output)
37 | }
38 |
--------------------------------------------------------------------------------
/internal/checkos.go:
--------------------------------------------------------------------------------
1 | /*
2 | Copyright © 2023 Miha miha.kralj@outlook.com
3 |
4 | Licensed under the Apache License, Version 2.0 (the "License");
5 | you may not use this file except in compliance with the License.
6 | You may obtain a copy of the License at
7 |
8 | http://www.apache.org/licenses/LICENSE-2.0
9 |
10 | Unless required by applicable law or agreed to in writing, software
11 | distributed under the License is distributed on an "AS IS" BASIS,
12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | See the License for the specific language governing permissions and
14 | limitations under the License.
15 | */
16 | package internal
17 |
18 | import (
19 | "strings"
20 | )
21 |
22 | // Checkos checks that the target is an OPNsense system
23 | func Checkos() (string, error) {
24 | //check that the target is OPNsense
25 | osstr := ExecuteCmd("echo `uname` `opnsense-version -N`", host)
26 | osstr = strings.TrimSpace(osstr)
27 | if osstr != "FreeBSD OPNsense" {
28 | Log(1, "%s is not OPNsense system", osstr)
29 | }
30 | Log(4, "OPNsense system detected")
31 | return osstr, nil
32 | }
33 |
--------------------------------------------------------------------------------
/internal/EtreeToJSON.go:
--------------------------------------------------------------------------------
1 | /*
2 | Copyright © 2023 Miha miha.kralj@outlook.com
3 |
4 | Licensed under the Apache License, Version 2.0 (the "License");
5 | you may not use this file except in compliance with the License.
6 | You may obtain a copy of the License at
7 |
8 | http://www.apache.org/licenses/LICENSE-2.0
9 |
10 | Unless required by applicable law or agreed to in writing, software
11 | distributed under the License is distributed on an "AS IS" BASIS,
12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | See the License for the specific language governing permissions and
14 | limitations under the License.
15 | */
16 | package internal
17 |
18 | import (
19 | "github.com/beevik/etree"
20 | "github.com/clbanning/mxj"
21 | )
22 |
23 | func EtreeToJSON(el *etree.Element) (string, error) {
24 | doc := etree.NewDocument()
25 | doc.SetRoot(el.Copy())
26 |
27 | str, err := doc.WriteToString()
28 | if err != nil {
29 | return "", err
30 | }
31 | mv, err := mxj.NewMapXml([]byte(str)) // parse xml to map
32 | if err != nil {
33 | return "", err
34 | }
35 |
36 | jsonStr, err := mv.JsonIndent("", " ") // convert map to json
37 | if err != nil {
38 | return "", err
39 | }
40 |
41 | return string(jsonStr), nil
42 | }
43 |
--------------------------------------------------------------------------------
/internal/color_map.go:
--------------------------------------------------------------------------------
1 | /*
2 | Copyright © 2023 Miha miha.kralj@outlook.com
3 |
4 | Licensed under the Apache License, Version 2.0 (the "License");
5 | you may not use this file except in compliance with the License.
6 | You may obtain a copy of the License at
7 |
8 | http://www.apache.org/licenses/LICENSE-2.0
9 |
10 | Unless required by applicable law or agreed to in writing, software
11 | distributed under the License is distributed on an "AS IS" BASIS,
12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | See the License for the specific language governing permissions and
14 | limitations under the License.
15 | */
16 | package internal
17 |
18 | var c = map[string]string{
19 | "tag": "\033[0m",
20 | "txt": "\033[33m",
21 | "atr": "\033[33m",
22 |
23 | "chg": "\033[36m",
24 | "add": "\033[32m",
25 | "del": "\033[31m\033[9m",
26 | "red": "\033[31m",
27 | "grn": "\033[32m",
28 | "ele": "\033[36m",
29 |
30 | "yel": "\033[33m",
31 | "blu": "\033[34m",
32 | "mgn": "\033[35m",
33 | "cyn": "\033[36m",
34 | "wht": "\033[37m",
35 | "gry": "\033[90m",
36 |
37 | "ita": "\033[3m", // italics
38 | "bld": "\033[1m", // bold
39 | "stk": "\033[9m", // strikethroough
40 | "und": "\033[4m",
41 | "rev": "\033[7m", // reverse colors
42 |
43 | "ell": "\u2026",
44 | "arw": " \u2192 ",
45 | "nil": "\033[0m",
46 | }
47 |
--------------------------------------------------------------------------------
/internal/LoadXMLFile.go:
--------------------------------------------------------------------------------
1 | /*
2 | Copyright © 2023 Miha miha.kralj@outlook.com
3 |
4 | Licensed under the Apache License, Version 2.0 (the "License");
5 | you may not use this file except in compliance with the License.
6 | You may obtain a copy of the License at
7 |
8 | http://www.apache.org/licenses/LICENSE-2.0
9 |
10 | Unless required by applicable law or agreed to in writing, software
11 | distributed under the License is distributed on an "AS IS" BASIS,
12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | See the License for the specific language governing permissions and
14 | limitations under the License.
15 | */
16 | package internal
17 |
18 | import (
19 | "fmt"
20 | "strings"
21 |
22 | "github.com/beevik/etree"
23 | )
24 |
25 | func LoadXMLFile(filename string, host string, canBeMissing bool) *etree.Document {
26 | if !strings.HasSuffix(filename, ".xml") {
27 | Log(1, "filename %s does not end with .xml", filename)
28 | }
29 | doc := etree.NewDocument()
30 | bash := fmt.Sprintf(`test -f "%s" && cat "%s" || echo "missing"`, filename, filename)
31 | content := ExecuteCmd(bash, host)
32 | if strings.TrimSpace(content) == "missing" {
33 | if canBeMissing {
34 | return nil
35 | } else {
36 | Log(1, "failed to get data from %s", filename)
37 | }
38 | }
39 | err := doc.ReadFromString(content)
40 | if err != nil {
41 | Log(1, "%s is not an XML file", filename)
42 | }
43 | return doc
44 |
45 | }
46 |
--------------------------------------------------------------------------------
/internal/setflags.go:
--------------------------------------------------------------------------------
1 | /*
2 | Copyright © 2023 Miha miha.kralj@outlook.com
3 |
4 | Licensed under the Apache License, Version 2.0 (the "License");
5 | you may not use this file except in compliance with the License.
6 | You may obtain a copy of the License at
7 |
8 | http://www.apache.org/licenses/LICENSE-2.0
9 |
10 | Unless required by applicable law or agreed to in writing, software
11 | distributed under the License is distributed on an "AS IS" BASIS,
12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | See the License for the specific language governing permissions and
14 | limitations under the License.
15 | */
16 | package internal
17 |
18 | var (
19 | verbose int
20 | force bool
21 | host string
22 | configfile string
23 | nocolor bool
24 | depth int
25 | xmlFlag bool
26 | yamlFlag bool
27 | jsonFlag bool
28 | )
29 |
30 | func SetFlags(v int, f bool, h string, config string, nc bool, dpt int, x bool, y bool, j bool) {
31 | if v < 1 || v > 5 {
32 | Log(1, "invalid verbosity level %d. It should be between 1 and 5", v)
33 | }
34 | verbose = v
35 | force = f
36 | host = h
37 | configfile = config
38 | nocolor = nc
39 | depth = dpt
40 | xmlFlag = x
41 | yamlFlag = y
42 | jsonFlag = j
43 | Log(5, "flags:\tverbose=%d, host=%s, config=%s", verbose, host, configfile)
44 | if nc {
45 | for key := range c {
46 | delete(c, key)
47 | }
48 | c["ell"] = "..."
49 | c["arw"] = " -> "
50 | }
51 | }
52 |
53 | func FullDepth() {
54 | depth = depth+50
55 | }
56 |
--------------------------------------------------------------------------------
/internal/sshAgent_unix.go:
--------------------------------------------------------------------------------
1 | /*
2 | Copyright © 2023 Miha miha.kralj@outlook.com
3 |
4 | Licensed under the Apache License, Version 2.0 (the "License");
5 | you may not use this file except in compliance with the License.
6 | You may obtain a copy of the License at
7 |
8 | http://www.apache.org/licenses/LICENSE-2.0
9 |
10 | Unless required by applicable law or agreed to in writing, software
11 | distributed under the License is distributed on an "AS IS" BASIS,
12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | See the License for the specific language governing permissions and
14 | limitations under the License.
15 | */
16 | //go:build !windows
17 | // +build !windows
18 |
19 | package internal
20 |
21 | import (
22 | "errors"
23 | "golang.org/x/crypto/ssh"
24 | "golang.org/x/crypto/ssh/agent"
25 | "net"
26 | "os"
27 | )
28 |
29 | func GetSSHAgent() (ssh.AuthMethod, error) {
30 | var agentClient agent.Agent
31 | sshAgent, err := net.Dial("unix", os.Getenv("SSH_AUTH_SOCK"))
32 | if err != nil {
33 | return nil, err
34 | }
35 | agentClient = agent.NewClient(sshAgent)
36 |
37 | signers, err := agentClient.Signers()
38 | if err != nil {
39 | return nil, err
40 | }
41 | if len(signers) == 0 {
42 | return nil, errors.New("SSH agent has no keys")
43 | }
44 |
45 | return ssh.PublicKeysCallback(agentClient.Signers), nil
46 | }
47 |
48 | func createAgentClient() (agent.Agent, error) {
49 | sshAgent, err := net.Dial("unix", os.Getenv("SSH_AUTH_SOCK"))
50 | if err != nil {
51 | return nil, err
52 | }
53 | return agent.NewClient(sshAgent), nil
54 | }
55 |
--------------------------------------------------------------------------------
/internal/sshAgent_windows.go:
--------------------------------------------------------------------------------
1 | /*
2 | Copyright © 2023 Miha miha.kralj@outlook.com
3 |
4 | Licensed under the Apache License, Version 2.0 (the "License");
5 | you may not use this file except in compliance with the License.
6 | You may obtain a copy of the License at
7 |
8 | http://www.apache.org/licenses/LICENSE-2.0
9 |
10 | Unless required by applicable law or agreed to in writing, software
11 | distributed under the License is distributed on an "AS IS" BASIS,
12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | See the License for the specific language governing permissions and
14 | limitations under the License.
15 | */
16 | //go:build windows
17 | // +build windows
18 |
19 | package internal
20 |
21 | import (
22 | "errors"
23 | "github.com/Microsoft/go-winio"
24 | "golang.org/x/crypto/ssh"
25 | "golang.org/x/crypto/ssh/agent"
26 | )
27 |
28 | func GetSSHAgent() (ssh.AuthMethod, error) {
29 | var agentClient agent.Agent
30 | conn, err := winio.DialPipe(`\\.\pipe\openssh-ssh-agent`, nil)
31 | if err != nil {
32 | return nil, err
33 | }
34 | agentClient = agent.NewClient(conn)
35 |
36 | signers, err := agentClient.Signers()
37 | if err != nil {
38 | return nil, err
39 | }
40 | if len(signers) == 0 {
41 | return nil, errors.New("SSH agent has no keys")
42 | }
43 |
44 | return ssh.PublicKeysCallback(agentClient.Signers), nil
45 | }
46 |
47 | func createAgentClient() (agent.Agent, error) {
48 | conn, err := winio.DialPipe(`\\.\pipe\openssh-ssh-agent`, nil)
49 | if err != nil {
50 | return nil, err
51 | }
52 | return agent.NewClient(conn), nil
53 | }
54 |
--------------------------------------------------------------------------------
/internal/log.go:
--------------------------------------------------------------------------------
1 | /*
2 | Copyright © 2023 Miha miha.kralj@outlook.com
3 |
4 | Licensed under the Apache License, Version 2.0 (the "License");
5 | you may not use this file except in compliance with the License.
6 | You may obtain a copy of the License at
7 |
8 | http://www.apache.org/licenses/LICENSE-2.0
9 |
10 | Unless required by applicable law or agreed to in writing, software
11 | distributed under the License is distributed on an "AS IS" BASIS,
12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | See the License for the specific language governing permissions and
14 | limitations under the License.
15 | */
16 | package internal
17 |
18 | import (
19 | "bufio"
20 | "fmt"
21 | "os"
22 | "strings"
23 | )
24 |
25 | func Log(verbosity int, format string, args ...interface{}) {
26 | levels := []string{"",
27 | c["red"] + "Error:\t " + c["nil"],
28 | c["yel"] + "Warning: " + c["nil"],
29 | c["grn"] + "Info:\t " + c["nil"],
30 | c["blu"] + "Note:\t " + c["nil"],
31 | c["wht"] + "Debug:\t " + c["nil"]}
32 |
33 | formatted := fmt.Sprintf(format, args...)
34 | if len(formatted) > 2000 {
35 | formatted = formatted[:1000] + "\n...\n" + formatted[len(formatted)-200:]
36 | }
37 | message := levels[verbosity] + formatted
38 |
39 | if (verbose >= verbosity || verbosity == 1) && verbosity != 2 {
40 | fmt.Fprintln(os.Stderr, message)
41 | }
42 | if verbosity == 2 && !force {
43 | fmt.Print(message + "\nAre you sure? (Y/N): ")
44 | reader := bufio.NewReader(os.Stdin)
45 | response, err := reader.ReadString('\n')
46 | if err != nil {
47 | Log(1, "error reading input")
48 | }
49 | response = strings.ToLower(strings.TrimSpace(response))
50 | if response == "y" || response == "yes" {
51 | return
52 | } else {
53 | fmt.Fprintln(os.Stderr, "action canceled")
54 | os.Exit(1)
55 | }
56 | }
57 | if verbosity == 1 {
58 | os.Exit(1)
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/internal/SaveXMLFile.go:
--------------------------------------------------------------------------------
1 | /*
2 | Copyright © 2023 Miha miha.kralj@outlook.com
3 |
4 | Licensed under the Apache License, Version 2.0 (the "License");
5 | you may not use this file except in compliance with the License.
6 | You may obtain a copy of the License at
7 |
8 | http://www.apache.org/licenses/LICENSE-2.0
9 |
10 | Unless required by applicable law or agreed to in writing, software
11 | distributed under the License is distributed on an "AS IS" BASIS,
12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | See the License for the specific language governing permissions and
14 | limitations under the License.
15 | */
16 | package internal
17 |
18 | import (
19 | "fmt"
20 | "math/rand"
21 | "strings"
22 | "time"
23 |
24 | "github.com/beevik/etree"
25 | )
26 |
27 | func SaveXMLFile(filename string, doc *etree.Document, host string, forced bool) {
28 | configout := ConfigToXML(doc, "opnsense")
29 | bash := `test -f "` + filename + `" && echo "exists" || echo "missing"`
30 | fileexists := ExecuteCmd(bash, host)
31 |
32 | if strings.TrimSpace(fileexists) == "exists" {
33 | if !forced {
34 | Log(2, "%s already exists and will be overwritten.", filename)
35 | }
36 | // delete the file
37 | bash = "sudo rm " + filename
38 | ExecuteCmd(bash, host)
39 | }
40 | sftpCmd(configout, filename, host)
41 |
42 | // check that file was written
43 | bash = `test -f "` + filename + `" && echo "exists" || echo "missing"`
44 | fileexists = ExecuteCmd(bash, host)
45 |
46 | if strings.TrimSpace(fileexists) == "exists" {
47 | Log(4, "%s has been succesfully saved.\n", filename)
48 | } else {
49 | Log(1, "error writing file %s", filename)
50 | }
51 | }
52 |
53 | func GenerateBackupFilename() string {
54 | timestamp := time.Now().Unix()
55 | randomNumber := rand.Intn(10000)
56 | filename := fmt.Sprintf("config-%d.%04d.xml", timestamp, randomNumber)
57 | return filename
58 | }
59 |
--------------------------------------------------------------------------------
/internal/executecmd.go:
--------------------------------------------------------------------------------
1 | /*
2 | Copyright © 2023 Miha miha.kralj@outlook.com
3 |
4 | Licensed under the Apache License, Version 2.0 (the "License");
5 | you may not use this file except in compliance with the License.
6 | You may obtain a copy of the License at
7 |
8 | http://www.apache.org/licenses/LICENSE-2.0
9 |
10 | Unless required by applicable law or agreed to in writing, software
11 | distributed under the License is distributed on an "AS IS" BASIS,
12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | See the License for the specific language governing permissions and
14 | limitations under the License.
15 | */
16 | package internal
17 |
18 | import (
19 | "bytes"
20 | "os/exec"
21 | "strings"
22 | )
23 |
24 | func ExecuteCmd(command, host string) string {
25 | if host == "" {
26 | Log(5, "local shell: %s", command)
27 | out, err := exec.Command("sh", "-c", command).Output()
28 | if err != nil {
29 | Log(1, "failed to execute command: %s %s", command, err.Error())
30 | }
31 | Log(5, "received from local shell: %s", out)
32 |
33 | return string(out)
34 | }
35 |
36 | sshClient, err := getSSHClient(host)
37 | if err != nil {
38 | Log(1, "failed to initiate ssh client. %s", err.Error())
39 | }
40 |
41 | if sshClient.Session == nil {
42 | session, err := sshClient.Client.NewSession()
43 | if err != nil {
44 | Log(1, "failed to initiate ssh session. %s", err.Error())
45 | }
46 | var stdout bytes.Buffer
47 | session.Stdout = &stdout
48 | sshClient.Session = session
49 | }
50 |
51 | var stdoutBuf bytes.Buffer
52 | sshClient.Session.Stdout = &stdoutBuf
53 | Log(5, "ssh: %s", command)
54 | err = sshClient.Session.Run(command)
55 | if err != nil {
56 | Log(1, "failed to execute ssh command: %s", err.Error())
57 | }
58 | Log(5, "received from ssh: %s", strings.TrimRight(stdoutBuf.String(), "\n"))
59 | return strings.TrimRight(stdoutBuf.String(), "\n")
60 | }
61 |
--------------------------------------------------------------------------------
/internal/ConfigToOutput.go:
--------------------------------------------------------------------------------
1 | /*
2 | Copyright © 2023 Miha miha.kralj@outlook.com
3 |
4 | Licensed under the Apache License, Version 2.0 (the "License");
5 | you may not use this file except in compliance with the License.
6 | You may obtain a copy of the License at
7 |
8 | http://www.apache.org/licenses/LICENSE-2.0
9 |
10 | Unless required by applicable law or agreed to in writing, software
11 | distributed under the License is distributed on an "AS IS" BASIS,
12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | See the License for the specific language governing permissions and
14 | limitations under the License.
15 | */
16 | package internal
17 |
18 | import (
19 | "encoding/json"
20 | "strings"
21 |
22 | "github.com/beevik/etree"
23 | "gopkg.in/yaml.v3"
24 | )
25 |
26 | func ConfigToTTY(doc *etree.Document, path string) string {
27 |
28 | path = strings.TrimPrefix(path, "/")
29 | focused := FocusEtree(doc, path)
30 | d := depth + len(strings.Split(path, "/")) - 1
31 | if len(doc.FindElements(path)) > 1 {
32 | d -= 1
33 | }
34 | return EtreeToTTY(focused, d, 0)
35 | }
36 |
37 | func ConfigToXML(doc *etree.Document, path string) string {
38 | focused := FocusEtree(doc, path)
39 | newDoc := etree.NewDocument()
40 | newDoc.CreateProcInst("xml", `version="1.0" encoding="UTF-8"`)
41 | //newDoc.CreateComment("XPath: " + path)
42 | newDoc.SetRoot(focused)
43 | newDoc.Indent(2)
44 | xmlStr, err := newDoc.WriteToString()
45 | if err != nil {
46 | return ""
47 | }
48 | return xmlStr
49 | }
50 |
51 | func ConfigToJSON(doc *etree.Document, path string) string {
52 | focused := FocusEtree(doc, path)
53 | res, _ := EtreeToJSON(focused)
54 | return res
55 | }
56 |
57 | func ConfigToYAML(doc *etree.Document, path string) string {
58 | focused := FocusEtree(doc, path)
59 | jsonStr, _ := EtreeToJSON(focused)
60 | var jsonObj interface{}
61 | json.Unmarshal([]byte(jsonStr), &jsonObj)
62 |
63 | yamlBytes, _ := yaml.Marshal(jsonObj)
64 | return string(yamlBytes)
65 | }
66 |
--------------------------------------------------------------------------------
/internal/sftpCmd.go:
--------------------------------------------------------------------------------
1 | /*
2 | Copyright © 2023 Miha miha.kralj@outlook.com
3 |
4 | Licensed under the Apache License, Version 2.0 (the "License");
5 | you may not use this file except in compliance with the License.
6 | You may obtain a copy of the License at
7 |
8 | http://www.apache.org/licenses/LICENSE-2.0
9 |
10 | Unless required by applicable law or agreed to in writing, software
11 | distributed under the License is distributed on an "AS IS" BASIS,
12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | See the License for the specific language governing permissions and
14 | limitations under the License.
15 | */
16 | package internal
17 |
18 | import (
19 | "os"
20 |
21 | "github.com/pkg/sftp"
22 | )
23 |
24 | func sftpCmd(data, filename, host string) {
25 | var sftpClient *sftp.Client
26 | var err error
27 |
28 | if host == "" {
29 | // If host is empty, save the data to a local file
30 | err = os.WriteFile(filename, []byte(data), 0644)
31 | if err != nil {
32 | Log(1, "Failed to save data to local file. %s", err.Error())
33 | }
34 | Log(4, "Successfully saved data to local file %s", filename)
35 | return
36 | }
37 |
38 | sshClient, err := getSSHClient(host)
39 | if err != nil {
40 | Log(1, "failed to initiate ssh client. %s", err.Error())
41 | }
42 |
43 | if sshClient.Client == nil {
44 | Log(1, "SSH client is nil. Cannot perform SFTP operation.")
45 | }
46 |
47 | // Create an SFTP client
48 | sftpClient, err = sftp.NewClient(sshClient.Client)
49 | if err != nil {
50 | Log(1, "Failed to initiate SFTP client. %s", err.Error())
51 | }
52 | defer sftpClient.Close()
53 |
54 | // Create remote file
55 | remoteFile, err := sftpClient.Create(filename)
56 | if err != nil {
57 | Log(1, "Failed to create remote file. %s", err.Error())
58 | }
59 | defer remoteFile.Close()
60 |
61 | // Write data to remote file
62 | _, err = remoteFile.Write([]byte(data))
63 | if err != nil {
64 | Log(1, "Failed to write to remote file. %s", err.Error())
65 | }
66 |
67 | Log(4, "Successfully transferred data to %s on host %s", filename, host)
68 | }
69 |
--------------------------------------------------------------------------------
/.github/make_manifest.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | mkdir -p ./dist/pkg/usr/local/bin
4 | export GOARCH=amd64
5 | export GOOS=freebsd
6 | export GOCACHE=~/Github/opnsense-cli/dist/pkg/cache
7 | go mod vendor
8 | go build -trimpath -ldflags "-w -s" -mod=vendor -o ./dist/pkg/usr/local/bin/opnsense opnsense.go
9 | chmod +x ./dist/pkg/usr/local/bin/opnsense
10 | SHA=$(shasum -a 256 ./dist/pkg/usr/local/bin/opnsense | awk '{ print $1 }')
11 |
12 | VERSION=$1
13 | FLATSIZE=$(du -b -s ./dist/pkg/usr/local/bin | cut -f1)
14 | MANIFEST="./dist/pkg/+MANIFEST"
15 | echo -e "{\n\"name\": \"opnsense-cli\"," > $MANIFEST
16 | echo -e "\"version\": \"${VERSION}\"," >> $MANIFEST
17 | echo -e "\"origin\": \"net-mgmt/opnsense-cli\"," >> $MANIFEST
18 | echo -e "\"comment\": \"CLI to manage and monitor OPNsense firewall configuration, check status, change settings, and execute commands.\"," >> $MANIFEST
19 | echo -e "\"desc\": \"opnsense is a command-line utility for managing, configuring, and monitoring OPNsense firewall systems. It facilitates non-GUI administration, both directly in the shell and remotely via an SSH tunnel.\"," >> $MANIFEST
20 | echo -e "\"maintainer\": \"miha.kralj@outlook.com\"," >> $MANIFEST
21 | echo -e "\"www\": \"https://github.com/mihakralj/opnsense-cli\"," >> $MANIFEST
22 | echo -e "\"abi\": \"FreeBSD:*:amd64\"," >> $MANIFEST
23 | echo -e "\"prefix\": \"/usr/local\"," >> $MANIFEST
24 | echo -e "\"flatsize\": ${FLATSIZE}," >> $MANIFEST
25 | echo -e "\"licenselogic\": \"single\"," >> $MANIFEST
26 | echo -e "\"licenses\": [\"APACHE20\"]," >> $MANIFEST
27 | echo -e "\"files\": {" >> $MANIFEST
28 | echo -e "\"/usr/local/bin/opnsense\": \"SHA256:$SHA\"" >> $MANIFEST
29 | echo -e "}" >> $MANIFEST
30 | echo -e "}" >> $MANIFEST
31 |
32 | MANIFEST="./dist/pkg/+COMPACT_MANIFEST"
33 | echo -e "{\n\"name\": \"opnsense-cli\"," > $MANIFEST
34 | echo -e "\"version\": \"${VERSION}\"," >> $MANIFEST
35 | echo -e "\"origin\": \"net-mgmt/opnsense-cli\"," >> $MANIFEST
36 | echo -e "\"comment\": \"CLI to manage and monitor OPNsense firewall configuration, check status, change settings, and execute commands.\"," >> $MANIFEST
37 | echo -e "\"www\": \"https://github.com/mihakralj/opnsense-cli\"," >> $MANIFEST
38 | echo -e "\"abi\": \"FreeBSD:*:amd64\"," >> $MANIFEST
39 | echo -e "}" >> $MANIFEST
40 |
41 | cd ./dist/pkg
42 | tar -cJf ../opnsense-cli-${VERSION}.txz -s'|^\./|/|' -P +MANIFEST +COMPACT_MANIFEST ./usr/local/bin/opnsense
43 |
--------------------------------------------------------------------------------
/README.MD:
--------------------------------------------------------------------------------
1 | # OPNsense Command Line Interface (CLI)
2 |
3 | *OPNsense CLI* is a command-line utility for FreeBSD, Linux, MacOS and Windows that empowers administrators and power users to manage, configure, and monitor OPNsense firewall systems. The CLI provides an alternative method to browser-based GUI to interact with the firewall system.
4 |
5 | [Why this thing exists?](/doc/scope.md)
6 |
7 |
8 | ## Usage
9 |
10 | `opnsense [flags] command [parameters]`
11 |
12 | ### Commands
13 |
14 | - **`show []`**: Displays config.xml or the Xpath segment in it
15 | - **`compare [] []`**: Compares two config files
16 | - **`set [value] [(attribute)]`**: Adds a new branch, value and/or attribute
17 | - **`set [value] [(attribute)] -d`**: Deletes branch, value and/or attribute
18 | - **`discard []`**: Discards a value (or all changes) in the 'staging.xml'
19 | - **`commit`**: Moves staging.xml to active 'config.xml'
20 | - **`export [] []`**: Extracts a patch file
21 | - **`import [patch.xml]`**: Reads provided XML patch and injects it into 'staging.xml'
22 | - **`backup []`**: Lists available backup configs or displays a specific backup
23 | - **`restore []`**: Restores config.xml from a specific backup.xml. (alias: `load`)
24 | - **`save []`**: Creates a new /conf/backup/file.xml
25 | - **`delete `**: Deletes a specific backup.xml.
26 | - **`delete age [days]`**: Deletes all backups older than specified days
27 | - **`delete keep [count]`**: Keeps specified number of backups and deletes the rest
28 | - **`delete trim [count]`**: Deletes number of the oldest backups
29 | - **`sysinfo []`**: Retrieves system information from the firewall
30 | - **`run `**: Executes commands on OPNsense.
31 |
32 | ### Flags
33 |
34 | - **`--target (-t)`**: Sets the target OPNsense in the form of `user@hostname[:port]`.
35 | - **`--no-color (-n)`**: Disable ANSI color output
36 | - **`--force (-f)`**: Removes checks and prompts before `config.xml` or `configctl` are touched.
37 | - **`--verbose (-v)`**: Sets verbosity (1=error, 2=warning, 3=info, 4=note, 5=debug).
38 | - **`--no-color (-n)`**: Removes ANSI colors from the printout.
39 | - **`--xml (-x)`**: Displays results in XML format.
40 | - **`--json (-j)`**: Displays results in JSON format.
41 | - **`--yaml (-y)`**: Displays results in YAML format.
42 |
43 |
--------------------------------------------------------------------------------
/cmd/save.go:
--------------------------------------------------------------------------------
1 | /*
2 | Copyright © 2023 Miha miha.kralj@outlook.com
3 |
4 | Licensed under the Apache License, Version 2.0 (the "License");
5 | you may not use this file except in compliance with the License.
6 | You may obtain a copy of the License at
7 |
8 | http://www.apache.org/licenses/LICENSE-2.0
9 |
10 | Unless required by applicable law or agreed to in writing, software
11 | distributed under the License is distributed on an "AS IS" BASIS,
12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | See the License for the specific language governing permissions and
14 | limitations under the License.
15 | */
16 | package cmd
17 |
18 | import (
19 | "fmt"
20 | "regexp"
21 | "strings"
22 |
23 | "github.com/mihakralj/opnsense/internal"
24 | "github.com/spf13/cobra"
25 | )
26 |
27 | // saveCmd represents the save command
28 | var saveCmd = &cobra.Command{
29 | Use: "save [filename]",
30 | Short: "Create a new backup XML configuration in '/conf/backup' directory",
31 | Long: `The 'save' command generates a new backup of the existing configuration, storing it in the '/conf/backup' directory. You can specify a filename for the backup, or if no filename is provided, the system will generate a default name based on the current epoch time.`,
32 | Example: ` opnsense save Save current config as '/conf/backup/config-.xml'
33 | opnsense save filename.xml Save current config as '/conf/backup/filename.xml'`,
34 |
35 | Run: func(cmd *cobra.Command, args []string) {
36 | filename := ""
37 | if len(args) < 1 {
38 | filename = internal.GenerateBackupFilename()
39 | } else {
40 | filename = args[0]
41 | filename = strings.TrimPrefix(filename, "/conf/backup/")
42 | filename = strings.TrimPrefix(filename, "conf/backup/")
43 | if !strings.HasSuffix(filename, ".xml") {
44 | filename += ".xml"
45 | }
46 | validFilenamePattern := "^[a-zA-Z0-9_.-]+$"
47 | match, err := regexp.MatchString(validFilenamePattern, filename)
48 | if err != nil || !match {
49 | internal.Log(1, "%s is not a valid filename to save in /conf/backup.", filename)
50 | }
51 | }
52 |
53 | filename = "/conf/backup/" + filename
54 | internal.Checkos()
55 | configdoc := internal.LoadXMLFile(configfile, host, false)
56 |
57 | internal.SaveXMLFile(filename, configdoc, host, false)
58 | fmt.Printf("%s saved to %s\n", configfile, filename)
59 | },
60 | }
61 |
62 | func init() {
63 | rootCmd.AddCommand(saveCmd)
64 | }
65 |
--------------------------------------------------------------------------------
/cmd/show.go:
--------------------------------------------------------------------------------
1 | /*
2 | Copyright © 2023 Miha miha.kralj@outlook.com
3 |
4 | Licensed under the Apache License, Version 2.0 (the "License");
5 | you may not use this file except in compliance with the License.
6 | You may obtain a copy of the License at
7 |
8 | http://www.apache.org/licenses/LICENSE-2.0
9 |
10 | Unless required by applicable law or agreed to in writing, software
11 | distributed under the License is distributed on an "AS IS" BASIS,
12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | See the License for the specific language governing permissions and
14 | limitations under the License.
15 | */
16 | package cmd
17 |
18 | import (
19 | "regexp"
20 | "strings"
21 |
22 | "github.com/mihakralj/opnsense/internal"
23 | "github.com/spf13/cobra"
24 | )
25 |
26 | // configCmd represents the show command
27 | var showCmd = &cobra.Command{
28 | Use: "show",
29 | Short: "Display active and staged information in 'config.xml'",
30 | Long: `The 'show' command allows you to view various configuration elements within the 'config.xml' file of your OPNsense firewall system. This includes details about interfaces, routes, firewall rules, and other essential settings. The command is useful for reviewing the current system configuration and aiding in troubleshooting.`,
31 | Example: ` opnsense show interfaces/wan Display configuration details for the WAN interface
32 | opnsense show system/hostname Show the system's current hostname
33 | opnsense show firewall/rules List all firewall rules in 'config.xml'`,
34 |
35 | Run: func(cmd *cobra.Command, args []string) {
36 |
37 | path := "opnsense"
38 | if len(args) >= 1 {
39 | trimmedArg := strings.Trim(args[0], "/")
40 | if matched, _ := regexp.MatchString(`\[0\]`, trimmedArg); matched {
41 | internal.Log(1, "XPath indexing of elements starts with 1, not 0")
42 | }
43 | if trimmedArg != "" {
44 | path = trimmedArg
45 | }
46 | parts := strings.Split(path, "/")
47 | if parts[0] != "opnsense" {
48 | path = "opnsense/" + path
49 | }
50 | }
51 |
52 | internal.Checkos()
53 |
54 | configdoc := internal.LoadXMLFile(configfile, host, false)
55 | stagingdoc := internal.LoadXMLFile(stagingfile, host, true)
56 | if stagingdoc == nil {
57 | stagingdoc = configdoc
58 | }
59 |
60 | deltadoc := internal.DiffXML(configdoc, stagingdoc, true)
61 | internal.PrintDocument(deltadoc, path)
62 |
63 | },
64 | }
65 |
66 | func init() {
67 | showCmd.Flags().IntVarP(&depth, "depth", "d", 1, "Specifies number of levels of returned tree (1-5)")
68 | rootCmd.AddCommand(showCmd)
69 | }
70 |
--------------------------------------------------------------------------------
/internal/FocusEtree.go:
--------------------------------------------------------------------------------
1 | /*
2 | Copyright © 2023 Miha miha.kralj@outlook.com
3 |
4 | Licensed under the Apache License, Version 2.0 (the "License");
5 | you may not use this file except in compliance with the License.
6 | You may obtain a copy of the License at
7 |
8 | http://www.apache.org/licenses/LICENSE-2.0
9 |
10 | Unless required by applicable law or agreed to in writing, software
11 | distributed under the License is distributed on an "AS IS" BASIS,
12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | See the License for the specific language governing permissions and
14 | limitations under the License.
15 | */
16 | package internal
17 |
18 | import (
19 | "strings"
20 |
21 | "github.com/beevik/etree"
22 | )
23 |
24 | // FocusEtree returns an etree.Element that represents the specified path in the document.
25 | // It removes all other branches above the element that are not on the path
26 | // If the path does not exist, it returns nil.
27 | func FocusEtree(doc *etree.Document, path string) *etree.Element {
28 | path = strings.TrimPrefix(path, "/")
29 |
30 | path = EnumeratePath(path)
31 | EnumerateListElements(doc.Root())
32 |
33 | // Find all elements that match the path
34 | foundElements := doc.FindElements(path)
35 |
36 | if len(foundElements) == 0 {
37 | Log(1, "Xpath element \"%s\" does not exist", path)
38 | return nil
39 | }
40 |
41 | // Create a new element to represent the focused path
42 | parts := strings.Split(path, "/")
43 | focused := etree.NewElement(parts[0])
44 |
45 | // Get the space of the found element
46 | space := foundElements[0].Space
47 | depth := len(parts)
48 | if depth > 1 {
49 | // Create child elements for each part of the path
50 | parts = parts[:depth-1]
51 | current := focused
52 | for i := 1; i < len(parts); i++ {
53 | newElem := current.CreateElement(parts[i])
54 | // Find the element in the document and copy its attributes
55 | element := doc.FindElement(strings.Join(parts[:i+1], "/"))
56 | space = element.Space
57 | if space != "" {
58 | newElem.Space = space
59 | }
60 | if element != nil {
61 | for _, attr := range element.Attr {
62 | newElem.CreateAttr(attr.Key, attr.Value)
63 | }
64 | }
65 | current = newElem
66 | }
67 | // Add all found elements as children of the last child element
68 | for _, foundElement := range foundElements {
69 | current.AddChild(foundElement.Copy())
70 | }
71 | } else {
72 | // If the path is just the root element, return the root element of the document
73 | focused = doc.Root()
74 | }
75 | if space != "" {
76 | // Set the space of the focused element to "att"
77 | focused.Space = "att"
78 | }
79 |
80 | return focused
81 | }
82 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | # Makefile for opnsense module
2 | # run make help for options
3 |
4 | # Variables
5 | GO=go
6 | BUILD_DIR=build
7 | BINARY_NAME=opnsense
8 |
9 | # Phony Targets
10 | .PHONY: all clean build test install fmt cross-build help
11 |
12 | # Default Target
13 | all: clean build
14 |
15 | # Display Help
16 | help:
17 | @echo "Makefile commands for opnsense:"
18 | @echo "all Build the binary"
19 | @echo "clean Remove build artifacts"
20 | @echo "build Build the binary"
21 | @echo "test Run tests"
22 | @echo "install Install the binary"
23 | @echo "fmt Format the source code"
24 | @echo "cross-build Cross-compile for different platforms"
25 |
26 | # Clean up
27 | clean:
28 | @echo "Cleaning..."
29 | ifeq ($(OS),Windows_NT)
30 | @if exist $(BUILD_DIR) rmdir /s /q $(BUILD_DIR)
31 | else
32 | -@rm -rf $(BUILD_DIR)
33 | endif
34 | @echo "Cleaning done"
35 |
36 | # Download Dependencies
37 | deps:
38 | @echo "Downloading dependencies..."
39 | @$(GO) mod tidy
40 |
41 | # Build the binary
42 | build: deps
43 | @echo "Building..."
44 | ifeq ($(OS),Windows_NT)
45 | @if not exist $(BUILD_DIR) mkdir $(BUILD_DIR)
46 | @$(GO) build -o $(BUILD_DIR)/$(BINARY_NAME).exe .
47 | else
48 | @mkdir -p $(BUILD_DIR)
49 | @$(GO) build -o $(BUILD_DIR)/$(BINARY_NAME) .
50 | endif
51 |
52 | # Run tests
53 | test: deps
54 | @echo "Testing..."
55 | @$(GO) test -v ./...
56 |
57 | # Install the binary
58 | install:
59 | @echo "Installing..."
60 | ifeq ($(OS),Windows_NT)
61 | @$(GO) install .
62 | else
63 | @sudo $(GO) install .
64 | endif
65 |
66 | # Format the code
67 | fmt:
68 | @echo "Formatting..."
69 | @$(GO) fmt ./...
70 |
71 | # Cross-compile for different platforms
72 | cross-build: deps
73 | @echo "Cross-building..."
74 | ifeq ($(OS),Windows_NT)
75 | @set GOOS=linux&& set GOARCH=amd64&& $(GO) build -o $(BUILD_DIR)/$(BINARY_NAME)-lnx .
76 | @echo "Linux binary done"
77 | @set GOOS=windows&& set GOARCH=amd64&& $(GO) build -o $(BUILD_DIR)/$(BINARY_NAME).exe .
78 | @echo "Windows binary done"
79 | @set GOOS=darwin&& set GOARCH=amd64&& $(GO) build -o $(BUILD_DIR)/$(BINARY_NAME)-mac .
80 | @echo "MacOS binary done"
81 | @set GOOS=freebsd&& set GOARCH=amd64&& $(GO) build -o $(BUILD_DIR)/$(BINARY_NAME)-bsd .
82 | @echo "FreeBSD binary done"
83 | else
84 | @GOOS=linux GOARCH=amd64 $(GO) build -o $(BUILD_DIR)/$(BINARY_NAME)-lnx .
85 | @echo "Linux binary done"
86 | @GOOS=windows GOARCH=amd64 $(GO) build -o $(BUILD_DIR)/$(BINARY_NAME).exe .
87 | @echo "Windows binary done"
88 | @GOOS=darwin GOARCH=amd64 $(GO) build -o $(BUILD_DIR)/$(BINARY_NAME)-mac .
89 | @echo "MacOS binary done"
90 | @GOOS=freebsd GOARCH=amd64 $(GO) build -o $(BUILD_DIR)/$(BINARY_NAME)-bsd .
91 | @echo "FreeBSD binary done"
92 | endif
--------------------------------------------------------------------------------
/internal/PatchXML.go:
--------------------------------------------------------------------------------
1 | /*
2 | Copyright © 2023 Miha miha.kralj@outlook.com
3 |
4 | Licensed under the Apache License, Version 2.0 (the "License");
5 | you may not use this file except in compliance with the License.
6 | You may obtain a copy of the License at
7 |
8 | http://www.apache.org/licenses/LICENSE-2.0
9 |
10 | Unless required by applicable law or agreed to in writing, software
11 | distributed under the License is distributed on an "AS IS" BASIS,
12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | See the License for the specific language governing permissions and
14 | limitations under the License.
15 | */
16 | package internal
17 |
18 | import (
19 | "strings"
20 |
21 | "github.com/beevik/etree"
22 | )
23 |
24 | // PatchElements processes patch instructions in an XML element to modify a target XML document.
25 | func PatchElements(patchEl *etree.Element, newDoc *etree.Document) {
26 | EnumerateListElements(newDoc.Root())
27 | // Check for nil inputs and log if detected
28 | if patchEl == nil {
29 | return
30 | }
31 |
32 | // Process the patch element based on its namespace (either "add" or "del")
33 | if patchEl.Space == "add" || patchEl.Space == "del" {
34 | InjectElementAtPath(patchEl, newDoc)
35 | }
36 |
37 | // Process patch attributes in the same way
38 | for _, attr := range patchEl.Attr {
39 | if attr.Space == "add" || attr.Space == "del" {
40 | InjectElementAtPath(patchEl, newDoc)
41 | }
42 | }
43 |
44 | // Recursively handle child elements
45 | for _, child := range patchEl.ChildElements() {
46 | PatchElements(child, newDoc)
47 | }
48 | }
49 |
50 | // InjectElementAtPath either adds or deletes elements and attributes from the target document
51 | // based on the patch instruction.
52 | func InjectElementAtPath(el *etree.Element, doc *etree.Document) {
53 | // Try to find the element in the target document
54 | match := doc.FindElement(el.GetPath())
55 |
56 | // If no matching element found, create necessary path
57 | if match == nil {
58 | current := doc.Root()
59 | parts := strings.Split(el.GetPath(), "/")
60 |
61 | // Traverse or create the path in the target document
62 | for i := 2; i < len(parts); i++ {
63 | match = current.SelectElement(parts[i])
64 | if match == nil {
65 | match = current.CreateElement(parts[i])
66 | }
67 | current = match
68 | }
69 |
70 | // If adding, set the text content of the element
71 | if el.Space == "add" {
72 | match.SetText(el.Text())
73 | }
74 | } else {
75 | // If element is found and is being added, set its text content
76 | if el.Space == "add" {
77 | match.SetText(el.Text())
78 | } else if el.Space == "del" {
79 | // If element is being deleted, remove it from its parent
80 | match.Parent().RemoveChild(match)
81 | }
82 | }
83 |
84 | // Apply attribute patches
85 | for _, attr := range el.Attr {
86 | if attr.Space == "add" {
87 | match.CreateAttr(attr.Key, attr.Value)
88 | } else if attr.Space == "del" {
89 | match.RemoveAttr(attr.Key)
90 | }
91 | }
92 | }
93 |
--------------------------------------------------------------------------------
/internal/getSSHClient.go:
--------------------------------------------------------------------------------
1 | /*
2 | Copyright © 2023 Miha miha.kralj@outlook.com
3 |
4 | Licensed under the Apache License, Version 2.0 (the "License");
5 | you may not use this file except in compliance with the License.
6 | You may obtain a copy of the License at
7 |
8 | http://www.apache.org/licenses/LICENSE-2.0
9 |
10 | Unless required by applicable law or agreed to in writing, software
11 | distributed under the License is distributed on an "AS IS" BASIS,
12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | See the License for the specific language governing permissions and
14 | limitations under the License.
15 | */
16 | package internal
17 |
18 | import (
19 | "fmt"
20 | "net"
21 | "strings"
22 | "syscall"
23 |
24 | "golang.org/x/crypto/ssh"
25 | "golang.org/x/term"
26 | )
27 |
28 | type SSHClient struct {
29 | Session *ssh.Session
30 | Client *ssh.Client
31 | }
32 |
33 | var (
34 | SshClient *SSHClient
35 | SSHTarget string
36 | config *ssh.ClientConfig
37 | )
38 |
39 | func getSSHClient(target string) (*SSHClient, error) {
40 | var user, host, port string
41 |
42 | userhost, port, err := net.SplitHostPort(target)
43 | if err != nil {
44 | userhost = target
45 | }
46 | if port == "" {
47 | port = "22"
48 | }
49 | split := strings.SplitN(userhost, "@", 2)
50 | if len(split) == 2 {
51 | user = split[0]
52 | host = split[1]
53 | } else {
54 | user = "admin"
55 | host = userhost
56 | }
57 |
58 | var connection *ssh.Client
59 |
60 | if config == nil {
61 | config = &ssh.ClientConfig{
62 | User: user,
63 | Auth: []ssh.AuthMethod{},
64 | HostKeyCallback: ssh.InsecureIgnoreHostKey(),
65 | }
66 |
67 | //try to get sshAgent
68 | sshAgent, err := GetSSHAgent()
69 | if err == nil {
70 | config.Auth = append(config.Auth, sshAgent)
71 | if len(config.Auth) > 0 {
72 | connection, err = ssh.Dial("tcp", host+":"+port, config)
73 | if err == nil {
74 | return &SSHClient{Client: connection}, nil
75 | }
76 | }
77 | }
78 | fmt.Println("No authorized SSH keys found in local ssh agent, reverting to password-based access.\nTo enable seamless access, use the 'ssh-add' to add the authorized key for user", user)
79 | fmt.Printf("Enter password for %s@%s: ", user, host)
80 | bytePassword, err := term.ReadPassword(int(syscall.Stdin))
81 | if err != nil {
82 | Log(5, "failed to read password: %v", err)
83 | }
84 | password := string(bytePassword)
85 | config.Auth = []ssh.AuthMethod{ssh.Password(password)}
86 | connection, err = ssh.Dial("tcp", host+":"+port, config)
87 | if err != nil {
88 | fmt.Println()
89 | Log(1, "%v", err)
90 | } else {
91 | fmt.Println()
92 | return &SSHClient{Client: connection}, nil
93 | }
94 | }
95 | connection, err = ssh.Dial("tcp", host+":"+port, config)
96 | if err != nil {
97 | Log(1, "%v", err)
98 | }
99 | SshClient = &SSHClient{Client: connection}
100 |
101 | return SshClient, nil
102 | }
103 |
--------------------------------------------------------------------------------
/cmd/load.go:
--------------------------------------------------------------------------------
1 | /*
2 | Copyright © 2023 Miha miha.kralj@outlook.com
3 |
4 | Licensed under the Apache License, Version 2.0 (the "License");
5 | you may not use this file except in compliance with the License.
6 | You may obtain a copy of the License at
7 |
8 | http://www.apache.org/licenses/LICENSE-2.0
9 |
10 | Unless required by applicable law or agreed to in writing, software
11 | distributed under the License is distributed on an "AS IS" BASIS,
12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | See the License for the specific language governing permissions and
14 | limitations under the License.
15 | */
16 | package cmd
17 |
18 | import (
19 | "fmt"
20 | "regexp"
21 | "strings"
22 |
23 | "github.com/mihakralj/opnsense/internal"
24 | "github.com/spf13/cobra"
25 | )
26 |
27 | // loadCmd represents the load command
28 | var restoreCmd = &cobra.Command{
29 | Use: "restore []",
30 | Aliases: []string{"load"},
31 | Short: `Restore active configuration from a backup XML file`,
32 | Long: `The 'restore' command restores the active configuration of the OPNsense firewall system using a backup file from the '/conf/backup' directory. When no filename is provided, the system defaults to using the most recent backup. The command also has alias 'load'.`,
33 | Example: ` opnsense restore Restore from the most recent backup in '/conf/backup'
34 | opnsense load config-123.xml Restore from the specified backup file in '/conf/backup'`,
35 |
36 | Run: func(cmd *cobra.Command, args []string) {
37 |
38 | var filename string
39 | if len(args) < 1 {
40 | bash := "ls -t /conf/backup/*.xml | head -n 1"
41 | filename = strings.TrimSpace(internal.ExecuteCmd(bash, host))
42 | if filename == "" {
43 | internal.Log(1, "No backup files found in /conf/backup.")
44 | return
45 | }
46 | } else {
47 | filename = args[0]
48 |
49 | }
50 | filename = strings.TrimPrefix(filename, "/conf/backup/")
51 | filename = strings.TrimPrefix(filename, "conf/backup/")
52 | if !strings.HasSuffix(filename, ".xml") {
53 | filename += ".xml"
54 | }
55 | validFilenamePattern := "^[a-zA-Z0-9_.-]+$"
56 | match, err := regexp.MatchString(validFilenamePattern, filename)
57 | if err != nil || !match {
58 | internal.Log(1, "%s is not a valid filename.", filename)
59 | return
60 | }
61 | filename = "/conf/backup/"+filename
62 | internal.Checkos()
63 |
64 | configdoc := internal.LoadXMLFile(configfile, host, false)
65 | saveddoc := internal.LoadXMLFile(filename, host, false)
66 |
67 | depthset := cmd.LocalFlags().Lookup("depth")
68 | if depthset != nil && !depthset.Changed {
69 | internal.FullDepth()
70 | }
71 |
72 | deltadoc := internal.DiffXML(configdoc, saveddoc, false)
73 |
74 | internal.PrintDocument(deltadoc, "opnsense")
75 |
76 | internal.Log(2, "Stage %s into %s",filename, stagingfile)
77 | internal.SaveXMLFile(stagingfile, saveddoc, host, true)
78 | fmt.Printf("The file %s has been staged into %s.\n", filename, stagingfile)
79 | },
80 | }
81 |
82 | func init() {
83 | restoreCmd.Flags().IntVarP(&depth, "depth", "d", 1, "Specifies number of depth levels of returned tree (default: 1)")
84 | rootCmd.AddCommand(restoreCmd)
85 |
86 | }
87 |
--------------------------------------------------------------------------------
/cmd/export.go:
--------------------------------------------------------------------------------
1 | /*
2 | Copyright © 2023 Miha miha.kralj@outlook.com
3 |
4 | Licensed under the Apache License, Version 2.0 (the "License");
5 | you may not use this file except in compliance with the License.
6 | You may obtain a copy of the License at
7 |
8 | http://www.apache.org/licenses/LICENSE-2.0
9 |
10 | Unless required by applicable law or agreed to in writing, software
11 | distributed under the License is distributed on an "AS IS" BASIS,
12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | See the License for the specific language governing permissions and
14 | limitations under the License.
15 | */
16 | package cmd
17 |
18 | import (
19 | "fmt"
20 | "strings"
21 |
22 | "github.com/mihakralj/opnsense/internal"
23 | "github.com/spf13/cobra"
24 | )
25 |
26 | // compareCmd represents the compare command
27 | var exportCmd = &cobra.Command{
28 | Use: "export [] []",
29 | Short: `Export differences between two XML configuration files`,
30 | Long: `The 'export' command generates a patch XML between two configuration files for the OPNsense firewall system. When only one filename is provided, it exports differences between that file and the current 'config.xml'. When no filenames are provided, it exports the patch from current 'config.xml' to 'staging.xml'`,
31 | Example: ` opnsense export b1.xml b2.xml Exports XML patch from 'b1.xml' to 'b2.xml'
32 | opnsense export backup.xml Exports XML patch from 'backup.xml' to 'config.xml'
33 | opnsense export Exports XML patch from 'config.xml' to 'staging.xml'`,
34 |
35 | Run: func(cmd *cobra.Command, args []string) {
36 | internal.SetFlags(verbose, force, host, configfile, nocolor, depth, xmlFlag, yamlFlag, jsonFlag)
37 | var oldconfig, newconfig, path string
38 |
39 | switch len(args) {
40 | case 3:
41 | oldconfig = "/conf/backup/" + args[0]
42 | newconfig = "/conf/backup/" + args[1]
43 | path = strings.Trim(args[2], "/")
44 | case 2:
45 | if strings.HasSuffix(args[1], ".xml") {
46 | oldconfig = "/conf/backup/" + args[0]
47 | newconfig = "/conf/backup/" + args[1]
48 | path = "opnsense"
49 | } else {
50 | oldconfig = "/conf/backup/" + args[0]
51 | newconfig = "/conf/config.xml"
52 | path = strings.Trim(args[1], "/")
53 | }
54 | case 1:
55 | if strings.HasSuffix(args[0], ".xml") {
56 | newconfig = "/conf/config.xml"
57 | oldconfig = "/conf/backup/" + args[0]
58 | path = "opnsense"
59 | } else {
60 | newconfig = "/conf/staging.xml"
61 | oldconfig = "/conf/config.xml"
62 | path = strings.Trim(args[0], "/")
63 | }
64 | default:
65 | oldconfig = "/conf/config.xml"
66 | newconfig = "/conf/staging.xml"
67 | path = "opnsense"
68 | }
69 | parts := strings.Split(path, "/")
70 | if parts[0] != "opnsense" {
71 | path = "opnsense/" + path
72 | }
73 |
74 | internal.Checkos()
75 | olddoc := internal.LoadXMLFile(oldconfig, host, false)
76 | newdoc := internal.LoadXMLFile(newconfig, host, true)
77 | if newdoc == nil {
78 | newdoc = olddoc.Copy()
79 | }
80 |
81 | deltadoc := internal.DiffXML(olddoc, newdoc, false)
82 | internal.RemoveChgSpace(deltadoc.Root())
83 |
84 | output := internal.ConfigToXML(deltadoc, path)
85 | fmt.Print(output)
86 | },
87 | }
88 |
89 | func init() {
90 |
91 | rootCmd.AddCommand(exportCmd)
92 | }
93 |
--------------------------------------------------------------------------------
/cmd/import.go:
--------------------------------------------------------------------------------
1 | /*
2 | Copyright © 2023 Miha miha.kralj@outlook.com
3 |
4 | Licensed under the Apache License, Version 2.0 (the "License");
5 | you may not use this file except in compliance with the License.
6 | You may obtain a copy of the License at
7 |
8 | http://www.apache.org/licenses/LICENSE-2.0
9 |
10 | Unless required by applicable law or agreed to in writing, software
11 | distributed under the License is distributed on an "AS IS" BASIS,
12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | See the License for the specific language governing permissions and
14 | limitations under the License.
15 | */
16 | package cmd
17 |
18 | import (
19 | "fmt"
20 | "os"
21 |
22 | "github.com/beevik/etree"
23 | "github.com/mihakralj/opnsense/internal"
24 | "github.com/spf13/cobra"
25 | )
26 |
27 | // compareCmd represents the compare command
28 | var importCmd = &cobra.Command{
29 | Use: "import [patch.xml]",
30 | Short: `Import XML patch and stage it for configuraiton change`,
31 | Long: `The 'import' command allows bulk import of configuration changes by injecting an XML patch file that specifies what to add, or delete in the current configuration. Patch file is in the standard XML format generated by the 'export' command, using namespace tags indicating the type of change (e.g., add:, del:).
32 |
33 | Once the patch is imported, it is added to currently staged changes in 'staging.xml'. You can review staged changes using 'opnsense compare --compact' and apply them using 'opnsense commit' when ready.`,
34 |
35 | Run: func(cmd *cobra.Command, args []string) {
36 | if !cmd.Flag("depth").Changed {
37 | depth = 5
38 | }
39 | internal.SetFlags(verbose, force, host, configfile, nocolor, depth, xmlFlag, yamlFlag, jsonFlag)
40 |
41 | patchdoc := etree.NewDocument()
42 |
43 | if len(args) > 0 {
44 | //check parameters
45 | if len(args) > 0 {
46 | importfilename := args[0]
47 | if _, err := os.Stat(importfilename); os.IsNotExist(err) {
48 | internal.Log(1, "import file %s does not exist\n", importfilename)
49 | }
50 | // Read file contents into a string
51 | fileContents, err := os.ReadFile(importfilename)
52 | if err != nil {
53 | internal.Log(1, "failed to read file %s: %v\n", importfilename, err)
54 | return
55 | }
56 | fileString := string(fileContents)
57 |
58 | err = patchdoc.ReadFromString(fileString)
59 | if err != nil {
60 | internal.Log(1, "%s is not an XML file", importfilename)
61 | }
62 | }
63 | } else {
64 | internal.Log(1, "No patch XML file provided")
65 | }
66 |
67 | internal.Checkos()
68 | configdoc := internal.LoadXMLFile(configfile, host, false)
69 |
70 | stagingdoc := internal.LoadXMLFile(stagingfile, host, true)
71 | if stagingdoc == nil {
72 | stagingdoc = configdoc.Copy()
73 | }
74 |
75 | internal.PatchElements(patchdoc.Root(), stagingdoc)
76 | deltadoc := internal.DiffXML(configdoc, stagingdoc, false)
77 |
78 | fmt.Println("Preview of patch scheduled for imported into staging.xml:")
79 |
80 | internal.PrintDocument(deltadoc, "opnsense")
81 |
82 | internal.Log(2, "importing patch into staging.xml")
83 | internal.SaveXMLFile(stagingfile, stagingdoc, host, true)
84 | fmt.Println("\nModifications imported into staging.xml")
85 | },
86 | }
87 |
88 | func init() {
89 | importCmd.Flags().IntVarP(&depth, "depth", "d", 5, "Specifies number of depth levels of returned tree (default: 5)")
90 | rootCmd.AddCommand(importCmd)
91 | }
92 |
--------------------------------------------------------------------------------
/cmd/compare.go:
--------------------------------------------------------------------------------
1 | /*
2 | Copyright © 2023 Miha miha.kralj@outlook.com
3 |
4 | Licensed under the Apache License, Version 2.0 (the "License");
5 | you may not use this file except in compliance with the License.
6 | You may obtain a copy of the License at
7 |
8 | http://www.apache.org/licenses/LICENSE-2.0
9 |
10 | Unless required by applicable law or agreed to in writing, software
11 | distributed under the License is distributed on an "AS IS" BASIS,
12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | See the License for the specific language governing permissions and
14 | limitations under the License.
15 | */
16 | package cmd
17 |
18 | import (
19 | "strings"
20 |
21 | "github.com/mihakralj/opnsense/internal"
22 | "github.com/spf13/cobra"
23 | )
24 |
25 | var compact bool
26 |
27 | // compareCmd represents the compare command
28 | var compareCmd = &cobra.Command{
29 | Use: "compare [] []",
30 | Short: `Compare differences between two XML configuration files`,
31 | Long: `The 'compare' command identifies differences between two XML configuration files for the OPNsense firewall system. When only one filename is provided, it shows the differences between that file and the current 'config.xml'. When no filenames are provided, it compares the current 'config.xml' with 'staging.xml', akin to the 'show' command.`,
32 | Example: ` opnsense compare b1.xml b2.xml Compare differences from 'b1.xml' to 'b2.xml'
33 | opnsense compare backup.xml Compare differences from 'backup.xml' to 'config.xml'
34 | opnsense compare Compare differences from 'config.xml' to 'staging.xml'`,
35 |
36 | Run: func(cmd *cobra.Command, args []string) {
37 | internal.SetFlags(verbose, force, host, configfile, nocolor, depth, xmlFlag, yamlFlag, jsonFlag)
38 | var oldconfig, newconfig, path string
39 |
40 | switch len(args) {
41 | case 3:
42 | oldconfig = "/conf/backup/" + args[0]
43 | newconfig = "/conf/backup/" + args[1]
44 | path = strings.Trim(args[2], "/")
45 | case 2:
46 | if strings.HasSuffix(args[1], ".xml") {
47 | oldconfig = "/conf/backup/" + args[0]
48 | newconfig = "/conf/backup/" + args[1]
49 | path = "opnsense"
50 | } else {
51 | oldconfig = "/conf/backup/" + args[0]
52 | newconfig = "/conf/config.xml"
53 | path = strings.Trim(args[1], "/")
54 | }
55 | case 1:
56 | if strings.HasSuffix(args[0], ".xml") {
57 | newconfig = "/conf/config.xml"
58 | oldconfig = "/conf/backup/" + args[0]
59 | path = "opnsense"
60 | } else {
61 | newconfig = "/conf/staging.xml"
62 | oldconfig = "/conf/config.xml"
63 | path = strings.Trim(args[0], "/")
64 | }
65 | default:
66 | oldconfig = "/conf/config.xml"
67 | newconfig = "/conf/staging.xml"
68 | path = "opnsense"
69 | }
70 | parts := strings.Split(path, "/")
71 | if parts[0] != "opnsense" {
72 | path = "opnsense/" + path
73 | }
74 |
75 | internal.Checkos()
76 | olddoc := internal.LoadXMLFile(oldconfig, host, false)
77 | newdoc := internal.LoadXMLFile(newconfig, host, true)
78 | if newdoc == nil {
79 | newdoc = olddoc
80 | }
81 |
82 | deltadoc := internal.DiffXML(olddoc, newdoc, !compact)
83 | internal.PrintDocument(deltadoc, path)
84 |
85 | },
86 | }
87 |
88 | func init() {
89 | compareCmd.Flags().IntVarP(&depth, "depth", "d", 1, "Specifies number of depth levels of returned tree (default: 1)")
90 | compareCmd.Flags().BoolVarP(&compact, "compact", "c", false, "Show only the net changes between configurations")
91 | rootCmd.AddCommand(compareCmd)
92 | }
93 |
--------------------------------------------------------------------------------
/cmd/backup.go:
--------------------------------------------------------------------------------
1 | /*
2 | Copyright © 2023 Miha miha.kralj@outlook.com
3 |
4 | Licensed under the Apache License, Version 2.0 (the "License");
5 | you may not use this file except in compliance with the License.
6 | You may obtain a copy of the License at
7 |
8 | http://www.apache.org/licenses/LICENSE-2.0
9 |
10 | Unless required by applicable law or agreed to in writing, software
11 | distributed under the License is distributed on an "AS IS" BASIS,
12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | See the License for the specific language governing permissions and
14 | limitations under the License.
15 | */
16 | package cmd
17 |
18 | import (
19 | "fmt"
20 | "strings"
21 |
22 | "github.com/beevik/etree"
23 | "github.com/mihakralj/opnsense/internal"
24 | "github.com/spf13/cobra"
25 | )
26 |
27 | // backupCmd represents the backup command
28 | var backupCmd = &cobra.Command{
29 | Use: "backup",
30 | Short: `List available backup configurations in '/conf/backup' directory`,
31 | Long: `The 'backup' command provides functionalities for managing and viewing backup XML configurations within your OPNsense firewall system. You can list all backup configurations or get details about a specific one.`,
32 | Example: ` show backup Lists all backup XML configurations.
33 | show backup Show details of a specific backup XML configuration`,
34 | Run: func(cmd *cobra.Command, args []string) {
35 |
36 | backupdir := "/conf/backup/"
37 | path := "backups"
38 | filename := ""
39 |
40 | if len(args) > 0 {
41 | filename = strings.TrimPrefix(args[0], "/")
42 | if !strings.HasSuffix(filename, ".xml") {
43 | filename = filename + ".xml"
44 | }
45 | path = path + "/" + filename
46 | }
47 |
48 | internal.Checkos()
49 | rootdoc := etree.NewDocument()
50 |
51 | bash := fmt.Sprintf(`echo -n '' && echo -n '' | sed 's/##/"/g'`, backupdir)
52 | bash = bash + fmt.Sprintf(` && find %s -type f -exec sh -c 'echo $(stat -f "%%m" "$1") $(basename "$1") $(stat -f "%%z" "$1") $(md5sum "$1")' sh {} \; | sort -nr -k1`, backupdir)
53 | bash = bash + `| awk '{ date = strftime("%Y-%m-%dT%H:%M:%S", $1); delta = systime() - $1; days = int(delta / 86400); hours = int((delta % 86400) / 3600); minutes = int((delta % 3600) / 60); seconds = int(delta % 60); age = days "d " hours "h " minutes "m " seconds "s"; print " <" $2 " age=\"" age "\">" date "" $3 "" $4 "" $2 ">"; } END { print ""; }'`
54 |
55 | backups := internal.ExecuteCmd(bash, host)
56 | err := rootdoc.ReadFromString(backups)
57 | if err != nil {
58 | internal.Log(1, "format is not XML")
59 | }
60 | if len(args) > 0 {
61 | internal.FullDepth()
62 |
63 | configdoc := internal.LoadXMLFile(configfile, host, false)
64 | backupdoc := internal.LoadXMLFile(backupdir+filename, host, false)
65 |
66 | deltadoc := internal.DiffXML(backupdoc, configdoc, false)
67 |
68 |
69 | // append all differences to the rootdoc
70 | diffEl := rootdoc.FindElement(path).CreateElement("diff")
71 | for _, child := range deltadoc.Root().ChildElements() {
72 | diffEl.AddChild(child.Copy())
73 | }
74 |
75 | }
76 | fmt.Println()
77 | internal.PrintDocument(rootdoc, path)
78 |
79 | },
80 | }
81 |
82 | func init() {
83 | backupCmd.Flags().IntVarP(&depth, "depth", "d", 1, "Specifies number of depth levels of returned tree (default: 1)")
84 | rootCmd.AddCommand(backupCmd)
85 | }
86 |
--------------------------------------------------------------------------------
/doc/scope.md:
--------------------------------------------------------------------------------
1 | # opnsense-cli features
2 |
3 | ### Scope and intent
4 |
5 | There is a gap between using OPNsense web GUI that offers fail-safe (but limited) configuration capabilities and using FreeBSD command terminal that offers direct access to all functionality of FreeBSD and OPNsense but exposes a great risk of messing things up for anyone that is not well versed in shell commands.
6 |
7 | __opnsense-cli__ utility bridges this gap by providing command-line access to local or remote OPNsense firewall. For remote access, it requires `ssh` service to be enabled, as it uses ssh to communicate with the firewall. Every action of __opnsense-cli__ is translated to a shell command that is then executed on OPNsense.
8 |
9 | ### Features and Benefits
10 | - **Versatility**: Can operate both locally and remotely (via ssh),and is suitable for various deployment scenarios.
11 | - **Transparency and Control**: All opnsense-cli Commands are translated to shell scripts (not API calls), with interactive confirmation for critical changes (bypassable with the --force flag).
12 | - **Cross-Platform Support**: Works on macOS, Windows, Linux, and OpenBSD.
13 | - **Streamlined Operations**: Facilitates repeatable configurations, troubleshooting and complex automations.
14 |
15 | ### Mechanics
16 |
17 | __opnsense-cli__ is focusing on `config.xml` manipulation of OPNsense. All configuration settings are stored in `config.xml` file and OPNSense web GUI actions primarily change data in config XML elements. To protect the integrity of configuration, __opnsense-cli__ is not changing `config.xml` directly - all changes are staged in a separate `staging.xml` file. Configuration elements can be added, removed, modified, discarded and imported - all changes will impact only `staging.xml` until 'commit' command is issued. That's when __opnsense-cli__ will create a backup of `config.xml` and replace it with content from `staging.xml`.
18 |
19 | __opnsense-cli__ is also providing commands to manage backup copies in `/conf/backup` directory of OPNsense. It can show all available backups, display details of a specific backup file (including XML diffs between backup file and config.xml), save, restore, load and delete backup files. It can trim number of backup files based on age and desired count of files in the directory.
20 |
21 | __opnsense-cli__ also offers (very basic) system management commands. `sysinfo` will display core information about OPNsense instance, `run` command will list and execute all commands that are available through __configctl__ process on OPNsense.
22 |
23 | ### using ssh identity with __opnsense-cli__
24 |
25 | When connecting remotely to OPNsense using ssh, __opnsense-cli__ will try to use private key stored in `ssh-agent` to authenticate. Only when no identities are present or match the public key on OPNsense server, the fallback to *password* will be initiated. As __opnsense-cli__ stores no data locally, the password request will pop-up every time when __opnsense-cli__ initiates the ssh call. Very annoying.
26 |
27 | To use ssh identity, both server and client need to be configured with the access key. OPNsense server requires the public key in the format `ssh-rsa AAAAB3NC7we...wIfhtcSt==` and is assigned to a specific user (under System/Access/Users) in the field 'Authorized keys'.
28 |
29 | Client needs to support `ssh-agent` and accepts the private key in the format:
30 | ```
31 | -----BEGIN RSA PRIVATE KEY-----
32 | [BASE64 ENCODED DATA]
33 | -----END RSA PRIVATE KEY-----
34 | ```
35 | The command to add the private key to `ssh-agent`:
36 | ```
37 | eval "$(ssh-agent -s)" # Start the ssh-agent in the background
38 | ssh-add id_rsa # Add your SSH private key to the ssh-agent
39 | ```
40 |
41 |
--------------------------------------------------------------------------------
/cmd/root.go:
--------------------------------------------------------------------------------
1 | /*
2 | Copyright © 2023 Miha miha.kralj@outlook.com
3 |
4 | Licensed under the Apache License, Version 2.0 (the "License");
5 | you may not use this file except in compliance with the License.
6 | You may obtain a copy of the License at
7 |
8 | http://www.apache.org/licenses/LICENSE-2.0
9 |
10 | Unless required by applicable law or agreed to in writing, software
11 | distributed under the License is distributed on an "AS IS" BASIS,
12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | See the License for the specific language governing permissions and
14 | limitations under the License.
15 | */
16 | package cmd
17 |
18 | import (
19 | "fmt"
20 | "os"
21 |
22 | "github.com/mihakralj/opnsense/internal"
23 | "github.com/spf13/cobra"
24 | )
25 |
26 | var (
27 | Version string = "0.14.0"
28 | verbose int
29 | force bool
30 | host string
31 | configfile string
32 | stagingfile string
33 | nocolor bool
34 | depth int = 1
35 | xmlFlag bool
36 | yamlFlag bool
37 | jsonFlag bool
38 | ver_flag bool
39 | )
40 |
41 | func init() {
42 | rootCmd.PersistentFlags().StringVarP(&host, "target", "t", "", "Specify target host (user@hostname[:port])")
43 | rootCmd.PersistentFlags().IntVarP(&verbose, "verbose", "v", 1, "Set verbosity level (range: 1-5, default: 1)")
44 | rootCmd.PersistentFlags().BoolVarP(&nocolor, "no-color", "n", false, "Disable ANSI color output")
45 | rootCmd.PersistentFlags().BoolVarP(&xmlFlag, "xml", "x", false, "Output results in XML format")
46 | rootCmd.PersistentFlags().BoolVarP(&jsonFlag, "json", "j", false, "Output results in JSON format")
47 | rootCmd.PersistentFlags().BoolVarP(&yamlFlag, "yaml", "y", false, "Output results in YAML format")
48 | rootCmd.PersistentFlags().BoolVarP(&force, "force", "f", false, "Bypass checks and prompts (force action)")
49 | rootCmd.Flags().BoolVarP(&ver_flag, "version", "V", false, "Display the version of opnsense")
50 | //rootCmd.SetHelpCommand(&cobra.Command{Hidden: true})
51 |
52 | cobra.OnInitialize(func() {
53 | configfile = "/conf/config.xml"
54 | stagingfile = "/conf/staging.xml"
55 | internal.SetFlags(verbose, force, host, configfile, nocolor, depth, xmlFlag, yamlFlag, jsonFlag)
56 | //other initializations
57 | })
58 | }
59 |
60 | var rootCmd = &cobra.Command{
61 | Use: "opnsense [command]",
62 | Short: "CLI tool for managing and monitoring OPNsense firewall systems",
63 | Long: `The 'opnsense' command-line utility provides non-GUI administration of OPNsense firewall systems. It can be run locally on the firewall or remotely via an SSH tunnel.
64 |
65 | To streamline remote operations, add your private key to the SSH agent using 'ssh-add' and the matching public key to the admin account on OPNsense.`,
66 | Example: ` opnsense help [COMMAND] Display help for specific commands
67 | opnsense show interfaces/wan Show details for the interfaces/wan node in config.xml
68 | opnsense -t admin@192.168.1.1 sysinfo Retrieve system information from a remote firewall`,
69 |
70 | PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
71 | return nil
72 | },
73 | Run: func(cmd *cobra.Command, args []string) {
74 | if ver_flag {
75 | fmt.Println("opnsense-CLI version", Version)
76 | os.Exit(0)
77 | }
78 |
79 | if len(args) == 0 {
80 | cmd.Help()
81 | os.Exit(0)
82 | }
83 | },
84 | }
85 |
86 | func Execute() {
87 | //rootCmd.CompletionOptions.DisableDefaultCmd = true
88 | //rootCmd.CompletionOptions.DisableNoDescFlag = true
89 | if err := rootCmd.Execute(); err != nil {
90 | fmt.Fprintf(os.Stderr, "Whoops. There was an error while executing CLI '%s'", err)
91 | os.Exit(1)
92 | }
93 | }
94 |
--------------------------------------------------------------------------------
/.github/workflows/old_bsd.yml:
--------------------------------------------------------------------------------
1 | name: MacBSD
2 |
3 | on:
4 | workflow_dispatch:
5 |
6 | jobs:
7 | macbsd:
8 | runs-on: macos-12
9 | name: A job to run test in FreeBSD
10 | steps:
11 | - name: Check out code
12 | uses: actions/checkout@v3
13 |
14 | - name: Extract version from Makefile
15 | id: extract_version
16 | run: echo "VERSION=$(awk -F '=' '/^VERSION/ {print $2}' Makefile)" >> $GITHUB_ENV
17 | shell: bash
18 |
19 | - name: Compile in FreeBSD
20 | id: compile
21 | uses: vmactions/freebsd-vm@v0.3.1
22 | with:
23 | envs: 'VERSION=${{ env.VERSION }}'
24 | usesh: true
25 | prepare: |
26 | pkg install -y curl wget
27 | name=$(curl -s https://go.dev/dl/ | grep 'freebsd-amd64' | sed -n 's/.*href="\([^"]*\)".*/\1/p' | head -n 1 | xargs basename)
28 | wget -q "https://dl.google.com/go/$name"
29 | tar -C /usr/local -xzf "$name"
30 | run: |
31 | mkdir ~/.gopkg
32 | export GOPATH=/root/.gopkg
33 | export PATH=$PATH:/usr/local/go/bin:/root/.gopkg/bin
34 | mkdir -p /Users/runner/work/opnsense-cli/opnsense-cli/work-dir/usr/local/bin
35 | go build -gcflags='-trimpath' -ldflags="-s -w -X cmd.Version=${VERSION}" -o /Users/runner/work/opnsense-cli/opnsense-cli/work-dir/usr/local/bin/opnsense opnsense.go
36 | checksum=$(sha256 -q /Users/runner/work/opnsense-cli/opnsense-cli/work-dir/usr/local/bin/opnsense)
37 | flatsize=$(stat -f%z /Users/runner/work/opnsense-cli/opnsense-cli/work-dir/usr/local/bin/opnsense)
38 | echo "VERSION: ${VERSION}"
39 | echo "CHECKSUM: ${checksum}"
40 | echo "FLATSIZE: ${flatsize}"
41 | echo "/usr/local/bin/opnsense: ${checksum}" > /Users/runner/work/opnsense-cli/opnsense-cli/sha256checksum
42 | echo "/usr/local/bin/opnsense" > /Users/runner/work/opnsense-cli/opnsense-cli/plist
43 |
44 | echo "name: \"opnsense\";" > /Users/runner/work/opnsense-cli/opnsense-cli/manifest
45 | echo "version: \"${VERSION}\";" >> /Users/runner/work/opnsense-cli/opnsense-cli/manifest
46 | echo "origin: \"net-mgmt/opnsense\";" >> /Users/runner/work/opnsense-cli/opnsense-cli/manifest
47 | echo "comment: \"CLI to manage and monitor OPNsense firewall configuration, check status, change settings, and execute commands.\";" >> /Users/runner/work/opnsense-cli/opnsense-cli/manifest
48 | echo "maintainer: \"miha.kralj@outlook.com\";" >> /Users/runner/work/opnsense-cli/opnsense-cli/manifest
49 | echo "www: \"https://github.com/mihakralj/opnsense-cli\";" >> /Users/runner/work/opnsense-cli/opnsense-cli/manifest
50 | echo "prefix: \"/usr/local\";" >> /Users/runner/work/opnsense-cli/opnsense-cli/manifest
51 | echo "abi: \"FreeBSD:12:amd64,FreeBSD:13:amd64\";" >> /Users/runner/work/opnsense-cli/opnsense-cli/manifest echo "flatsize: ${flatsize};" >> /Users/runner/work/opnsense-cli/opnsense-cli/manifest
52 | echo "files: { \"/usr/local/bin/opnsense\": \"${checksum}\" };" >> /Users/runner/work/opnsense-cli/opnsense-cli/manifest
53 |
54 | pkg create -M /Users/runner/work/opnsense-cli/opnsense-cli/manifest -p /Users/runner/work/opnsense-cli/opnsense-cli/plist -o /Users/runner/work/opnsense-cli/opnsense-cli -f txz
55 | cat /Users/runner/work/opnsense-cli/opnsense-cli/manifest
56 | ls -l /Users/runner/work/opnsense-cli/opnsense-cli/
57 |
58 | - name: Check for pkg in the host
59 | run: ls -l /Users/runner/work/opnsense-cli/opnsense-cli/
60 | shell: bash
61 |
62 | - name: Upload all artifacts
63 | uses: actions/upload-artifact@v3
64 | with:
65 | name: opnsense.pkg
66 | path: ./opnsense-*.pkg
--------------------------------------------------------------------------------
/cmd/delete.go:
--------------------------------------------------------------------------------
1 | /*
2 | Copyright © 2023 Miha miha.kralj@outlook.com
3 |
4 | Licensed under the Apache License, Version 2.0 (the "License");
5 | you may not use this file except in compliance with the License.
6 | You may obtain a copy of the License at
7 |
8 | http://www.apache.org/licenses/LICENSE-2.0
9 |
10 | Unless required by applicable law or agreed to in writing, software
11 | distributed under the License is distributed on an "AS IS" BASIS,
12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | See the License for the specific language governing permissions and
14 | limitations under the License.
15 | */
16 | package cmd
17 |
18 | import (
19 | "fmt"
20 | "regexp"
21 | "strconv"
22 | "strings"
23 |
24 | "github.com/mihakralj/opnsense/internal"
25 | "github.com/spf13/cobra"
26 | )
27 |
28 | // deleteCmd represents the delete command
29 | var deleteCmd = &cobra.Command{
30 | Use: "delete [filename|command]",
31 | Short: `Remove a backup XML configuration from '/conf/backup'`,
32 | Long: `The 'delete' command allows for the removal of specific backup configurations from the '/conf/backup' directory in the OPNsense firewall system. You can specify either a filename or a command that sets conditions based on age or quantity.`,
33 | Example: ` opnsense delete filename.xml Delete a specific file from '/conf/backup'
34 | opnsense delete age 10 Delete all files older than 10 days in '/conf/backup'
35 | opnsense delete trim 10 Delete the oldest 10 backup files in '/conf/backup'
36 | opnsense delete keep 10 Keep the most recent 10 backup files, delete the rest`,
37 |
38 |
39 | Run: func(cmd *cobra.Command, args []string) {
40 | if len(args) < 1 {
41 | // display long help
42 | cmd.Help()
43 | return
44 | }
45 | bash := ""
46 | filename := args[0]
47 |
48 | switch filename {
49 | case "age", "keep", "trim":
50 | if len(args) < 2 {
51 | internal.Log(1, "missing required value for %s", filename)
52 | return
53 | }
54 |
55 | value := args[1]
56 | _, err := strconv.Atoi(value) //value needs to be a number
57 | if err != nil {
58 | internal.Log(1, "%s is not a valid number", value)
59 | }
60 |
61 | if filename == "age" {
62 | bash = "find /conf/backup -type f -mtime +" + value
63 | }
64 | if filename == "keep" {
65 | bash = "find /conf/backup -type f -print0 | xargs -0 ls -lt | tail -n +" + value + " | awk '{print $NF}'"
66 | }
67 | if filename == "trim" {
68 | bash = "find /conf/backup -type f -print0 | xargs -0 ls -lt | tail -n " + value + " | awk '{print $NF}'"
69 | }
70 |
71 | ret := internal.ExecuteCmd(bash+" | wc -l", host)
72 |
73 | cnt := strings.TrimSpace(ret)
74 | if cnt == "0" {
75 | fmt.Println("no files meeting criteria")
76 | return
77 | }
78 | internal.Log(2, "deleting %s files from /conf/backup", cnt)
79 | ret = internal.ExecuteCmd(bash+" | sudo xargs rm", host)
80 | if ret == "" {
81 | fmt.Printf("%s files have been deleted.\n", cnt)
82 | }
83 | default:
84 | if !strings.HasSuffix(filename, ".xml") {
85 | filename += ".xml"
86 | }
87 | validFilenamePattern := "^[a-zA-Z0-9_.-]+$"
88 | match, err := regexp.MatchString(validFilenamePattern, filename)
89 | if err != nil || !match {
90 | internal.Log(1, "%s is not a valid file in /conf/backup.", filename)
91 | }
92 | internal.Checkos()
93 | bash = `if [ -e "/conf/backup/` + filename + `" ]; then echo "ok"; else echo "missing"; fi`
94 | fileexists := internal.ExecuteCmd(bash, host)
95 |
96 | if strings.TrimSpace(fileexists) == "missing" {
97 | internal.Log(1, "file %s not found", filename)
98 | }
99 |
100 | internal.Log(2, "deleting file %s", filename)
101 |
102 | bash = "sudo chmod a+w /conf/backup/" + filename + " && sudo rm -f /conf/backup/" + filename
103 | result := internal.ExecuteCmd(bash, host)
104 |
105 | if result == "" {
106 | fmt.Printf("%s has been deleted.\n", filename)
107 | }
108 | }
109 | },
110 | }
111 |
112 | func init() {
113 | rootCmd.AddCommand(deleteCmd)
114 | }
115 |
--------------------------------------------------------------------------------
/cmd/run.go:
--------------------------------------------------------------------------------
1 | /*
2 | Copyright © 2023 Miha miha.kralj@outlook.com
3 |
4 | Licensed under the Apache License, Version 2.0 (the "License");
5 | you may not use this file except in compliance with the License.
6 | You may obtain a copy of the License at
7 |
8 | http://www.apache.org/licenses/LICENSE-2.0
9 |
10 | Unless required by applicable law or agreed to in writing, software
11 | distributed under the License is distributed on an "AS IS" BASIS,
12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | See the License for the specific language governing permissions and
14 | limitations under the License.
15 | */
16 | package cmd
17 |
18 | import (
19 | "encoding/json"
20 | "fmt"
21 | "regexp"
22 | "strings"
23 |
24 | "github.com/beevik/etree"
25 | "github.com/mihakralj/opnsense/internal"
26 | "github.com/spf13/cobra"
27 | )
28 |
29 | var runCmd = &cobra.Command{
30 | Use: "run [service] [command] [parameters]",
31 | Short: "Execute registered commands on OPNsense firewall",
32 | Long: `The 'run' command allows you to list and execute specific commands registered with the 'configctl' utility on the OPNsense firewall system. You can run various service-specific commands and other operational tasks.`,
33 | Example: ` opnsense run List all available configd services
34 | opnsense run dns List available commands for DNS service
35 | opnsense run dhcpd list leases Show DHCP leases
36 | opnsense run interface flush arp Flush ARP table
37 | opnsense run firmware reboot Initiate a system reboot`,
38 |
39 |
40 | Run: func(cmd *cobra.Command, args []string) {
41 |
42 | path := "actions"
43 |
44 | // need better parsing of tokens, so the command can be recognized better and parameters passed
45 | trimmedArg := ""
46 | if len(args) >= 1 {
47 | trimmedArg = args[0]
48 | if len(args) > 1 {
49 | trimmedArg = trimmedArg + "/" + args[1]
50 | }
51 | if len(args) > 2 {
52 | trimmedArg += "." + args[2]
53 | }
54 | //trimmedArg = strings.Trim(args[0], "/")
55 | if trimmedArg != "" {
56 | path = trimmedArg
57 | }
58 | parts := strings.Split(path, "/")
59 | if parts[0] != "actions" {
60 | path = "actions/" + path
61 | }
62 | }
63 | internal.Checkos()
64 | bash := `echo "" && for file in /usr/local/opnsense/service/conf/actions.d/actions_*.conf; do service_name=$(basename "$file" | sed 's/actions_\(.*\).conf/\1/'); echo " <${service_name}>"; awk 'function escape_xml(str) { gsub(/&/, "&", str); gsub(/, "<", str); gsub(/>/, ">", str); return str; } BEGIN {FS=":"; action = "";} /\[.*\]/ { if (action != "") {print " " action ">"} action = substr($0, 2, length($0) - 2); print " <" action ">";} !/\[.*\]/ && NF > 1 { gsub(/^[ \t]+|[ \t]+$/, "", $2); value = escape_xml($2); print " <" $1 ">" value "" $1 ">";} END { if (action != "") {print " " action ">"} }' "$file"; echo " ${service_name}>"; done && echo ""`
65 | config := internal.ExecuteCmd(bash, host)
66 |
67 | configdoc := etree.NewDocument()
68 | configdoc.ReadFromString(config)
69 | node := configdoc.FindElement(path + "/command")
70 |
71 | if !force {
72 | configtty := internal.ConfigToTTY(configdoc, path)
73 | fmt.Println(configtty)
74 | }
75 |
76 | if node != nil {
77 | path = strings.Replace(path, "actions/", "", 1)
78 | command := "configctl " + regexp.MustCompile(`[/\.]`).ReplaceAllString(path, " ")
79 | internal.Log(2, "sending command: %s ", command)
80 | ret := internal.ExecuteCmd(command, host)
81 |
82 | var js json.RawMessage
83 | if err := json.Unmarshal([]byte(ret), &js); err == nil {
84 | var obj interface{}
85 | if err := json.Unmarshal(js, &obj); err != nil {
86 | fmt.Println("Error unmarshaling JSON:", err)
87 | return
88 | }
89 | prettyJSON, err := json.MarshalIndent(obj, "", " ")
90 | if err != nil {
91 | fmt.Println("Error formatting JSON:", err)
92 | return
93 | }
94 | fmt.Println(string(prettyJSON))
95 | } else {
96 | fmt.Println(ret)
97 | }
98 | }
99 |
100 | },
101 | }
102 |
103 | func init() {
104 | rootCmd.AddCommand(runCmd)
105 | }
106 |
--------------------------------------------------------------------------------
/cmd/discard.go:
--------------------------------------------------------------------------------
1 | /*
2 | Copyright © 2023 Miha miha.kralj@outlook.com
3 |
4 | Licensed under the Apache License, Version 2.0 (the "License");
5 | you may not use this file except in compliance with the License.
6 | You may obtain a copy of the License at
7 |
8 | http://www.apache.org/licenses/LICENSE-2.0
9 |
10 | Unless required by applicable law or agreed to in writing, software
11 | distributed under the License is distributed on an "AS IS" BASIS,
12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | See the License for the specific language governing permissions and
14 | limitations under the License.
15 | */
16 | package cmd
17 |
18 | import (
19 | "fmt"
20 | "regexp"
21 | "strings"
22 |
23 | "github.com/mihakralj/opnsense/internal"
24 | "github.com/spf13/cobra"
25 | )
26 |
27 | // discardCmd represents the discard command
28 | var discardCmd = &cobra.Command{
29 | Use: "discard []",
30 | Short: `Discard changes made to the 'staging.xml' file`,
31 | Long: `The 'discard' command reverses staged changes in the 'staging.xml' file. You can target specific nodes using an XPath expression. If no XPath is provided, all staged changes are discarded, effectively reverting 'staging.xml' to match the active 'config.xml'.`,
32 | Example: ` opnsense discard interfaces/wan/if Discard changes to the 'if' node under the 'wan' interface
33 | opnsense discard Discard all staged changes in 'staging.xml'
34 |
35 | To review staged changes, use 'show' or 'compare' command with no arguments.
36 | Use the 'discard' command cautiously to avoid losing uncommitted changes.`,
37 |
38 | Run: func(cmd *cobra.Command, args []string) {
39 |
40 | internal.Checkos()
41 |
42 | configdoc := internal.LoadXMLFile(configfile, host, false)
43 | stagingdoc := internal.LoadXMLFile(stagingfile, host, true)
44 | if stagingdoc == nil {
45 | stagingdoc = configdoc
46 | }
47 | path := "opnsense"
48 |
49 | if len(args) < 1 {
50 | internal.Log(2, "Discarding all staged configuration changes.")
51 | stagingdoc = configdoc
52 | } else {
53 |
54 | if matched, _ := regexp.MatchString(`\[0\]`, args[0]); matched {
55 | internal.Log(1, "XPath indexing of elements starts with 1, not 0")
56 | }
57 | if args[0] != "" {
58 | path = args[0]
59 | }
60 |
61 | parts := strings.Split(path, "/")
62 | if parts[0] != "opnsense" {
63 | path = "opnsense/" + path
64 | }
65 | if !strings.HasPrefix(path, "/") {
66 | path = "/" + path
67 | }
68 |
69 | stagingEl := stagingdoc.FindElement(path)
70 | configEl := configdoc.FindElement(path)
71 |
72 | if configEl == nil && stagingEl != nil {
73 | // Element is new in staging, remove it
74 | parent := stagingEl.Parent()
75 | parent.RemoveChild(stagingEl)
76 |
77 | // Remove the last part of the path
78 | lastSlash := strings.LastIndex(path, "/")
79 | if lastSlash != -1 {
80 | path = path[:lastSlash]
81 | }
82 | } else if configEl != nil && stagingEl != nil {
83 | // Element exists in both configdoc and stagingdoc, restore it
84 | parent := stagingEl.Parent()
85 | parent.RemoveChild(stagingEl)
86 | parent.AddChild(configEl.Copy())
87 |
88 | // Restore attributes
89 | configAttrs := configEl.Attr
90 | stagingEl = parent.FindElement(configEl.Tag)
91 | if stagingEl != nil {
92 | for _, attr := range configAttrs {
93 | stagingEl.CreateAttr(attr.Key, attr.Value)
94 | }
95 | }
96 | } else if configEl != nil && stagingEl == nil {
97 | // Element exists in configdoc but not in stagingdoc, add it to stagingdoc
98 | stagingdoc.Root().AddChild(configEl.Copy())
99 |
100 | // Copy attributes
101 | configAttrs := configEl.Attr
102 | stagingEl = stagingdoc.Root().FindElement(configEl.Tag)
103 | if stagingEl != nil {
104 | for _, attr := range configAttrs {
105 | stagingEl.CreateAttr(attr.Key, attr.Value)
106 | }
107 | }
108 | }
109 | }
110 |
111 | if len(args) < 1 {
112 | fmt.Printf("Discarded all staged configuration changes")
113 | } else {
114 | fmt.Printf("Discarded staged changes in node %s:\n\n", path)
115 | deltadoc := internal.DiffXML(configdoc, stagingdoc, true)
116 | internal.PrintDocument(deltadoc, path)
117 | }
118 | internal.SaveXMLFile(stagingfile, stagingdoc, host, true)
119 | },
120 | }
121 |
122 | func init() {
123 | rootCmd.AddCommand(discardCmd)
124 | }
125 |
--------------------------------------------------------------------------------
/internal/EtreeToTTY.go:
--------------------------------------------------------------------------------
1 | /*
2 | Copyright © 2023 Miha miha.kralj@outlook.com
3 |
4 | Licensed under the Apache License, Version 2.0 (the "License");
5 | you may not use this file except in compliance with the License.
6 | You may obtain a copy of the License at
7 |
8 | http://www.apache.org/licenses/LICENSE-2.0
9 |
10 | Unless required by applicable law or agreed to in writing, software
11 | distributed under the License is distributed on an "AS IS" BASIS,
12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | See the License for the specific language governing permissions and
14 | limitations under the License.
15 | */
16 | package internal
17 |
18 | import (
19 | "fmt"
20 | "strings"
21 |
22 | "github.com/beevik/etree"
23 | )
24 |
25 | // EtreeToTTY returns a string representation of the etree.Element in a TTY-friendly format.
26 | func EtreeToTTY(el *etree.Element, level int, indent int) string {
27 | // Enumerate list elements
28 | EnumerateListElements(el)
29 |
30 | // Set the indentation string for hierarchy
31 | indentation := strings.Repeat(" ", indent)
32 |
33 | // Set the line prefix based on the element's space
34 | var result strings.Builder
35 |
36 | // lead stores the leading spaces before root tag
37 | lead := c["nil"] + " "
38 | linePrefix := ""
39 | switch el.Space {
40 | case "att":
41 | linePrefix = c["chg"] + "!" + lead + c["chg"]
42 | case "add":
43 | linePrefix = c["add"] + "+" + lead
44 | indentation = indentation + c["grn"]
45 | case "chg":
46 | linePrefix = c["chg"] + "~" + lead + c["chg"]
47 | case "del":
48 | linePrefix = c["del"] + "-" + lead
49 | indentation = indentation + c["del"]
50 | default:
51 | linePrefix = c["tag"] + " " + lead + c["tag"]
52 | }
53 |
54 | // Build the attribute string
55 | var attributestr, chgstr string
56 | for _, attr := range el.Attr {
57 | switch {
58 | case attr.Space == "del":
59 | attributestr += " " + c["ita"] + c["del"] + fmt.Sprintf("(%s=\"%s\")"+c["nil"], attr.Key, attr.Value)
60 | if el.Space == "" {
61 | linePrefix = c["del"] + "-" + lead + c["nil"]
62 | }
63 | case attr.Space == "add":
64 | attributestr += " " + c["ita"] + c["add"] + fmt.Sprintf("(%s=\"%s\")"+c["nil"], attr.Key, attr.Value)
65 | if el.Space == "" {
66 | linePrefix = c["add"] + "+" + lead + c["nil"]
67 | }
68 | case attr.Space == "chg":
69 | attributestr += c["tag"] + " (" + c["ita"] + c["chg"] + fmt.Sprintf("%s"+c["tag"]+"=\""+c["del"]+"%s"+c["tag"]+"\")"+c["nil"], attr.Key, strings.Replace(attr.Value, "|||", c["nil"]+c["tag"]+"\""+c["arw"]+"\""+c["grn"], 1))
70 | if el.Space == "" {
71 | linePrefix = c["chg"] + "~" + lead + c["nil"]
72 | }
73 | default:
74 | attributestr += c["tag"] + " (" + c["ita"] + c["atr"] + fmt.Sprintf("%s"+c["tag"]+"=\""+c["atr"]+"%s"+c["tag"]+"\")"+c["nil"], attr.Key, attr.Value)
75 | }
76 | }
77 |
78 | // Replace ".n" with "[n]" in the tag name
79 | el.Tag = ReverseEnumeratePath(el.Tag)
80 | /*
81 | match, _ := regexp.MatchString(`\.\d+$`, el.Tag)
82 | if match {
83 | lastIndex := strings.LastIndex(el.Tag, ".")
84 | el.Tag = el.Tag[:lastIndex] + "[" + el.Tag[lastIndex+1:] + "]"
85 | }
86 | */
87 |
88 | // Build the content string
89 | if len(el.ChildElements()) > 0 {
90 | // If the element has child elements, build a block of nested elements
91 | result.WriteString(linePrefix + indentation + el.Tag + ":" + c["atr"] + attributestr + c["tag"] + " {" + c["nil"])
92 |
93 | if level > 0 {
94 | result.WriteString("\n")
95 | for _, child := range el.ChildElements() {
96 | result.WriteString(EtreeToTTY(child, level-1, indent+1))
97 | }
98 | result.WriteString(lead + " " + indentation + c["tag"] + "}" + c["nil"] + "\n")
99 | } else {
100 | result.WriteString(c["nil"] + c["txt"] + c["ell"] + c["tag"] + "}\n")
101 | }
102 |
103 | } else {
104 | // If the element has no child elements, build a single-line representation
105 | elText := el.Text()
106 | switch el.Space {
107 | case "chg":
108 | elText = c["nil"] + c["del"] + strings.Replace(elText, "|||", c["nil"]+c["arw"]+c["grn"], 1)
109 | case "del":
110 | elText = c["nil"] + c["del"] + strings.TrimSpace(elText)
111 | case "add":
112 | elText = c["nil"] + c["grn"] + strings.TrimSpace(elText)
113 | default:
114 | elText = c["nil"] + c["txt"] + strings.TrimSpace(elText)
115 | }
116 | content := chgstr + elText + c["nil"]
117 | if el.Parent().GetPath() == "/" && len(el.ChildElements()) == 0 {
118 | result.WriteString(linePrefix + indentation + el.Tag + ": {\n" + linePrefix + "}")
119 |
120 | } else {
121 | result.WriteString(linePrefix + indentation + el.Tag + ":" + c["atr"] + attributestr + c["nil"] + " " + content + c["nil"] + "\n")
122 | }
123 | }
124 |
125 | return result.String()
126 | }
127 |
--------------------------------------------------------------------------------
/cmd/commit.go:
--------------------------------------------------------------------------------
1 | /*
2 | Copyright © 2023 Miha miha.kralj@outlook.com
3 |
4 | Licensed under the Apache License, Version 2.0 (the "License");
5 | you may not use this file except in compliance with the License.
6 | You may obtain a copy of the License at
7 |
8 | http://www.apache.org/licenses/LICENSE-2.0
9 |
10 | Unless required by applicable law or agreed to in writing, software
11 | distributed under the License is distributed on an "AS IS" BASIS,
12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | See the License for the specific language governing permissions and
14 | limitations under the License.
15 | */
16 | package cmd
17 |
18 | import (
19 | "fmt"
20 | "strings"
21 |
22 | "github.com/mihakralj/opnsense/internal"
23 | "github.com/spf13/cobra"
24 | )
25 |
26 | // commitCmd represents the commit command
27 | var commitCmd = &cobra.Command{
28 | Use: "commit",
29 | Short: `Commit changes from the 'staging.xml' to the active 'config.xml'`,
30 | Long: `The 'commit' command finalizes the staged changes made to the 'staging.xml' file, making them the active configuration for the OPNsense firewall system. This operation is the last step in a sequence that typically involves the 'set' and optionally 'discard' commands. The 'commit' action creates a backup of the active 'config.xml', moves 'staging.xml' to 'config.xml', and reloads the 'configd' service.
31 | `,
32 |
33 | Example: ` opnsense commit Commit the changes in 'staging.xml' to become the active 'config.xml'
34 | opnsense commit --force Commit the changes without requiring interactive confirmation.`,
35 | Run: func(cmd *cobra.Command, args []string) {
36 |
37 | // check if staging.xml exists
38 | internal.Checkos()
39 | bash := `test -f "` + stagingfile + `" && echo "exists" || echo "missing"`
40 | fileexists := internal.ExecuteCmd(bash, host)
41 | if strings.TrimSpace(fileexists) != "exists" {
42 | fmt.Println("no staging.xml detected - nothing to commit.")
43 | return
44 | }
45 | bash = `cmp -s "` + configfile + `" "` + stagingfile + `" && echo "same" || echo "diff"`
46 | filesame := internal.ExecuteCmd(bash, host)
47 | if strings.TrimSpace(filesame) != "diff" {
48 | fmt.Println("staging.xml and config.xml are the same - nothing to commit.")
49 | }
50 |
51 | configdoc := internal.LoadXMLFile(configfile, host, false)
52 | stagingdoc := internal.LoadXMLFile(stagingfile, host, false)
53 |
54 | deltadoc := internal.DiffXML(configdoc, stagingdoc, false)
55 | fmt.Println("\nChanges to be commited:")
56 | internal.PrintDocument(deltadoc, "opnsense")
57 |
58 | internal.Log(2, "commiting %s to %s and reloading all services", stagingfile, configfile)
59 |
60 | // copy config.xml to /conf/backup dir
61 | backupname := internal.GenerateBackupFilename()
62 | bash = `sudo cp -f ` + configfile + ` /conf/backup/` + backupname + ` && sudo mv -f /conf/staging.xml ` + configfile
63 | internal.ExecuteCmd(bash, host)
64 |
65 | include := "php -r \"require_once('/usr/local/etc/inc/config.inc'); require_once('/usr/local/etc/inc/interfaces.inc'); require_once('/usr/local/etc/inc/filter.inc'); require_once('/usr/local/etc/inc/auth.inc'); require_once('/usr/local/etc/inc/rrd.inc'); require_once('/usr/local/etc/inc/util.inc'); require_once('/usr/local/etc/inc/system.inc'); require_once('/usr/local/etc/inc/interfaces.inc'); "
66 |
67 | var result string
68 |
69 | result = internal.ExecuteCmd(include+"system_firmware_configure(true); \"", host)
70 | fmt.Println(result)
71 | result = internal.ExecuteCmd(include+"system_trust_configure(true); \"", host)
72 | fmt.Println(result)
73 | result = internal.ExecuteCmd(include+"system_login_configure(true); \"", host)
74 | fmt.Println(result)
75 | result = internal.ExecuteCmd(include+"system_cron_configure(true); \"", host)
76 | fmt.Println(result)
77 | result = internal.ExecuteCmd(include+"system_timezone_configure(true); \"", host)
78 | fmt.Println(result)
79 | result = internal.ExecuteCmd(include+"system_hostname_configure(true); \"", host)
80 | fmt.Println(result)
81 | result = internal.ExecuteCmd(include+"system_resolver_configure(true); \"", host)
82 | fmt.Println(result)
83 | result = internal.ExecuteCmd(include+"interfaces_configure(true); \"", host)
84 | fmt.Println(result)
85 | result = internal.ExecuteCmd(include+"system_routing_configure(true); \"", host)
86 | fmt.Println(result)
87 | result = internal.ExecuteCmd(include+"rrd_configure(true); \"", host)
88 | fmt.Println(result)
89 | result = internal.ExecuteCmd(include+"filter_configure(true); \"", host)
90 | fmt.Println(result)
91 | result = internal.ExecuteCmd(include+"plugins_configure('vpn', true); \"", host)
92 | fmt.Println(result)
93 | result = internal.ExecuteCmd(include+"plugins_configure('local', true); \"", host)
94 | fmt.Println(result)
95 |
96 | },
97 | }
98 |
99 | func init() {
100 | rootCmd.AddCommand(commitCmd)
101 | }
102 |
--------------------------------------------------------------------------------
/cmd/sysinfo.go:
--------------------------------------------------------------------------------
1 | /*
2 | Copyright © 2023 Miha miha.kralj@outlook.com
3 |
4 | Licensed under the Apache License, Version 2.0 (the "License");
5 | you may not use this file except in compliance with the License.
6 | You may obtain a copy of the License at
7 |
8 | http://www.apache.org/licenses/LICENSE-2.0
9 |
10 | Unless required by applicable law or agreed to in writing, software
11 | distributed under the License is distributed on an "AS IS" BASIS,
12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | See the License for the specific language governing permissions and
14 | limitations under the License.
15 | */
16 | package cmd
17 |
18 | import (
19 | "fmt"
20 | "strings"
21 |
22 | "github.com/beevik/etree"
23 | "github.com/mihakralj/opnsense/internal"
24 | "github.com/spf13/cobra"
25 | )
26 |
27 | // systemCmd represents the status command
28 | var sysinfoCmd = &cobra.Command{
29 | Use: "sysinfo [node]",
30 | Short: "Retrieve comprehensive system information",
31 | Long: `The 'sysinfo' command provides an extensive overview of your OPNsense firewall system, including hardware, operating system, storage, and network configurations. The output is organized into multiple branches, each containing details on various aspects of the system:`,
32 | Example: ` opnsense sysinfo hardware Display hardware details
33 | opnsense sysinfo storage/disk0 Information about the first disk
34 | opnsense sysinfo network/igb0/mtu Show the MTU for the igb0 network interface`,
35 |
36 | Run: func(cmd *cobra.Command, args []string) {
37 | if changed := cmd.Flags().Changed("depth"); !changed {
38 | depth = 2
39 | internal.SetFlags(verbose, force, host, configfile, nocolor, depth, xmlFlag, yamlFlag, jsonFlag)
40 | }
41 |
42 | path := "system"
43 | if len(args) >= 1 {
44 | trimmedArg := strings.Trim(args[0], "/")
45 | if trimmedArg != "" {
46 | path = trimmedArg
47 | }
48 | parts := strings.Split(path, "/")
49 | if parts[0] != "system" {
50 | path = "system/" + path
51 | }
52 | }
53 | internal.Checkos()
54 | bash := `echo -e "\n" && sysctl hw.model hw.machine_arch hw.machine hw.clockrate hw.ncpu kern.smp.cpus hw.realmem hw.physmem hw.usermem kern.disks | awk -F: '{ gsub(/^hw\./, "", $1); gsub(/^kern\./, "", $1); content = substr($2, 2); if ($1 ~ /mem$/) { content = sprintf("%.2fGB", content/1073741824) }; printf "<%s>%s%s>\n", $1, content, $1 }' && echo -e ""
55 | echo "" && opnsense-version -O | sed -n -e '/{/d' -e '/}/d' -e 's/^[[:space:]]*"product_\([^"]*\)":[[:space:]]*"\([^"]*\)".*/<\1>\2<\/\1>/p' && sysctl kern.ostype kern.osrelease kern.version kern.hostname kern.hostid kern.hostuuid | sed 's/^kern.version:/kern.osversion:/' | awk 'NR>1 && !/^kern.opnversion/ && !NF {next} {print}' | awk -F: '{ gsub(/^kern\./, "", $1); printf "<%s>%s%s>\n", $1, substr($2, 2), $1 }' && epochtime=$(sysctl kern.boottime | awk '{print $5}' | tr -d ','); now=$(date "+%s"); diff=$((now - epochtime)); days=$((diff / 86400)); hours=$(( (diff % 86400) / 3600)); minutes=$(( (diff % 3600) / 60)); seconds=$((diff % 60)); boottime=$(date -j -r "$epochtime" "+%Y-%m-%d %H:%M:%S"); printf "%s\n%s\n%dd %dh %dm %ds\n%s\n" "$boottime" "$epochtime" "$days" "$hours" "$minutes" "$seconds" "$diff" && echo ""
56 | echo -e "" && mount | awk '{print $1}' | grep '^/dev/' | sort | uniq | xargs df -h | awk 'NR>1 {print}' | awk -v OFS='\t' -v disk_num=0 '{split($1, arr, "/"); if (arr[3] == "gpt") arr[3] = "rootfs"; print "\n\t" arr[3] "\n\tgpt\n\t" $2 "\n\t" $3 "\n\t" $4 "\n\t" $5 "\n\t"; disk_num++}' && zpool list | awk 'NR>1 {print}' | awk -v OFS='\t' -v pool_num=0 '{print "\n\t" $1 "\n\tzfs\n\t" $2 "\n\t" $3 "\n\t" $4 "\n\t" $8 "\n";pool_num++}' && echo -e"\n" && ifconfig -a | sed -E 's/metric ([0-9]+)/\n metric: \1/;s/mtu ([0-9]+)/\n mtu: \1/' | sed -E 's/=/: /g; s/<([^>]*)>/ (\1)/g; s/nd6 options/nd6_options/g; s/^([a-zA-Z0-9]+) /\1: /; s/^([[:space:]]+)([a-zA-Z0-9]+)([ \t])/\1\2:\3/' | awk 'BEGIN {ORS=""} /^[a-zA-Z0-9]+: / { if (iface) print "" iface ">"; iface=$1; sub(/:$/, "", iface); print "\n<" iface ">"; next } { sub(/:$/, "", $1); key=$1; $1=""; gsub(/^ /, ""); printf "\n\t<%s>%s%s>", key, $0, key } END { if (iface) print "\n" iface ">"; }' && echo -e '\n\n'
57 | echo -e ""`
58 | config := internal.ExecuteCmd(bash, host)
59 |
60 | configdoc := etree.NewDocument()
61 | configdoc.ReadFromString(config)
62 |
63 | configout := ""
64 | if xmlFlag {
65 | configout = internal.ConfigToXML(configdoc, path)
66 | } else if jsonFlag {
67 | configout = internal.ConfigToJSON(configdoc, path)
68 | } else if yamlFlag {
69 | configout = internal.ConfigToJSON(configdoc, path)
70 | } else {
71 | configout = internal.ConfigToTTY(configdoc, path)
72 | }
73 | fmt.Println(configout)
74 | },
75 | }
76 |
77 | func init() {
78 | sysinfoCmd.Flags().IntVarP(&depth, "depth", "d", 1, "Specifies number of levels of returned tree (1-5)")
79 | rootCmd.AddCommand(sysinfoCmd)
80 | }
81 |
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
1 | github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow=
2 | github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM=
3 | github.com/beevik/etree v1.2.0 h1:l7WETslUG/T+xOPs47dtd6jov2Ii/8/OjCldk5fYfQw=
4 | github.com/beevik/etree v1.2.0/go.mod h1:aiPf89g/1k3AShMVAzriilpcE4R/Vuor90y83zVZWFc=
5 | github.com/clbanning/mxj v1.8.4 h1:HuhwZtbyvyOw+3Z1AowPkU87JkJUSv751ELWaiTpj8I=
6 | github.com/clbanning/mxj v1.8.4/go.mod h1:BVjHeAH+rl9rs6f+QIpeRl0tfu10SXn1pUSa5PVGJng=
7 | github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
8 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
9 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
10 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
11 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
12 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
13 | github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
14 | github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
15 | github.com/pkg/sftp v1.13.6 h1:JFZT4XbOU7l77xGSpOdW+pwIMqP044IyjXX6FGyEKFo=
16 | github.com/pkg/sftp v1.13.6/go.mod h1:tz1ryNURKu77RL+GuCzmoJYxQczL3wLNNpPWagdg4Qk=
17 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
18 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
19 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
20 | github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I=
21 | github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0=
22 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
23 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
24 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
25 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
26 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
27 | github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk=
28 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
29 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
30 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
31 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
32 | golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw=
33 | golang.org/x/crypto v0.11.0 h1:6Ewdq3tDic1mg5xRO4milcWCfMVQhI4NkqWWvqejpuA=
34 | golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio=
35 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
36 | golang.org/x/mod v0.12.0 h1:rmsUpXtvNzj340zd98LZ4KntptpfRHwpFOHG188oHXc=
37 | golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
38 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
39 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
40 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
41 | golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco=
42 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
43 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
44 | golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E=
45 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
46 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
47 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
48 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
49 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
50 | golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
51 | golang.org/x/sys v0.10.0 h1:SqMFp9UcQJZa+pmYuAKjd9xq1f0j5rLcDIk0mj4qAsA=
52 | golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
53 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
54 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
55 | golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
56 | golang.org/x/term v0.10.0 h1:3R7pNqamzBraeqj/Tj8qt1aQ2HpmlC+Cx/qL/7hn4/c=
57 | golang.org/x/term v0.10.0/go.mod h1:lpqdcUyK/oCiQxvxVrppt5ggO2KCZ5QblwqPnfZ6d5o=
58 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
59 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
60 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
61 | golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
62 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
63 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
64 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
65 | golang.org/x/tools v0.11.1 h1:ojD5zOW8+7dOGzdnNgersm8aPfcDjhMp12UfG93NIMc=
66 | golang.org/x/tools v0.11.1/go.mod h1:anzJrxPjNtfgiYQYirP2CPGzGLxrH2u2QBhn6Bf3qY8=
67 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
68 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
69 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
70 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
71 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
72 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
73 |
--------------------------------------------------------------------------------
/cmd/set.go:
--------------------------------------------------------------------------------
1 | /*
2 | Copyright © 2023 Miha miha.kralj@outlook.com
3 |
4 | Licensed under the Apache License, Version 2.0 (the "License");
5 | you may not use this file except in compliance with the License.
6 | You may obtain a copy of the License at
7 |
8 | http://www.apache.org/licenses/LICENSE-2.0
9 |
10 | Unless required by applicable law or agreed to in writing, software
11 | distributed under the License is distributed on an "AS IS" BASIS,
12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | See the License for the specific language governing permissions and
14 | limitations under the License.
15 | */
16 | package cmd
17 |
18 | import (
19 | "fmt"
20 | "html"
21 | "regexp"
22 | "strconv"
23 | "strings"
24 | "unicode"
25 |
26 | "github.com/mihakralj/opnsense/internal"
27 | "github.com/spf13/cobra"
28 | )
29 |
30 | var deleteFlag bool = false
31 |
32 | // setCmd represents the set command
33 | var setCmd = &cobra.Command{
34 | Use: "set <(att=value)>",
35 | Short: "Set a value or attribute for a element in 'staging.xml'",
36 | Long: `The 'set' command modifies a specific element in the 'staging.xml' file by assigning a new value or attribute. These changes are staged and will not take effect until the 'commit' command is executed to move 'staging.xml' to 'config.xml'. You can discard any changes using the 'discard' command.
37 |
38 | The XPath parameter offers node targeting, enabling you to navigate to the exact element to modify in the XML structure.`,
39 | Example: ` opnsense set interfaces/wan/if igb0 Set the 'interfaces/wan/if' element to 'igb0'
40 | opnsense set system/hostname myrouter Assign 'myrouter' as the hostname in 'staging.xml'
41 | opnsense set interfaces "(version=2.0)" Assign an attribute to the element
42 | opnsense set system/hostname -d Remove the 'system/hostname' element and all its contents`,
43 |
44 | Run: func(cmd *cobra.Command, args []string) {
45 |
46 | if len(args) == 0 {
47 | internal.Log(1, "XPath not provided")
48 | return
49 | }
50 |
51 | internal.Checkos()
52 | configdoc := internal.LoadXMLFile(configfile, host, false)
53 | internal.EnumerateListElements(configdoc.Root())
54 |
55 | stagingdoc := internal.LoadXMLFile(stagingfile, host, true)
56 | if stagingdoc == nil {
57 | stagingdoc = configdoc
58 | }
59 | internal.EnumerateListElements(stagingdoc.Root())
60 |
61 | path := strings.Trim(args[0], "/")
62 | if !strings.HasPrefix(path, "opnsense/") {
63 | path = "opnsense/" + path
64 | }
65 | if matched, _ := regexp.MatchString(`\[0\]`, path); matched {
66 | internal.Log(1, "XPath indexing of elements starts with 1, not 0")
67 | return
68 | }
69 | // convert list targeting in path from item[1] to item.1
70 | path = internal.EnumeratePath(path)
71 |
72 | var attribute, value string
73 | if len(args) == 2 {
74 | if isAttribute(args[1]) {
75 | attribute = escapeXML(args[1])
76 |
77 | } else {
78 | value = escapeXML(strings.Trim(args[1], " "))
79 | }
80 |
81 | }
82 | if len(args) == 3 {
83 | if isAttribute(args[1]) {
84 | attribute = escapeXML(args[1])
85 | if !isAttribute((args[2])) {
86 | value = escapeXML(strings.Trim(args[2], " "))
87 | } else {
88 | internal.Log(1, "Too many attributes provided")
89 | }
90 | } else {
91 | value = strings.Trim(args[1], " ")
92 | if isAttribute(args[2]) {
93 | attribute = escapeXML(args[2])
94 | } else {
95 | internal.Log(1, "Too many values provided")
96 | }
97 | }
98 | }
99 |
100 | // stagingdoc is converted to enumeratedXML, path is converted to enumerated path
101 | element := stagingdoc.FindElement(path)
102 | if !deleteFlag {
103 | if element == nil {
104 | element = stagingdoc.Root()
105 | parts := strings.Split(path, "/")
106 |
107 | for i, part := range parts {
108 | part = fixXMLName(part)
109 | if i == 0 && part == "opnsense" {
110 | continue
111 | }
112 |
113 | if element.SelectElement(part) == nil {
114 | if element.SelectElement(part+".1") != nil {
115 | var maxIndex int
116 | for _, child := range element.ChildElements() {
117 | if strings.HasPrefix(child.Tag, part+".") {
118 | indexStr := strings.TrimPrefix(child.Tag, part+".")
119 | index, err := strconv.Atoi(indexStr)
120 | if err == nil && index > maxIndex {
121 | maxIndex = index
122 | }
123 | }
124 | }
125 | part = fmt.Sprintf("%s.%d", part, maxIndex+1)
126 | }
127 |
128 | newEl := element.CreateElement(part)
129 | fmt.Printf("Created a new element %s:\n\n", strings.TrimPrefix(newEl.GetPath(), "/"))
130 | }
131 | element = element.SelectElement(part)
132 | }
133 | path = element.GetPath()
134 | }
135 | if value != "" {
136 | children := element.ChildElements()
137 | if len(children) > 0 {
138 | internal.Log(1, "%s is an element container and cannot store content.", element.GetPath())
139 | }
140 | element.SetText(value)
141 | path = element.GetPath()
142 | fmt.Printf(`Set value "%s" of element %s:`+"\n\n", value, strings.TrimPrefix(path, "/"))
143 |
144 | }
145 | if attribute != "" {
146 | attribute = strings.Trim(attribute, "()") // remove parentheses
147 | parts := strings.Split(attribute, "=")
148 | if len(parts) == 2 {
149 | key := fixXMLName(parts[0])
150 | val := escapeXML(parts[1])
151 | element.CreateAttr(key, val)
152 | fmt.Printf("Set an attribute \"(%s=%s)\" of element %s:\n\n", key, val, path)
153 |
154 | } else {
155 | internal.Log(1, "Invalid attribute format")
156 | }
157 | }
158 | } else {
159 | if value == "" && attribute == "" && element != nil {
160 | parent := element.Parent()
161 | if parent != nil {
162 | parent.RemoveChild(element)
163 | fmt.Printf("Deleted element %s:\n\n", path)
164 | path = parent.GetPath()
165 | } else {
166 | internal.Log(1, "Cannot delete the root element")
167 | }
168 | }
169 | if value != "" {
170 | element.SetText("")
171 | fmt.Printf("Deleted value of element %s:\n\n", path)
172 | path = element.GetPath()
173 | }
174 | if attribute != "" {
175 | attribute = strings.Trim(attribute, "()") // remove parentheses
176 | parts := strings.Split(attribute, "=")
177 | if len(parts) == 2 {
178 | key := fixXMLName(parts[0])
179 | element.RemoveAttr(key)
180 | fmt.Printf("Deleted an attribute (%s) of element %s:\n\n", key, path)
181 |
182 | } else {
183 | internal.Log(1, "Invalid attribute format")
184 | }
185 | }
186 | }
187 |
188 | path = internal.ReverseEnumeratePath(path)
189 |
190 | internal.ReverseEnumerateListElements(configdoc.Root())
191 | internal.ReverseEnumerateListElements(stagingdoc.Root())
192 |
193 | deltadoc := internal.DiffXML(configdoc, stagingdoc, true)
194 |
195 | internal.PrintDocument(deltadoc, path)
196 | internal.SaveXMLFile(stagingfile, stagingdoc, host, true)
197 | },
198 | }
199 |
200 | func init() {
201 | rootCmd.AddCommand(setCmd)
202 | setCmd.Flags().BoolVarP(&deleteFlag, "delete", "d", false, "Delete an element")
203 |
204 | }
205 |
206 | func isAttribute(s string) bool {
207 | re := regexp.MustCompile(`^\([^=]+(=[^=]*)?\)$`)
208 | return re.MatchString(s)
209 | }
210 |
211 | func escapeXML(value string) string {
212 | value = strings.TrimSpace(value)
213 | escapedValue := html.EscapeString(value)
214 | return escapedValue
215 | }
216 |
217 | func fixXMLName(value string) string {
218 | // Trim the input string
219 | value = strings.TrimSpace(value)
220 | if value == "" {
221 | return "_"
222 | }
223 |
224 | // Ensure the first character is a valid start character
225 | for len(value) > 0 && !isXMLNameStartChar(rune(value[0])) {
226 | value = value[1:]
227 | }
228 |
229 | // If no valid start character was found, prepend an underscore
230 | if len(value) == 0 {
231 | value = "_"
232 | }
233 |
234 | // Ensure all other characters are valid name characters
235 | runes := []rune(value)
236 | for i, r := range runes {
237 | if !isXMLNameChar(r) {
238 | runes[i] = '_'
239 | }
240 | }
241 |
242 | return string(runes)
243 | }
244 |
245 | // Checks if a rune is a valid XML name start character
246 | func isXMLNameStartChar(r rune) bool {
247 | return unicode.IsLetter(r) || r == '_'
248 | }
249 |
250 | // Checks if a rune is a valid XML name character
251 | func isXMLNameChar(r rune) bool {
252 | return unicode.IsLetter(r) || unicode.IsDigit(r) || r == '.' || r == '-' || r == '_' || r == ':' || r == '[' || r == ']'
253 | }
254 |
--------------------------------------------------------------------------------
/.github/workflows/build.yml:
--------------------------------------------------------------------------------
1 | name: BuildPublish
2 |
3 | on:
4 | push:
5 | branches: [ "main" ]
6 | pull_request:
7 | branches: [ "main" ]
8 | workflow_dispatch:
9 |
10 | jobs:
11 | linuxwindows:
12 | runs-on: ubuntu-latest
13 | outputs:
14 | version: ${{ steps.extract_version.outputs.version }}
15 | name: Ubuntu runner for Linux and Windows
16 | steps:
17 | - name: Set up Go
18 | uses: actions/setup-go@v4
19 | with:
20 | go-version: '1.20'
21 | check-latest: true
22 |
23 | - name: Check out code
24 | uses: actions/checkout@v3
25 |
26 | - name: Install prereqs
27 | run: sudo apt-get install build-essential devscripts debhelper dh-make
28 | shell: bash
29 |
30 | - name: Extract version from root.go
31 | id: extract_version
32 | run: |
33 | VERSION=$(grep 'Version[[:space:]]*string' cmd/root.go | awk -F'"' '{ print $2 }')
34 | echo "VERSION=$VERSION" >> $GITHUB_ENV
35 | echo "::set-output name=version::${VERSION}"
36 | echo "VERSION=$VERSION"
37 | shell: bash
38 |
39 | - name: Build Windows binary
40 | env:
41 | GOOS: windows
42 | GOARCH: amd64
43 | run: |
44 | go build -ldflags "-s -w -X cmd.Version=${{ env.VERSION }}" -o ./opnsense.exe
45 |
46 | - name: Build Linux binary
47 | env:
48 | GOOS: linux
49 | GOARCH: amd64
50 | CGO_ENABLED: 0
51 | run: |
52 | go build -ldflags "-s -w -X cmd.Version=${{ env.VERSION }}" -o ./opnsense-linux
53 |
54 | - name: package linux binary into .deb
55 | run: |
56 | mkdir -p opnsense/DEBIAN && mkdir -p opnsense/usr/local/bin
57 | chmod +x ./opnsense-linux
58 | cp ./opnsense-linux opnsense/usr/local/bin/opnsense
59 | echo -e "Package: opnsense-cli\nVersion: ${VERSION}\nSection: base\nPriority: optional\nArchitecture: amd64\nMaintainer: Miha Kralj \nDescription: opnsense is a command-line utility for managing, configuring, and monitoring OPNsense firewall systems. It facilitates non-GUI administration, both directly in the shell and remotely via an SSH tunnel. All interactions with OPNsense utilize the same mechanisms as the Web GUI, including staged modifications of config.xml and execution of available configd commands." > opnsense/DEBIAN/control
60 | dpkg-deb -Zxz --build opnsense
61 | shell: bash
62 |
63 | - name: Upload all artifacts
64 | uses: actions/upload-artifact@v3
65 | with:
66 | name: bin
67 | path: |
68 | ./opnsense.deb
69 | ./opnsense.exe
70 |
71 | macbsd:
72 | runs-on: macos-12
73 | name: MacOS runner with FreeBSD virtualbox
74 | steps:
75 | - name: Set up Go
76 | uses: actions/setup-go@v4
77 | with:
78 | go-version: '1.20'
79 | check-latest: true
80 |
81 | - name: Check out code
82 | uses: actions/checkout@v3
83 |
84 | - name: Extract version from root.go
85 | id: extract_version
86 | run: |
87 | VERSION=$(grep 'Version[[:space:]]*string' cmd/root.go | awk -F'"' '{ print $2 }')
88 | echo "VERSION=$VERSION" >> $GITHUB_ENV
89 | echo "::set-output name=version::${VERSION}"
90 | echo "VERSION=$VERSION"
91 | shell: bash
92 |
93 | - name: Select XCode
94 | uses: BoundfoxStudios/action-xcode-select@v1
95 | with:
96 | version: 14.2
97 |
98 | - name: Import app certificate
99 | run: |
100 | security create-keychain -p ${{ secrets.APPLE_CERTPWD }} build.keychain
101 | security default-keychain -s build.keychain
102 | security unlock-keychain -p ${{ secrets.APPLE_CERTPWD }} build.keychain
103 | echo "${{ secrets.APPLE_APPCERT }}" | base64 --decode -o apple_certificate.p12
104 | security import apple_certificate.p12 -k build.keychain -P ${{ secrets.APPLE_CERTPWD }} -T /usr/bin/codesign
105 | security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k ${{ secrets.APPLE_CERTPWD }} build.keychain
106 | echo "${{ secrets.APPLE_INSTCERT }}" | base64 --decode -o apple_instcert.p12
107 | security import apple_instcert.p12 -k build.keychain -P ${{ secrets.APPLE_CERTPWD }} -T /usr/bin/productsign
108 | security set-key-partition-list -S apple-tool:,apple:,productsign: -s -k ${{ secrets.APPLE_CERTPWD }} build.keychain
109 | security find-identity -v
110 |
111 | - name: Build macOS binary
112 | env:
113 | GOOS: darwin
114 | GOARCH: amd64
115 | run: |
116 | mkdir -p ./opnsense/usr/local/bin
117 | go build -ldflags "-s -w -X cmd.Version=${{ env.VERSION }}" -o ./opnsense/usr/local/bin/opnsense
118 | chmod +x ./opnsense/usr/local/bin/opnsense
119 |
120 | - name: sign binary
121 | run: |
122 | APPSIGNING_IDENTITY=$(security find-identity -v -p codesigning | grep "Application" | awk '{print $2}')
123 | codesign --deep --force --verify --verbose --timestamp --options runtime --sign $APPSIGNING_IDENTITY ./opnsense/usr/local/bin/opnsense
124 | cp ./opnsense/usr/local/bin/opnsense ./opnsense-macos
125 |
126 | - name: build component package
127 | run: |
128 | pkgbuild --root ./opnsense --identifier com.github.mihakralj.opnsense --version "${{ env.VERSION }}" ./component-opnsense.pkg
129 | productbuild --synthesize --package ./component-opnsense.pkg ./distribution.xml
130 | productbuild --distribution ./distribution.xml --package-path ./ ./opnsense.pkg
131 |
132 | - name: sign the distribution package
133 | run: |
134 | INSTSIGNING_IDENTITY=$(security find-identity -v -p basic | grep "Installer" | awk '{print $2}')
135 | productsign --keychain build.keychain --sign $INSTSIGNING_IDENTITY ./opnsense.pkg ./signed-opnsense.pkg
136 | mv ./signed-opnsense.pkg ./opnsense-macos.pkg
137 |
138 | - name: notarize
139 | run: |
140 | xcrun notarytool submit ./opnsense-macos.pkg --wait --apple-id ${{ secrets.APPLE_NOTARIZATION_USERNAME }} --password ${{ secrets.APPLE_NOTARIZATION_PASSWORD }} --team-id ${{ secrets.APPLE_TEAM_NAME }}
141 | xcrun stapler staple ./opnsense-macos.pkg
142 |
143 | - name: Compile .txz package in FreeBSD
144 | id: compile
145 | uses: vmactions/freebsd-vm@v0.3.1
146 | with:
147 | envs: 'VERSION=${{ env.VERSION }}'
148 | usesh: true
149 | prepare: |
150 | pkg install -y curl wget
151 | name=$(curl -s https://go.dev/dl/ | grep 'freebsd-amd64' | sed -n 's/.*href="\([^"]*\)".*/\1/p' | head -n 1 | xargs basename)
152 | wget -q "https://dl.google.com/go/$name"
153 | tar -C /usr/local -xzf "$name"
154 | run: |
155 | mkdir ~/.gopkg
156 | export GOPATH=/root/.gopkg
157 | export PATH=$PATH:/usr/local/go/bin:/root/.gopkg/bin
158 | mkdir -p /Users/runner/work/opnsense-cli/opnsense-cli/work-dir/usr/local/bin
159 | go build -gcflags='-trimpath' -ldflags="-s -w -X cmd.Version=${VERSION}" -o /Users/runner/work/opnsense-cli/opnsense-cli/work-dir/usr/local/bin/opnsense opnsense.go
160 | cp /Users/runner/work/opnsense-cli/opnsense-cli/work-dir/usr/local/bin/opnsense /usr/local/bin/opnsense
161 | checksum=$(sha256 -q /Users/runner/work/opnsense-cli/opnsense-cli/work-dir/usr/local/bin/opnsense)
162 | flatsize=$(stat -f%z /Users/runner/work/opnsense-cli/opnsense-cli/work-dir/usr/local/bin/opnsense)
163 | echo "/usr/local/bin/opnsense: ${checksum}" > /Users/runner/work/opnsense-cli/opnsense-cli/sha256checksum
164 | echo "/usr/local/bin/opnsense" > /Users/runner/work/opnsense-cli/opnsense-cli/plist
165 | echo -e "name: opnsense-cli\nversion: ${VERSION}\norigin: net-mgmt/opnsense-cli\ncomment: \"CLI to manage and monitor OPNsense firewall configuration, check status, change settings, and execute commands.\"\ndesc: \"opnsense is a command-line utility for managing, configuring, and monitoring OPNsense firewall systems. It facilitates non-GUI administration, both directly in the shell and remotely via an SSH tunnel. All interactions with OPNsense utilize the same mechanisms as the Web GUI, including staged modifications of config.xml and execution of available configd commands.\"\nmaintainer: \"miha.kralj@outlook.com\"\nwww: \"https://github.com/mihakralj/opnsense-cli\"\nabi: \"FreeBSD:*:amd64\"\nprefix: /usr/local\nflatsize: ${flatsize}" > /Users/runner/work/opnsense-cli/opnsense-cli/manifest
166 | echo -e "files: {\n \"/usr/local/bin/opnsense\": \"${checksum}\",\n}" >> /Users/runner/work/opnsense-cli/opnsense-cli/manifest
167 | pkg create -M /Users/runner/work/opnsense-cli/opnsense-cli/manifest -o /Users/runner/work/opnsense-cli/opnsense-cli/ -f txz
168 | ls -l
169 |
170 | - name: Upload all artifacts
171 | uses: actions/upload-artifact@v3
172 | with:
173 | name: bin
174 | path: |
175 | ./opnsense-macos
176 | ./opnsense*.txz
177 | ./opnsense.pkg
178 | ./opnsense-cli-*.pkg
179 |
180 | release:
181 | needs: [linuxwindows, macbsd]
182 | runs-on: ubuntu-latest
183 |
184 | steps:
185 | - name: Download all artifacts
186 | uses: actions/download-artifact@v2
187 | with:
188 | path: .
189 |
190 | - name: list downloaded files
191 | run: |
192 | ls -l ./bin
193 | shell: bash
194 |
195 | - name: Create Beta Release and Upload Assets
196 | id: create_release
197 | uses: softprops/action-gh-release@v1
198 | with:
199 | prerelease: true
200 | draft: false
201 | tag_name: ${{ env.VERSION }}
202 | name: opnsense-cli ${{ env.VERSION }}
203 | files: ./bin/opnsense*
204 | env:
205 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
206 | VERSION: ${{ needs.linuxwindows.outputs.version }}
--------------------------------------------------------------------------------
/internal/DiffXML.go:
--------------------------------------------------------------------------------
1 | /*
2 | Copyright © 2023 Miha miha.kralj@outlook.com
3 |
4 | Licensed under the Apache License, Version 2.0 (the "License");
5 | you may not use this file except in compliance with the License.
6 | You may obtain a copy of the License at
7 |
8 | http://www.apache.org/licenses/LICENSE-2.0
9 |
10 | Unless required by applicable law or agreed to in writing, software
11 | distributed under the License is distributed on an "AS IS" BASIS,
12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | See the License for the specific language governing permissions and
14 | limitations under the License.
15 | */
16 | package internal
17 |
18 | import (
19 | "fmt"
20 | "regexp"
21 | "strings"
22 |
23 | "github.com/beevik/etree"
24 | )
25 |
26 | // DiffXML compares two etree documents and returns a new document with only the changed elements.
27 | func DiffXML(oldDoc, newDoc *etree.Document, fulltree bool) *etree.Document {
28 | diffDoc := oldDoc.Copy()
29 |
30 | EnumerateListElements(newDoc.Root())
31 | EnumerateListElements(diffDoc.Root())
32 |
33 | addMissingElements(newDoc.Root(), diffDoc)
34 | checkElements(diffDoc.Root(), newDoc)
35 |
36 | if !fulltree {
37 | removeNodesWithoutSpace(diffDoc.Root())
38 | removeAttSpace(diffDoc.Root())
39 | }
40 |
41 | //ReverseEnumerateListElements(diffDoc.Root())
42 | //ReverseEnumerateListElements(newDoc.Root())
43 |
44 | return diffDoc
45 | }
46 |
47 | // removeNodesWithoutSpace recursively removes elements without a "Space" attribute
48 | func removeNodesWithoutSpace(el *etree.Element) {
49 | for i := 0; i < len(el.Child); i++ {
50 | child, ok := el.Child[i].(*etree.Element)
51 | if !ok {
52 | continue
53 | }
54 |
55 | // Check if any attribute has Space defined
56 | hasAttrWithSpace := false
57 | for _, attr := range child.Attr {
58 | if attr.Space != "" {
59 | hasAttrWithSpace = true
60 | break
61 | }
62 | }
63 |
64 | // Remove the child element only if it doesn't have a Space and none of its attributes have a Space
65 | if child.Space == "" && !hasAttrWithSpace {
66 | el.RemoveChildAt(i)
67 | i-- // Adjust index because we've removed an item
68 | continue
69 | }
70 |
71 | removeNodesWithoutSpace(child)
72 | }
73 | }
74 |
75 | func removeAttSpace(el *etree.Element) {
76 | if el == nil {
77 | return
78 | }
79 |
80 | // Remove or unset the "Space" attribute if it is set to "att"
81 | if el.Space == "att" {
82 | el.Space = ""
83 | }
84 |
85 | // Recursively process children
86 | for i := 0; i < len(el.Child); i++ {
87 | child, ok := el.Child[i].(*etree.Element)
88 | if !ok {
89 | continue // Skip if this child is not an Element
90 | }
91 |
92 | removeAttSpace(child)
93 | }
94 | }
95 |
96 | func RemoveChgSpace(el *etree.Element) {
97 | if el == nil {
98 | return
99 | }
100 |
101 | // Remove or unset the "Space" attribute if it is set to "att"
102 | if el.Space == "chg" {
103 | parts := strings.Split(el.Text(), "|||")
104 | if len(parts) > 1 {
105 | el.SetText(parts[1])
106 | }
107 | el.Space = "add"
108 | }
109 | // Process attributes
110 | for i := range el.Attr {
111 | // Check if the attribute space is "chg"
112 | if el.Attr[i].Space == "chg" {
113 | parts := strings.Split(el.Attr[i].Value, "|||")
114 | if len(parts) > 1 {
115 | el.Attr[i].Value = parts[1]
116 | }
117 | el.Attr[i].Space = "add"
118 | }
119 | }
120 |
121 | // Recursively process children
122 | for i := 0; i < len(el.Child); i++ {
123 | child, ok := el.Child[i].(*etree.Element)
124 | if !ok {
125 | continue // Skip if this child is not an Element
126 | }
127 |
128 | RemoveChgSpace(child)
129 | }
130 | }
131 |
132 | func checkElements(oldEl *etree.Element, newDoc *etree.Document) {
133 | newEl := newDoc.FindElement(oldEl.GetPath())
134 | if newEl != nil {
135 | // Element found in newDoc
136 | newElText := strings.TrimSpace(newEl.Text())
137 | oldElText := strings.TrimSpace(oldEl.Text())
138 |
139 | if newElText != oldElText {
140 | if newElText != "" && oldElText != "" {
141 | oldEl.Space = "chg"
142 | oldEl.SetText(fmt.Sprintf("%s|||%s", oldElText, newElText))
143 | markParentSpace(oldEl)
144 | } else if newElText != "" && oldElText == "" {
145 | oldEl.Space = "chg"
146 | oldEl.SetText("N/A|||" + newEl.Text())
147 | markParentSpace(oldEl)
148 | }
149 | }
150 | copyAttributes(newEl, oldEl)
151 |
152 | // Check comments
153 | checkComments(oldEl, newEl)
154 | } else {
155 | oldEl.Space = "del"
156 | markParentSpace(oldEl)
157 | }
158 |
159 | // Recursively check all child elements
160 | for _, child := range oldEl.ChildElements() {
161 | checkElements(child, newDoc)
162 | }
163 | }
164 |
165 | func checkComments(oldEl, newEl *etree.Element) {
166 | oldComments := getComments(oldEl)
167 | newComments := getComments(newEl)
168 |
169 | for _, oldComment := range oldComments {
170 | if !containsComment(newComments, oldComment) {
171 | updateComment(oldEl, "del:"+oldComment)
172 | }
173 | }
174 |
175 | for i, newComment := range newComments {
176 | if !containsComment(oldComments, newComment) {
177 | newCommentNode := etree.NewComment("new:" + newComment)
178 | oldEl.InsertChildAt(i, newCommentNode)
179 | }
180 | }
181 | }
182 |
183 | func addMissingElements(newEl *etree.Element, oldDoc *etree.Document) {
184 | oldEl := oldDoc.FindElement(newEl.GetPath())
185 | if oldEl == nil {
186 | // Element not found in oldDoc
187 | parentPath := newEl.Parent().GetPath()
188 | parentInOldDoc := oldDoc.FindElement(parentPath)
189 | if parentInOldDoc != nil {
190 |
191 | oldEl := etree.NewElement(fmt.Sprintf("add:%s", newEl.Tag))
192 | oldEl.SetText(newEl.Text())
193 | copyAttributes(newEl, oldEl)
194 |
195 | parentInOldDoc.AddChild(oldEl)
196 | addedchild := parentInOldDoc.Child[len(parentInOldDoc.Child)-1]
197 |
198 | markParentSpace(addedchild.(*etree.Element))
199 | }
200 | }
201 |
202 | // Recursively check all child elements
203 | for _, child := range newEl.ChildElements() {
204 | addMissingElements(child, oldDoc)
205 | }
206 | }
207 |
208 | func copyAttributes(oldEl, newEl *etree.Element) {
209 | // Check if oldEl or newEl is nil
210 | if oldEl == nil || newEl == nil {
211 | return
212 | }
213 |
214 | // Check attributes in oldEl
215 | for _, oldAttr := range oldEl.Attr {
216 | newAttr := newEl.SelectAttr(oldAttr.Key)
217 | if newAttr != nil {
218 | // Attribute exists in newEl
219 | if newAttr.Value != oldAttr.Value {
220 | // Different value, add chg: in front of attribute name
221 | newEl.RemoveAttr(oldAttr.Key)
222 | newEl.CreateAttr(fmt.Sprintf("chg:%s", oldAttr.Key), fmt.Sprintf("%s|||%s", newAttr.Value, oldAttr.Value))
223 | markParentSpace(newEl)
224 | }
225 | // If same value, do nothing
226 | } else {
227 | // Attribute does not exist in newEl, add with namespace del:
228 | newEl.CreateAttr(fmt.Sprintf("add:%s", oldAttr.Key), oldAttr.Value)
229 | markParentSpace(newEl)
230 | }
231 | }
232 |
233 | // Create a copy of newEl.Attr
234 | newAttrs := make([]etree.Attr, len(newEl.Attr))
235 | copy(newAttrs, newEl.Attr)
236 |
237 | // Check attributes in newEl
238 | for _, newAttr := range newAttrs {
239 | oldAttr := oldEl.SelectAttr(newAttr.Key)
240 | if oldAttr == nil {
241 | // Attribute does not exist in oldEl, add with namespace new:
242 | newEl.RemoveAttr(newAttr.Key)
243 | newEl.CreateAttr(fmt.Sprintf("del:%s", newAttr.Key), strings.TrimSpace(newAttr.Value))
244 | markParentSpace(newEl)
245 | }
246 | // If attribute exists in oldEl, it has already been handled
247 | }
248 | }
249 |
250 | func EnumerateListElements(el *etree.Element) {
251 | childElementCounts := make(map[string]int)
252 | childElements := el.ChildElements()
253 |
254 | // Count occurrences of each tag
255 | for _, child := range childElements {
256 | childElementCounts[child.Tag]++
257 | }
258 |
259 | // Rename elements with duplicate tags
260 | for tag, count := range childElementCounts {
261 | if count > 1 {
262 | var index = 1
263 | for _, child := range childElements {
264 | if child.Tag == tag {
265 | child.Tag = fmt.Sprintf("%s.%d", tag, index)
266 | index++
267 | }
268 | EnumerateListElements(child)
269 | }
270 | } else {
271 | for _, child := range childElements {
272 | if child.Tag == tag {
273 | EnumerateListElements(child)
274 | }
275 | }
276 | }
277 | }
278 | }
279 |
280 | func ReverseEnumerateListElements(el *etree.Element) {
281 | childElements := el.ChildElements()
282 |
283 | // Iterate over child elements
284 | for _, child := range childElements {
285 | // Check if the tag contains a dot
286 | if strings.Contains(child.Tag, ".") {
287 | // Split the tag on the dot and take the first part
288 | parts := strings.Split(child.Tag, ".")
289 | child.Tag = parts[0]
290 | }
291 | // Recursively call the function on the child
292 | ReverseEnumerateListElements(child)
293 | }
294 | }
295 |
296 | func EnumeratePath(path string) string {
297 | re := regexp.MustCompile(`\[(\d+)\]`)
298 | return re.ReplaceAllString(path, ".$1")
299 | }
300 |
301 | func ReverseEnumeratePath(path string) string {
302 | re := regexp.MustCompile(`\.(\d+)`)
303 | return re.ReplaceAllString(path, "[$1]")
304 | }
305 |
306 | func getComments(el *etree.Element) []string {
307 | var comments []string
308 | for _, token := range el.Child {
309 | if comment, ok := token.(*etree.Comment); ok {
310 | comments = append(comments, comment.Data)
311 | }
312 | }
313 | return comments
314 | }
315 |
316 | func containsComment(comments []string, comment string) bool {
317 | for _, c := range comments {
318 | if c == comment {
319 | return true
320 | }
321 | }
322 | return false
323 | }
324 |
325 | func getCommentString(el *etree.Element) string {
326 | commentStr := ""
327 | for _, token := range el.Child {
328 | if comment, ok := token.(*etree.Comment); ok {
329 | commentStr = comment.Data
330 | }
331 | }
332 | return commentStr
333 | }
334 |
335 | func updateComment(el *etree.Element, newCommentStr string) {
336 | for _, child := range el.Child {
337 | if comment, ok := child.(*etree.Comment); ok {
338 | comment.Data = newCommentStr
339 | break
340 | }
341 | }
342 | }
343 |
344 | func markParentSpace(el *etree.Element) {
345 | if el == nil {
346 | return
347 | }
348 | parent := el.Parent()
349 | if parent != nil && parent.Space == "" {
350 | parent.Space = "att"
351 | markParentSpace(parent)
352 | }
353 | }
354 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Apache License
2 | Version 2.0, January 2004
3 | http://www.apache.org/licenses/
4 |
5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6 |
7 | 1. Definitions.
8 |
9 | "License" shall mean the terms and conditions for use, reproduction,
10 | and distribution as defined by Sections 1 through 9 of this document.
11 |
12 | "Licensor" shall mean the copyright owner or entity authorized by
13 | the copyright owner that is granting the License.
14 |
15 | "Legal Entity" shall mean the union of the acting entity and all
16 | other entities that control, are controlled by, or are under common
17 | control with that entity. For the purposes of this definition,
18 | "control" means (i) the power, direct or indirect, to cause the
19 | direction or management of such entity, whether by contract or
20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the
21 | outstanding shares, or (iii) beneficial ownership of such entity.
22 |
23 | "You" (or "Your") shall mean an individual or Legal Entity
24 | exercising permissions granted by this License.
25 |
26 | "Source" form shall mean the preferred form for making modifications,
27 | including but not limited to software source code, documentation
28 | source, and configuration files.
29 |
30 | "Object" form shall mean any form resulting from mechanical
31 | transformation or translation of a Source form, including but
32 | not limited to compiled object code, generated documentation,
33 | and conversions to other media types.
34 |
35 | "Work" shall mean the work of authorship, whether in Source or
36 | Object form, made available under the License, as indicated by a
37 | copyright notice that is included in or attached to the work
38 | (an example is provided in the Appendix below).
39 |
40 | "Derivative Works" shall mean any work, whether in Source or Object
41 | form, that is based on (or derived from) the Work and for which the
42 | editorial revisions, annotations, elaborations, or other modifications
43 | represent, as a whole, an original work of authorship. For the purposes
44 | of this License, Derivative Works shall not include works that remain
45 | separable from, or merely link (or bind by name) to the interfaces of,
46 | the Work and Derivative Works thereof.
47 |
48 | "Contribution" shall mean any work of authorship, including
49 | the original version of the Work and any modifications or additions
50 | to that Work or Derivative Works thereof, that is intentionally
51 | submitted to Licensor for inclusion in the Work by the copyright owner
52 | or by an individual or Legal Entity authorized to submit on behalf of
53 | the copyright owner. For the purposes of this definition, "submitted"
54 | means any form of electronic, verbal, or written communication sent
55 | to the Licensor or its representatives, including but not limited to
56 | communication on electronic mailing lists, source code control systems,
57 | and issue tracking systems that are managed by, or on behalf of, the
58 | Licensor for the purpose of discussing and improving the Work, but
59 | excluding communication that is conspicuously marked or otherwise
60 | designated in writing by the copyright owner as "Not a Contribution."
61 |
62 | "Contributor" shall mean Licensor and any individual or Legal Entity
63 | on behalf of whom a Contribution has been received by Licensor and
64 | subsequently incorporated within the Work.
65 |
66 | 2. Grant of Copyright License. Subject to the terms and conditions of
67 | this License, each Contributor hereby grants to You a perpetual,
68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69 | copyright license to reproduce, prepare Derivative Works of,
70 | publicly display, publicly perform, sublicense, and distribute the
71 | Work and such Derivative Works in Source or Object form.
72 |
73 | 3. Grant of Patent License. Subject to the terms and conditions of
74 | this License, each Contributor hereby grants to You a perpetual,
75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76 | (except as stated in this section) patent license to make, have made,
77 | use, offer to sell, sell, import, and otherwise transfer the Work,
78 | where such license applies only to those patent claims licensable
79 | by such Contributor that are necessarily infringed by their
80 | Contribution(s) alone or by combination of their Contribution(s)
81 | with the Work to which such Contribution(s) was submitted. If You
82 | institute patent litigation against any entity (including a
83 | cross-claim or counterclaim in a lawsuit) alleging that the Work
84 | or a Contribution incorporated within the Work constitutes direct
85 | or contributory patent infringement, then any patent licenses
86 | granted to You under this License for that Work shall terminate
87 | as of the date such litigation is filed.
88 |
89 | 4. Redistribution. You may reproduce and distribute copies of the
90 | Work or Derivative Works thereof in any medium, with or without
91 | modifications, and in Source or Object form, provided that You
92 | meet the following conditions:
93 |
94 | (a) You must give any other recipients of the Work or
95 | Derivative Works a copy of this License; and
96 |
97 | (b) You must cause any modified files to carry prominent notices
98 | stating that You changed the files; and
99 |
100 | (c) You must retain, in the Source form of any Derivative Works
101 | that You distribute, all copyright, patent, trademark, and
102 | attribution notices from the Source form of the Work,
103 | excluding those notices that do not pertain to any part of
104 | the Derivative Works; and
105 |
106 | (d) If the Work includes a "NOTICE" text file as part of its
107 | distribution, then any Derivative Works that You distribute must
108 | include a readable copy of the attribution notices contained
109 | within such NOTICE file, excluding those notices that do not
110 | pertain to any part of the Derivative Works, in at least one
111 | of the following places: within a NOTICE text file distributed
112 | as part of the Derivative Works; within the Source form or
113 | documentation, if provided along with the Derivative Works; or,
114 | within a display generated by the Derivative Works, if and
115 | wherever such third-party notices normally appear. The contents
116 | of the NOTICE file are for informational purposes only and
117 | do not modify the License. You may add Your own attribution
118 | notices within Derivative Works that You distribute, alongside
119 | or as an addendum to the NOTICE text from the Work, provided
120 | that such additional attribution notices cannot be construed
121 | as modifying the License.
122 |
123 | You may add Your own copyright statement to Your modifications and
124 | may provide additional or different license terms and conditions
125 | for use, reproduction, or distribution of Your modifications, or
126 | for any such Derivative Works as a whole, provided Your use,
127 | reproduction, and distribution of the Work otherwise complies with
128 | the conditions stated in this License.
129 |
130 | 5. Submission of Contributions. Unless You explicitly state otherwise,
131 | any Contribution intentionally submitted for inclusion in the Work
132 | by You to the Licensor shall be under the terms and conditions of
133 | this License, without any additional terms or conditions.
134 | Notwithstanding the above, nothing herein shall supersede or modify
135 | the terms of any separate license agreement you may have executed
136 | with Licensor regarding such Contributions.
137 |
138 | 6. Trademarks. This License does not grant permission to use the trade
139 | names, trademarks, service marks, or product names of the Licensor,
140 | except as required for reasonable and customary use in describing the
141 | origin of the Work and reproducing the content of the NOTICE file.
142 |
143 | 7. Disclaimer of Warranty. Unless required by applicable law or
144 | agreed to in writing, Licensor provides the Work (and each
145 | Contributor provides its Contributions) on an "AS IS" BASIS,
146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147 | implied, including, without limitation, any warranties or conditions
148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149 | PARTICULAR PURPOSE. You are solely responsible for determining the
150 | appropriateness of using or redistributing the Work and assume any
151 | risks associated with Your exercise of permissions under this License.
152 |
153 | 8. Limitation of Liability. In no event and under no legal theory,
154 | whether in tort (including negligence), contract, or otherwise,
155 | unless required by applicable law (such as deliberate and grossly
156 | negligent acts) or agreed to in writing, shall any Contributor be
157 | liable to You for damages, including any direct, indirect, special,
158 | incidental, or consequential damages of any character arising as a
159 | result of this License or out of the use or inability to use the
160 | Work (including but not limited to damages for loss of goodwill,
161 | work stoppage, computer failure or malfunction, or any and all
162 | other commercial damages or losses), even if such Contributor
163 | has been advised of the possibility of such damages.
164 |
165 | 9. Accepting Warranty or Additional Liability. While redistributing
166 | the Work or Derivative Works thereof, You may choose to offer,
167 | and charge a fee for, acceptance of support, warranty, indemnity,
168 | or other liability obligations and/or rights consistent with this
169 | License. However, in accepting such obligations, You may act only
170 | on Your own behalf and on Your sole responsibility, not on behalf
171 | of any other Contributor, and only if You agree to indemnify,
172 | defend, and hold each Contributor harmless for any liability
173 | incurred by, or claims asserted against, such Contributor by reason
174 | of your accepting any such warranty or additional liability.
175 |
176 | END OF TERMS AND CONDITIONS
177 |
178 | APPENDIX: How to apply the Apache License to your work.
179 |
180 | To apply the Apache License to your work, attach the following
181 | boilerplate notice, with the fields enclosed by brackets "[]"
182 | replaced with your own identifying information. (Don't include
183 | the brackets!) The text should be enclosed in the appropriate
184 | comment syntax for the file format. We also recommend that a
185 | file or class name and description of purpose be included on the
186 | same "printed page" as the copyright notice for easier
187 | identification within third-party archives.
188 |
189 | Copyright 2023 Miha Kralj
190 |
191 | Licensed under the Apache License, Version 2.0 (the "License");
192 | you may not use this file except in compliance with the License.
193 | You may obtain a copy of the License at
194 |
195 | http://www.apache.org/licenses/LICENSE-2.0
196 |
197 | Unless required by applicable law or agreed to in writing, software
198 | distributed under the License is distributed on an "AS IS" BASIS,
199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200 | See the License for the specific language governing permissions and
201 | limitations under the License.
202 |
--------------------------------------------------------------------------------