├── .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 ""; } 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); return str; } BEGIN {FS=":"; action = "";} /\[.*\]/ { if (action != "") {print " "} action = substr($0, 2, length($0) - 2); print " <" action ">";} !/\[.*\]/ && NF > 1 { gsub(/^[ \t]+|[ \t]+$/, "", $2); value = escape_xml($2); print " <" $1 ">" value "";} END { if (action != "") {print " "} }' "$file"; echo " "; 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\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\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=$1; sub(/:$/, "", iface); print "\n<" iface ">"; next } { sub(/:$/, "", $1); key=$1; $1=""; gsub(/^ /, ""); printf "\n\t<%s>%s", key, $0, key } END { if (iface) print "\n"; }' && 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 | --------------------------------------------------------------------------------