diff --git a/.github/ISSUE_TEMPLATE/bug-issue-report.md b/.github/ISSUE_TEMPLATE/bug-issue-report.md new file mode 100644 index 0000000..190431b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug-issue-report.md @@ -0,0 +1,40 @@ +--- +name: Bug/Issue report +about: Create a report to help us improve +title: '' +labels: ['triage-needed'] +assignees: '' + +--- + +**Describe the problem** + +A clear and concise description of what the problem is. + +**To Reproduce** + +Steps to reproduce the behavior + +**Expected behavior** + +A clear and concise description of what you expected to happen. + +**Are you using NetBird Cloud?** + +Please specify whether you use NetBird Cloud or self-host NetBird's control plane. + +**NetBird version** + +`netbird version` + +**NetBird-GitOps version** + +version of this project used. + +**Screenshots** + +If applicable, add screenshots to help explain your problem. + +**Additional context** + +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..4a3e578 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: ['feature-request'] +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..ab23f17 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,11 @@ +## Describe your changes + +## Issue ticket number and link + +### Checklist +- [ ] Is it a bug fix +- [ ] Is a typo/documentation fix +- [ ] Is a feature enhancement +- [ ] It is a refactor +- [ ] Created tests that fail without the change (if possible) +- [ ] Extended the README / documentation, if necessary diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a679b19 --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +# Allowlisting gitignore template for GO projects prevents us +# from adding various unwanted local files, such as generated +# files, developer configurations or IDE-specific files etc. +# +# Recommended: Go.AllowList.gitignore + +# Ignore everything +* + +# But not these files... +!/.gitignore + +!*.go +!go.sum +!go.mod + +!*.md +!Dockerfile +!.github/**/* +!LICENSE +!CODEOWNERS +!/example/* + +# ...even if they are in subdirectories +!*/ diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 0000000..04631dd --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1 @@ +* @Instabug/infrastructure-team diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..9da4ed7 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,132 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socioeconomic status, +nationality, personal appearance, race, caste, color, religion, or sexual +identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall + community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or advances of + any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email address, + without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +messam@instabug.com. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of +actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or permanent +ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the +community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1, available at +[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at +[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at +[https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..da474b2 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,19 @@ +FROM golang:1.22.2 AS builder + +COPY . /go/src/github.com/instabug/netbird-gitops/ +WORKDIR /go/src/github.com/instabug/netbird-gitops/ +RUN set -Eeux && \ + go mod download && \ + go mod verify + +RUN GOOS=linux GOARCH=amd64 \ + go build \ + -o app cmd/ + +FROM alpine:3.17.1 +RUN apk --no-cache add ca-certificates +WORKDIR /root/ +COPY --from=builder /go/src/github.com/instabug/netbird-gitops/app . + +EXPOSE 8123 +ENTRYPOINT ["./app"] \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1e46b97 --- /dev/null +++ b/LICENSE @@ -0,0 +1,13 @@ +BSD 3-Clause License + +Copyright (c) 2022 Wiretrustee UG (haftungsbeschränkt) & AUTHORS + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..f80c800 --- /dev/null +++ b/README.md @@ -0,0 +1,252 @@ +# NetBird GitOps + +This program is made to synchronize [Netbird](https://netbird.io) configuration +with a source-controller git repository. + +## Installation + +You can deploy this as a container alongside NetBird management service or as a +standalone docker container + +### Docker Compose + +```yaml +services: + gitops: + image: instabug/netbird-gitops:latest + restart: unless-stopped + commands: + - --notify-services-path=/notify.yaml + volumes: + - ./notify.yaml:/ + # SSH key used in case of SSH auth method + # - ./key.pem:/key.pem + environment: + # Repository Clone URL + - GIT_AUTH_METHOD=basic # Valid options (none, basic, ssh) + - GIT_REPO_URL=https://github.com/Instabug/netbird-gitops.git + # Path within repository for configurations, leave empty for root + - GIT_RELATIVE_PATH=netbird-configs + # HTTPS Username (Set to anything in case of access token) + - GIT_USERNAME=someone + # HTTPS Password/Access Token + - GIT_PASSWORD=password + # Uncomment in case of SSH key + # - GIT_PRIVATE_KEY_PATH=/key.pem + # - GIT_PRIVATE_KEY_PASSWORD=somepassword + - NETBIRD_TOKEN=abcdef + - NETBIRD_MANAGEMENT_API=https://api.netbird.io + - LOG_LEVEL=info + +``` + +## Configuration + +Configuration files are written in YAML and can be written in 1 or more files +within the directory specified + +### Schema + +You can check the [example](./example) for a configuration example. + +> Note: All Group, and PostureCheck names are names, not IDs, NetBird GitOps does the translation + +#### NetBird GitOps Config + +Configuration for NetBird GitOps itself + +```yaml +config: + # autoSync behavior + # - manual: only sync if --sync-and-exit is set + # - update: only sync if Git is updated + # - enforce: always sync + autoSync: ("manual", "update", "enforce") + # Set peer groups individually + # When set to false, peers that belong to users are given the user's autogroups + individualPeerGroups: false +``` + +#### DNS Settings + +Configuration for NetBird DNS + +```yaml +dns: + disable_for: + - group1 + - group2 + +nameservers: +- name: Google DNS + description: Google DNS servers + nameservers: + - ip: 8.8.8.8 + ns_type: udp + port: 53 + enabled: true + groups: + - group1 + - group2 + primary: true + domains: + - example.com + search_domains_enabled: true +``` + +#### Network Routes + +```yaml +network_routes: +- network_type: ("IPv4"|"IPv6"|"Domain") + description: Route Description # Optional + network_id: Route 1 # Required + enabled: true # Optional, defaults to false + # peer_groups and peer are mutually exclusive + peer_groups: # Optional, must be set if peer is not set + - g2 + peer: c2312515613213 # Optional, must be set if peer_groups not set + # domains and network are mutually exclusive + domains: + - example.com + network: 0.0.0.0/0 + metric: 9999 # Required + masquerade: true # Optional, defaults to false + groups: # Required + - g1 + keep_route: true # Optional, deafults to false +``` + +#### Peers + +Since peers cannot be added from API, this is used to manage Peer Groups and settings + +```yaml +peers: +- id: cr6ibk8pcsa9d3fncct0 # Required + name: "Test" # Required + groups: # Optional, All is implicitly included + - g2 + ssh_enabled: true # Optional, defaults to false + expiration_disabled: true # Optional, defaults to false +``` + +#### Policies + +```yaml +policies: +- name: Production # Required + description: Production machines access # Required + enabled: false # Optional, defaults to false + source_posture_checks: # Optional + - pc1 + action: accept # Required + bidirectional: false # Optional, defaults to false + protocol: all # Required (all|tcp|udp|icmp) + sources: # Required + - g1 + destinations: # Required + - g3 +``` + +#### Posture Checks + +```yaml +posture_checks: +- name: pc1 # Required + description: Something # Required + checks: + nb_version_check: # Optional + min_version: "14.3" # Required + os_version_check: # Optional + android: # Optional + min_version: "13" # Required + ios: # Optional + min_version: 17.3.1 # Required + darwin: # Optional + min_version: 14.2.1 # Required + linux: # Optional + min_kernel_version: 5.3.3 # Required + windows: # Optional + min_kernel_version: 10.0.1234 # Required + geo_location_check: # Optional + locations: # Required + - country_code: DE # Required + city_name: Berlin # Optional + action: allow # Required (allow|block) + peer_network_range_check: # Optional + ranges: # Required + - 192.168.1.0/24 + - 10.0.0.0/8 + - 2001:db8:1234:1a00::/56 + action: allow # Required (allow|block) + process_check: # Optional + processes: # Required + - linux_path: /usr/local/bin/netbird # Optional + mac_path: /Applications/NetBird.app/Contents/MacOS/netbird # Optional + windows_path: "C:\ProgramData\\NetBird\\netbird.exe" # Optional +``` + +#### Users + +```yaml +users: +- email: someone@somewhere.com # Required + groups: # Required + - g1 + - g2 + role: admin # Optional, defaults to user (user|admin|owner) +``` + +### Notification Services + +This projects supports sending notifications to any services supported by [nikoksr/notify](https://github.com/nikoksr/notify), however only Slack is implemented currently. + +#### Configuration schema + +Configuring notification services exists in `notify.yaml` by default and can be overridden with `--notify-services-path` + +```yaml +slack: + token: xoxb-.... + channels: + - channel-a + - channel-b +``` + +## Usage + +netbird-gitops can run in enforce mode where only Git configuration is the source of truth, it also supports manual syncing through the `--sync-and-exit` flag, which will pull the configuration, apply them and exit. + +```bash + -git-auth-method string + basic (username-password/access token), or ssh (private key), or none (default "none") + -git-branch string + Name of branch to pull changes from (default "main") + -git-password string + git basic auth password, must be defined if --git-auth-method is basic + -git-private-key-password string + git SSH private key password (if any) + -git-private-key-path string + git SSH private key path, must be defined if --git-auth-method is ssh + -git-relative-path string + Relative path of NetBird configuration within the git repo + -git-repo-url string + Git Repo URL (ssh/https) (Required) + -git-username string + git basic auth username, must be defined if --git-auth-method is basic + -log-level string + Log level (debug, info, warn, error) + -netbird-mgmt-api string + NetBird Management API URL + -netbird-token string + NetBird Management API token (default "nbp_woIGracLxicjqDafocrFpKPZYO4KCN3HOcE5") + -notify-services-path string + Path to notification services configuration yaml (default "notify.yaml") + -sync-and-exit + Force sync once and exit +``` + +## Legal + +NetBird is a [registered trademark](https://netbird.io/terms) of [Wiretrustee UG (haftungsbeschränkt)](https://netbird.io/) & [AUTHORS](https://github.com/netbirdio/netbird/blob/main/AUTHORS) \ No newline at end of file diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..ca8dec4 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,12 @@ +# Security Policy + +NetBird's goal is to provide a secure network. If you find a vulnerability or bug, please report it by opening an issue [here](https://github.com/instabug/netbird-gitops/issues/new?assignees=&labels=&template=bug-issue-report.md&title=) or by contacting us by email. + +There has yet to be an official bug bounty program for the NetBird-GitOps project. + +## Supported Versions +- We currently support only the latest version + +## Reporting a Vulnerability + +Please report security issues to `messam@instabug.com` diff --git a/cmd/main.go b/cmd/main.go new file mode 100644 index 0000000..b06c8db --- /dev/null +++ b/cmd/main.go @@ -0,0 +1,142 @@ +package main + +import ( + "context" + "flag" + "fmt" + "log/slog" + "os" + "os/signal" + "syscall" + + "github.com/Instabug/netbird-gitops/pkg/controller" + "github.com/go-git/go-git/v5/plumbing/transport" + "github.com/go-git/go-git/v5/plumbing/transport/http" + "github.com/go-git/go-git/v5/plumbing/transport/ssh" +) + +func envDefault(envVar, def string) string { + if v, ok := os.LookupEnv(envVar); ok { + return v + } + return def +} + +var ( + gitRepoURL = flag.String("git-repo-url", os.Getenv("GIT_REPO_URL"), "Git Repo URL (ssh/https) (Required)") + gitRelativePath = flag.String("git-relative-path", os.Getenv("GIT_RELATIVE_PATH"), "Relative path of NetBird configuration within the git repo") + gitBranch = flag.String("git-branch", envDefault("GIT_BRANCH", "main"), "Name of branch to pull changes from") + gitAuthMethod = flag.String("git-auth-method", envDefault("GIT_AUTH_METHOD", "none"), "basic (username-password/access token), or ssh (private key), or none") + gitUsername = flag.String("git-username", os.Getenv("GIT_USERNAME"), "git basic auth username, must be defined if --git-auth-method is basic") + gitPassword = flag.String("git-password", os.Getenv("GIT_PASSWORD"), "git basic auth password, must be defined if --git-auth-method is basic") + gitPrivateKeyPath = flag.String("git-private-key-path", os.Getenv("GIT_PRIVATE_KEY_PATH"), "git SSH private key path, must be defined if --git-auth-method is ssh") + gitPrivateKeyPassword = flag.String("git-private-key-password", os.Getenv("GIT_PRIVATE_KEY_PASSWORD"), "git SSH private key password (if any)") + netbirdToken = flag.String("netbird-token", os.Getenv("NETBIRD_TOKEN"), "NetBird Management API token") + netbirdManagementAPI = flag.String("netbird-mgmt-api", os.Getenv("NETBIRD_MANAGEMENT_API"), "NetBird Management API URL") + logLevel = flag.String("log-level", os.Getenv("LOG_LEVEL"), "Log level (debug, info, warn, error)") + syncExit = flag.Bool("sync-and-exit", false, "Force sync once and exit") + notifyServicesPath = flag.String("notify-services-path", "notify.yaml", "Path to notification services configuration yaml") +) + +func main() { + flag.Parse() + + level := slog.LevelInfo + if *logLevel != "" { + if err := level.UnmarshalText([]byte(*logLevel)); err != nil { + slog.Warn("Error setting log level", "err", err) + } + } + slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ + Level: level, + AddSource: true, + }))) + + if *netbirdToken == "" { + flag.PrintDefaults() + fmt.Println("--netbird-token is required") + os.Exit(1) + } + + if *netbirdManagementAPI == "" { + flag.PrintDefaults() + fmt.Println("--netbird-mgmt-api is required") + os.Exit(1) + } + + if *gitRepoURL == "" { + flag.PrintDefaults() + fmt.Println("--git-repo-url is required") + os.Exit(1) + } + + var gitAuth transport.AuthMethod + switch *gitAuthMethod { + case "basic": + if *gitUsername == "" || *gitPassword == "" { + flag.PrintDefaults() + fmt.Println("--git-auth-method is basic, but one of --git-username or --git-password is empty") + os.Exit(1) + } + gitAuth = &http.BasicAuth{ + Username: *gitUsername, + Password: *gitPassword, + } + break + case "ssh": + if *gitPrivateKeyPath == "" { + flag.PrintDefaults() + panic("--git-auth-method is ssh, but --git-private-key-path is empty") + } + _, err := os.Stat(*gitPrivateKeyPath) + if err != nil { + fmt.Println("private key not found") + os.Exit(1) + } + publicKeys, err := ssh.NewPublicKeysFromFile("git", *gitPrivateKeyPath, *gitPrivateKeyPath) + if err != nil { + slog.Error("Error loading private key", "path", gitPrivateKeyPath, "err", err, "using_password", *gitPrivateKeyPassword != "") + fmt.Println("Could not load private key") + os.Exit(1) + } + gitAuth = publicKeys + break + case "none": + break + default: + flag.PrintDefaults() + fmt.Println("Unknown --git-auth-method") + os.Exit(1) + } + + err := setupNotifiers() + if err != nil { + slog.Warn("Error setting up notifications", "err", err) + } + + ctrl := controller.NewController(controller.Options{ + GitRepoURL: *gitRepoURL, + GitRelativePath: *gitRelativePath, + GitBranch: *gitBranch, + GitAuth: gitAuth, + NetBirdToken: *netbirdToken, + NetBirdAPI: *netbirdManagementAPI, + SyncOnceAndExit: *syncExit, + }) + + ctx, cancel := context.WithCancel(context.Background()) + + sigc := make(chan os.Signal, 1) + signal.Notify(sigc, syscall.SIGTERM, syscall.SIGINT) + go func() { + <-sigc + cancel() + slog.Warn("Signal received, shutting down") + os.Exit(0) + }() + + if err := ctrl.Start(ctx); err != nil { + panic(err) + } + sigc <- os.Interrupt +} diff --git a/cmd/notifications.go b/cmd/notifications.go new file mode 100644 index 0000000..62c6ebb --- /dev/null +++ b/cmd/notifications.go @@ -0,0 +1,61 @@ +package main + +import ( + "fmt" + "log/slog" + "os" + + "github.com/nikoksr/notify" + "github.com/nikoksr/notify/service/slack" + "gopkg.in/yaml.v3" +) + +var notifiers = map[string]func(map[string]interface{}) error{ + "slack": setupSlack, +} + +func setupNotifiers() error { + configBytes, err := os.ReadFile(*notifyServicesPath) + if err != nil { + return fmt.Errorf("Failed to read services file: %w", err) + } + config := make(map[string]map[string]interface{}) + err = yaml.Unmarshal(configBytes, &config) + + if err != nil { + return fmt.Errorf("Failed to read services file: %w", err) + } + + for k, v := range notifiers { + if cfg, ok := config[k]; ok { + err = v(cfg) + if err != nil { + slog.Warn("Error setting up service", "service", "slack", "err", err) + } + } + } + return nil +} + +func setupSlack(cfg map[string]interface{}) error { + token, ok := cfg["token"].(string) + if !ok { + return fmt.Errorf("Invalid value for slack.token: %v", cfg["token"]) + } + svc := slack.New(token) + receiversIface, ok := cfg["channels"].([]interface{}) + if !ok { + return fmt.Errorf("Invalid value for slack.channels: %v", cfg["channels"]) + } + var receivers []string + for idx, v := range receiversIface { + val, ok := v.(string) + if !ok { + return fmt.Errorf("Invalid value for slack.channels[%d]: %v", idx, v) + } + receivers = append(receivers, val) + } + svc.AddReceivers(receivers...) + notify.UseServices(svc) + return nil +} diff --git a/example/config.yaml b/example/config.yaml new file mode 100644 index 0000000..b97cf84 --- /dev/null +++ b/example/config.yaml @@ -0,0 +1,3 @@ +config: + autoSync: false + individualPeerGroups: true diff --git a/example/nameservers.yaml b/example/nameservers.yaml new file mode 100644 index 0000000..d305324 --- /dev/null +++ b/example/nameservers.yaml @@ -0,0 +1,18 @@ +dns: + disable_for: + - g3 + +nameservers: +- name: Google DNS + description: Google DNS servers + nameservers: + - ip: 8.8.8.8 + ns_type: udp + port: 53 + enabled: true + groups: + - g1 + primary: true + domains: + - example.com + search_domains_enabled: true diff --git a/example/network_routes.yaml b/example/network_routes.yaml new file mode 100644 index 0000000..d46b486 --- /dev/null +++ b/example/network_routes.yaml @@ -0,0 +1,14 @@ +network_routes: +- network_type: IPv4 + description: My first route + network_id: Route 1 + enabled: true + peer_groups: + - g2 + domains: + - example.com + metric: 9999 + masquerade: true + groups: + - g1 + keep_route: true diff --git a/example/peers.yaml b/example/peers.yaml new file mode 100644 index 0000000..14bb713 --- /dev/null +++ b/example/peers.yaml @@ -0,0 +1,5 @@ +peers: +- id: cr6ibk8pcsa9d3fncct0 + name: "Test" + groups: + - g2 \ No newline at end of file diff --git a/example/policies.yaml b/example/policies.yaml new file mode 100644 index 0000000..a2a0990 --- /dev/null +++ b/example/policies.yaml @@ -0,0 +1,13 @@ +policies: +- name: Production + description: Production machines access + enabled: false + source_posture_checks: + - pc1 + action: accept + bidirectional: false + protocol: all + sources: + - g1 + destinations: + - g3 diff --git a/example/posture_checks.yaml b/example/posture_checks.yaml new file mode 100644 index 0000000..bbae350 --- /dev/null +++ b/example/posture_checks.yaml @@ -0,0 +1,33 @@ +posture_checks: +- name: pc1 + description: This checks if the peer is running required NetBird's version + checks: + nb_version_check: + min_version: "14.3" + os_version_check: + android: + min_version: "13" + ios: + min_version: 17.3.1 + darwin: + min_version: 14.2.1 + linux: + min_kernel_version: 5.3.3 + windows: + min_kernel_version: 10.0.1234 + geo_location_check: + locations: + - country_code: DE + city_name: Berlin + action: allow + peer_network_range_check: + ranges: + - 192.168.1.0/24 + - 10.0.0.0/8 + - 2001:db8:1234:1a00::/56 + action: allow + process_check: + processes: + - linux_path: /usr/local/bin/netbird + mac_path: /Applications/NetBird.app/Contents/MacOS/netbird + windows_path: "C:\ProgramData\\NetBird\\netbird.exe" \ No newline at end of file diff --git a/example/users.yaml b/example/users.yaml new file mode 100644 index 0000000..faaed39 --- /dev/null +++ b/example/users.yaml @@ -0,0 +1,9 @@ +users: +- email: someone@somewhere.com + groups: + - g1 + - g2 + role: admin +- email: another@somewhere.com + groups: + - g3 \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..0a5ff5a --- /dev/null +++ b/go.mod @@ -0,0 +1,42 @@ +module github.com/Instabug/netbird-gitops + +go 1.22 + +toolchain go1.22.6 + +require ( + github.com/go-git/go-git/v5 v5.12.0 + github.com/nikoksr/notify v1.0.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + dario.cat/mergo v1.0.0 // indirect + github.com/Microsoft/go-winio v0.6.1 // indirect + github.com/ProtonMail/go-crypto v1.0.0 // indirect + github.com/cloudflare/circl v1.3.7 // indirect + github.com/cyphar/filepath-securejoin v0.2.4 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/emirpasic/gods v1.18.1 // indirect + github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect + github.com/go-git/go-billy/v5 v5.5.0 // indirect + github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect + github.com/gorilla/websocket v1.5.3 // indirect + github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect + github.com/kevinburke/ssh_config v1.2.0 // indirect + github.com/pjbgf/sha1cd v0.3.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect + github.com/skeema/knownhosts v1.2.2 // indirect + github.com/slack-go/slack v0.13.1 // indirect + github.com/stretchr/objx v0.5.2 // indirect + github.com/stretchr/testify v1.9.0 // indirect + github.com/xanzy/ssh-agent v0.3.3 // indirect + golang.org/x/crypto v0.25.0 // indirect + golang.org/x/mod v0.12.0 // indirect + golang.org/x/net v0.27.0 // indirect + golang.org/x/sync v0.7.0 // indirect + golang.org/x/sys v0.22.0 // indirect + golang.org/x/tools v0.13.0 // indirect + gopkg.in/warnings.v0 v0.1.2 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..c58fb3b --- /dev/null +++ b/go.sum @@ -0,0 +1,160 @@ +dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= +dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= +github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow= +github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM= +github.com/ProtonMail/go-crypto v1.0.0 h1:LRuvITjQWX+WIfr930YHG2HNfjR1uOfyf5vE0kC2U78= +github.com/ProtonMail/go-crypto v1.0.0/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= +github.com/bwesterb/go-ristretto v1.2.3/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0= +github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA= +github.com/cloudflare/circl v1.3.7 h1:qlCDlTPz2n9fu58M0Nh1J/JzcFpfgkFHHX3O35r5vcU= +github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBSc8r4zxgA= +github.com/cyphar/filepath-securejoin v0.2.4 h1:Ugdm7cg7i6ZK6x3xDF1oEu1nfkyfH53EtKeQYTC3kyg= +github.com/cyphar/filepath-securejoin v0.2.4/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/elazarl/goproxy v0.0.0-20230808193330-2592e75ae04a h1:mATvB/9r/3gvcejNsXKSkQ6lcIaNec2nyfOdlTBR2lU= +github.com/elazarl/goproxy v0.0.0-20230808193330-2592e75ae04a/go.mod h1:Ro8st/ElPeALwNFlcTpWmkr6IoMFfkjXAvTHpevnDsM= +github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= +github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= +github.com/gliderlabs/ssh v0.3.7 h1:iV3Bqi942d9huXnzEF2Mt+CY9gLu8DNM4Obd+8bODRE= +github.com/gliderlabs/ssh v0.3.7/go.mod h1:zpHEXBstFnQYtGnB8k8kQLol82umzn/2/snG7alWVD8= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= +github.com/go-git/go-billy/v5 v5.5.0 h1:yEY4yhzCDuMGSv83oGxiBotRzhwhNr8VZyphhiu+mTU= +github.com/go-git/go-billy/v5 v5.5.0/go.mod h1:hmexnoNsr2SJU1Ju67OaNz5ASJY3+sHgFRpCtpDCKow= +github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= +github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= +github.com/go-git/go-git/v5 v5.12.0 h1:7Md+ndsjrzZxbddRDZjF14qK+NN56sy6wkqaVrjZtys= +github.com/go-git/go-git/v5 v5.12.0/go.mod h1:FTM9VKtnI2m65hNI/TenDDDnUf2Q9FHnXYjuz9i5OEY= +github.com/go-test/deep v1.0.4 h1:u2CU3YKy9I2pmu9pX0eq50wCgjfGIt539SqR7FbHiho= +github.com/go-test/deep v1.0.4/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= +github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible h1:jdpOPRN1zP63Td1hDQbZW73xKmzDvZHzVdNYxhnTMDA= +github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible/go.mod h1:1c7szIrayyPPB/987hsnvNzLushdWf4o/79s3P08L8A= +github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= +github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/nikoksr/notify v1.0.0 h1:qe9/6FRsWdxBgQgWcpvQ0sv8LRGJZDpRB4TkL2uNdO8= +github.com/nikoksr/notify v1.0.0/go.mod h1:hPaaDt30d6LAA7/5nb0e48Bp/MctDfycCSs8VEgN29I= +github.com/onsi/gomega v1.27.10 h1:naR28SdDFlqrG6kScpT8VWpu1xWY5nJRCF3XaYyBjhI= +github.com/onsi/gomega v1.27.10/go.mod h1:RsS8tutOdbdgzbPtzzATp12yT7kM5I5aElG3evPbQ0M= +github.com/pjbgf/sha1cd v0.3.0 h1:4D5XXmUUBUl/xQ6IjCkEAbqXskkq/4O7LmGn0AqMDs4= +github.com/pjbgf/sha1cd v0.3.0/go.mod h1:nZ1rrWOcGJ5uZgEEVL1VUM9iRQiZvWdbZjkKyFzPPsI= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= +github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= +github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= +github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= +github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= +github.com/skeema/knownhosts v1.2.2 h1:Iug2P4fLmDw9f41PB6thxUkNUkJzB5i+1/exaj40L3A= +github.com/skeema/knownhosts v1.2.2/go.mod h1:xYbVRSPxqBZFrdmDyMmsOs+uX1UZC3nTN3ThzgDxUwo= +github.com/slack-go/slack v0.13.1 h1:6UkM3U1OnbhPsYeb1IMkQ6HSNOSikWluwOncJt4Tz/o= +github.com/slack-go/slack v0.13.1/go.mod h1:hlGi5oXA+Gt+yWTPP0plCdRKmjsDxecdHxYQdlMQKOw= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= +github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= +golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= +golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30= +golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0 h1:rmsUpXtvNzj340zd98LZ4KntptpfRHwpFOHG188oHXc= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= +golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys= +golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= +golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= +golang.org/x/term v0.22.0 h1:BbsgPEJULsl2fV/AT3v15Mjva5yXKQDyKf+TbDz7QJk= +golang.org/x/term v0.22.0/go.mod h1:F3qCibpT5AMpCRfhfT53vVJwhLtIVHhB9XDjfFvnMI4= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= +golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0 h1:Iey4qkscZuv0VvIt8E0neZjtPVQFSc870HQ448QgEmQ= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= +gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/pkg/client/client.go b/pkg/client/client.go new file mode 100644 index 0000000..cb204cc --- /dev/null +++ b/pkg/client/client.go @@ -0,0 +1,78 @@ +package client + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "strings" + "time" +) + +// Client NetBird API Client +type Client struct { + managementAPI string + token string + client *http.Client + DryRun bool +} + +// NewClient returns a new NetBird API Client +func NewClient(managementAPI, token string, dryRun bool) *Client { + managementAPI = strings.TrimSuffix(managementAPI, "/") + return &Client{ + managementAPI: managementAPI, + token: token, + client: http.DefaultClient, + DryRun: dryRun, + } +} + +func (c Client) doRequest(ctx context.Context, method, resource string, body interface{}) ([]byte, error) { + t1 := time.Now() + slog.Info(method+" /api/"+resource, "body", body) + var bodyReader io.Reader + + if body != nil { + bodyBytes, err := json.Marshal(body) + if err != nil { + return nil, err + } + + bodyReader = bytes.NewReader(bodyBytes) + } + + req, err := http.NewRequest(method, c.managementAPI+"/api/"+resource, bodyReader) + if err != nil { + return nil, err + } + + if body != nil { + req.Header.Add("Content-Type", "application/json") + } + req.Header.Add("Accept", "application/json") + req.Header.Add("Authorization", "Token "+c.token) + req = req.WithContext(ctx) + + resp, err := c.client.Do(req) + if err != nil { + return nil, err + } + + respBytes, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + slog.Debug(method+" /api/"+resource, "response", string(respBytes), "time", time.Since(t1)) + slog.Info(method+" /api/"+resource, "response_code", resp.StatusCode, "time", time.Since(t1), "content_size", len(respBytes)) + + if resp.StatusCode > 299 { + return nil, fmt.Errorf("status code %d", resp.StatusCode) + } + + return respBytes, nil +} diff --git a/pkg/client/dns.go b/pkg/client/dns.go new file mode 100644 index 0000000..970e806 --- /dev/null +++ b/pkg/client/dns.go @@ -0,0 +1,111 @@ +package client + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/Instabug/netbird-gitops/pkg/data" +) + +// GetDNSSettings Get NetBird DNS settings +func (c Client) GetDNSSettings(ctx context.Context) (data.DNSResponse, error) { + respBytes, err := c.doRequest(ctx, "GET", "dns/settings", nil) + if err != nil { + return data.DNSResponse{}, fmt.Errorf("NetBird API: GetDNSSettings: %w", err) + } + var ret data.DNSResponse + + err = json.Unmarshal(respBytes, &ret) + if err != nil { + return data.DNSResponse{}, fmt.Errorf("NetBird API: GetDNSSettings: %w", err) + } + + return ret, nil +} + +// UpdateDNSSettings Update NetBird DNS settings +func (c Client) UpdateDNSSettings(ctx context.Context, settings data.DNS) error { + if c.DryRun { + return nil + } + + body, err := json.Marshal(settings) + if err != nil { + return err + } + + _, err = c.doRequest(ctx, "POST", "dns/settings", body) + if err != nil { + return fmt.Errorf("NetBird API: UpdateDNSSettings: %w", err) + } + + return nil +} + +// ListNameservers List DNS Nameservers +func (c Client) ListNameservers(ctx context.Context) ([]data.Nameserver, error) { + respBytes, err := c.doRequest(ctx, "GET", "dns/nameservers", nil) + if err != nil { + return nil, fmt.Errorf("NetBird API: ListNameservers: %w", err) + } + var ret []data.Nameserver + + err = json.Unmarshal(respBytes, &ret) + if err != nil { + return nil, fmt.Errorf("NetBird API: ListNameservers: %w", err) + } + + return ret, nil +} + +// UpdateNameserver updates a single NetBird nameserver +func (c Client) UpdateNameserver(ctx context.Context, nameserver data.Nameserver) error { + if c.DryRun { + return nil + } + + body, err := json.Marshal(nameserver) + if err != nil { + return err + } + + _, err = c.doRequest(ctx, "PUT", "dns/nameservers/"+nameserver.ID, body) + if err != nil { + return fmt.Errorf("NetBird API: UpdateNameserver: %w", err) + } + return nil +} + +// CreateNameserver updates a single NetBird nameserver +func (c Client) CreateNameserver(ctx context.Context, nameserver data.Nameserver) error { + if c.DryRun { + nameserver.ID = nameserver.Name + return nil + } + + body, err := json.Marshal(nameserver) + if err != nil { + return err + } + + _, err = c.doRequest(ctx, "POST", "dns/nameservers", body) + if err != nil { + return fmt.Errorf("NetBird API: CreateNameserver: %w", err) + } + + return nil +} + +// DeleteNameserver updates a single NetBird nameserver +func (c Client) DeleteNameserver(ctx context.Context, nameserver data.Nameserver) error { + if c.DryRun { + return nil + } + + _, err := c.doRequest(ctx, "DELETE", "dns/nameservers/"+nameserver.ID, nil) + if err != nil { + return fmt.Errorf("NetBird API: DeleteNameserver: %w", err) + } + return nil +} diff --git a/pkg/client/groups.go b/pkg/client/groups.go new file mode 100644 index 0000000..9389213 --- /dev/null +++ b/pkg/client/groups.go @@ -0,0 +1,82 @@ +package client + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/Instabug/netbird-gitops/pkg/data" +) + +// ListGroups lists all NetBird groups +func (c Client) ListGroups(ctx context.Context) ([]data.Group, error) { + respBytes, err := c.doRequest(ctx, "GET", "groups", nil) + if err != nil { + return nil, fmt.Errorf("NetBird API: ListGroups: %w", err) + } + var ret []data.Group + + err = json.Unmarshal(respBytes, &ret) + if err != nil { + return nil, fmt.Errorf("NetBird API: ListGroups: %w", err) + } + + return ret, nil +} + +// CreateGroup create NetBird Group +func (c Client) CreateGroup(ctx context.Context, group data.Group) (data.Group, error) { + if c.DryRun { + group.ID = group.Name + return group, nil + } + body := map[string]interface{}{ + "name": group.Name, + } + + respBytes, err := c.doRequest(ctx, "POST", "groups", body) + if err != nil { + return data.Group{}, fmt.Errorf("NetBird API: CreateGroup: %w", err) + } + + var ret data.Group + + err = json.Unmarshal(respBytes, &ret) + if err != nil { + return ret, fmt.Errorf("NetBird API: CreateGroup: %w", err) + } + + return ret, nil +} + +// UpdateGroup update NetBird Group +func (c Client) UpdateGroup(ctx context.Context, group data.Group) error { + if c.DryRun { + return nil + } + body := map[string]interface{}{ + "name": group.Name, + "peers": group.Peers, + } + + _, err := c.doRequest(ctx, "PUT", "groups/"+group.ID, body) + if err != nil { + return fmt.Errorf("NetBird API: CreateGroup: %w", err) + } + + return nil +} + +// DeleteGroup delete NetBird Group +func (c Client) DeleteGroup(ctx context.Context, group data.Group) error { + if c.DryRun { + return nil + } + + _, err := c.doRequest(ctx, "DELETE", "groups/"+group.ID, nil) + if err != nil { + return fmt.Errorf("NetBird API: DeleteGroup: %w", err) + } + + return nil +} diff --git a/pkg/client/peers.go b/pkg/client/peers.go new file mode 100644 index 0000000..de5ad6f --- /dev/null +++ b/pkg/client/peers.go @@ -0,0 +1,45 @@ +package client + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/Instabug/netbird-gitops/pkg/data" +) + +// ListPeers lists all NetBird peers +func (c Client) ListPeers(ctx context.Context) ([]data.Peer, error) { + respBytes, err := c.doRequest(ctx, "GET", "peers", nil) + if err != nil { + return nil, fmt.Errorf("NetBird API: ListPeers: %w", err) + } + var ret []data.Peer + + err = json.Unmarshal(respBytes, &ret) + if err != nil { + return nil, fmt.Errorf("NetBird API: ListPeers: %w", err) + } + + return ret, nil +} + +// UpdatePeer updates a single NetBird user +func (c Client) UpdatePeer(ctx context.Context, peer data.Peer) error { + if c.DryRun { + return nil + } + + body := map[string]interface{}{ + "name": peer.Name, + "ssh_enabled": peer.SSHEnabled, + "login_expiration_enabled": !peer.ExpirationDisabled, + } + + _, err := c.doRequest(ctx, "PUT", "peers/"+peer.ID, body) + if err != nil { + return fmt.Errorf("NetBird API: UpdatePeer: %w", err) + } + + return nil +} diff --git a/pkg/client/policies.go b/pkg/client/policies.go new file mode 100644 index 0000000..5deeca3 --- /dev/null +++ b/pkg/client/policies.go @@ -0,0 +1,108 @@ +package client + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/Instabug/netbird-gitops/pkg/data" +) + +// ListPolicies lists all NetBird policies +func (c Client) ListPolicies(ctx context.Context) ([]data.Policy, error) { + respBytes, err := c.doRequest(ctx, "GET", "policies", nil) + if err != nil { + return nil, fmt.Errorf("NetBird API: ListPolicies: %w", err) + } + var ret []data.Policy + + err = json.Unmarshal(respBytes, &ret) + if err != nil { + return nil, fmt.Errorf("NetBird API: ListPolicies: %w", err) + } + + for idx := range ret { + ret[idx].Flatten() + } + + return ret, nil +} + +// UpdatePolicy updates a single NetBird policy +func (c Client) UpdatePolicy(ctx context.Context, policy data.Policy) error { + if c.DryRun { + return nil + } + + body := map[string]interface{}{ + "name": policy.Name, + "description": policy.Description, + "enabled": policy.Enabled, + "source_posture_checks": policy.SourcePostureChecks, + "rules": []map[string]interface{}{ + { + "name": policy.Name, + "description": policy.Description, + "enabled": policy.Enabled, + "action": policy.Action, + "bidirectional": policy.Bidirectional, + "protocol": policy.Protocol, + "ports": policy.Ports, + "sources": policy.Sources, + "destinations": policy.Destinations, + }, + }, + } + + _, err := c.doRequest(ctx, "PUT", "policies/"+policy.ID, body) + if err != nil { + return fmt.Errorf("NetBird API: UpdatePolicy: %w", err) + } + return nil +} + +// CreatePolicy updates a single NetBird policy +func (c Client) CreatePolicy(ctx context.Context, policy data.Policy) error { + if c.DryRun { + return nil + } + + body := map[string]interface{}{ + "name": policy.Name, + "description": policy.Description, + "enabled": policy.Enabled, + "source_posture_checks": policy.SourcePostureChecks, + "rules": []map[string]interface{}{ + { + "name": policy.Name, + "description": policy.Description, + "enabled": policy.Enabled, + "action": policy.Action, + "bidirectional": policy.Bidirectional, + "protocol": policy.Protocol, + "ports": policy.Ports, + "sources": policy.Sources, + "destinations": policy.Destinations, + }, + }, + } + + _, err := c.doRequest(ctx, "POST", "policies", body) + if err != nil { + return fmt.Errorf("NetBird API: CreatePolicy: %w", err) + } + return nil +} + +// DeletePolicy updates a single NetBird policy +func (c Client) DeletePolicy(ctx context.Context, policy data.Policy) error { + if c.DryRun { + return nil + } + + _, err := c.doRequest(ctx, "DELETE", "policies/"+policy.ID, nil) + if err != nil { + return fmt.Errorf("NetBird API: DeletePolicy: %w", err) + } + return nil +} diff --git a/pkg/client/posture_checks.go b/pkg/client/posture_checks.go new file mode 100644 index 0000000..369b501 --- /dev/null +++ b/pkg/client/posture_checks.go @@ -0,0 +1,83 @@ +package client + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/Instabug/netbird-gitops/pkg/data" +) + +// ListPostureChecks lists all NetBird posture-checks +func (c Client) ListPostureChecks(ctx context.Context) ([]data.PostureCheck, error) { + respBytes, err := c.doRequest(ctx, "GET", "posture-checks", nil) + if err != nil { + return nil, fmt.Errorf("NetBird API: ListPostureChecks: %w", err) + } + var ret []data.PostureCheck + + err = json.Unmarshal(respBytes, &ret) + if err != nil { + return nil, fmt.Errorf("NetBird API: ListPostureChecks: %w", err) + } + + return ret, nil +} + +// UpdatePostureCheck updates a single NetBird postureCheck +func (c Client) UpdatePostureCheck(ctx context.Context, postureCheck data.PostureCheck) error { + if c.DryRun { + return nil + } + + body, err := json.Marshal(postureCheck) + if err != nil { + return err + } + + _, err = c.doRequest(ctx, "PUT", "posture-checks/"+postureCheck.ID, body) + if err != nil { + return fmt.Errorf("NetBird API: UpdatePostureCheck: %w", err) + } + return nil +} + +// CreatePostureCheck updates a single NetBird postureCheck +func (c Client) CreatePostureCheck(ctx context.Context, postureCheck data.PostureCheck) (data.PostureCheck, error) { + if c.DryRun { + postureCheck.ID = postureCheck.Name + return postureCheck, nil + } + + body, err := json.Marshal(postureCheck) + if err != nil { + return data.PostureCheck{}, err + } + + respBytes, err := c.doRequest(ctx, "POST", "posture-checks", body) + if err != nil { + return data.PostureCheck{}, fmt.Errorf("NetBird API: CreatePostureCheck: %w", err) + } + + var ret data.PostureCheck + + err = json.Unmarshal(respBytes, &ret) + if err != nil { + return ret, fmt.Errorf("NetBird API: CreatePostureCheck: %w", err) + } + + return ret, nil +} + +// DeletePostureCheck updates a single NetBird postureCheck +func (c Client) DeletePostureCheck(ctx context.Context, postureCheck data.PostureCheck) error { + if c.DryRun { + return nil + } + + _, err := c.doRequest(ctx, "DELETE", "posture-checks/"+postureCheck.ID, nil) + if err != nil { + return fmt.Errorf("NetBird API: DeletePostureCheck: %w", err) + } + return nil +} diff --git a/pkg/client/routes.go b/pkg/client/routes.go new file mode 100644 index 0000000..c48a13c --- /dev/null +++ b/pkg/client/routes.go @@ -0,0 +1,92 @@ +package client + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/Instabug/netbird-gitops/pkg/data" +) + +// ListNetworkRoutes lists all NetBird routes +func (c Client) ListNetworkRoutes(ctx context.Context) ([]data.NetworkRoute, error) { + respBytes, err := c.doRequest(ctx, "GET", "routes", nil) + if err != nil { + return nil, fmt.Errorf("NetBird API: ListNetworkRoutes: %w", err) + } + var ret []data.NetworkRoute + + err = json.Unmarshal(respBytes, &ret) + if err != nil { + return nil, fmt.Errorf("NetBird API: ListNetworkRoutes: %w", err) + } + + return ret, nil +} + +// UpdateNetworkRoute updates a single NetBird route +func (c Client) UpdateNetworkRoute(ctx context.Context, route data.NetworkRoute) error { + if c.DryRun { + return nil + } + + body := map[string]interface{}{ + "description": route.Description, + "network_id": route.NetworkID, + "enabled": route.Enabled, + "peer": route.Peer, + "peer_groups": route.PeerGroups, + "network": route.Network, + "domains": route.Domains, + "metric": route.Metric, + "masquerade": route.Masquerade, + "groups": route.Groups, + "keep_route": route.KeepRoute, + } + + _, err := c.doRequest(ctx, "PUT", "routes/"+route.ID, body) + if err != nil { + return fmt.Errorf("NetBird API: UpdateNetworkRoute: %w", err) + } + return nil +} + +// CreateNetworkRoute updates a single NetBird route +func (c Client) CreateNetworkRoute(ctx context.Context, route data.NetworkRoute) error { + if c.DryRun { + return nil + } + + body := map[string]interface{}{ + "description": route.Description, + "network_id": route.NetworkID, + "enabled": route.Enabled, + "peer": route.Peer, + "peer_groups": route.PeerGroups, + "network": route.Network, + "domains": route.Domains, + "metric": route.Metric, + "masquerade": route.Masquerade, + "groups": route.Groups, + "keep_route": route.KeepRoute, + } + + _, err := c.doRequest(ctx, "POST", "routes", body) + if err != nil { + return fmt.Errorf("NetBird API: CreateNetworkRoute: %w", err) + } + return nil +} + +// DeleteNetworkRoute updates a single NetBird route +func (c Client) DeleteNetworkRoute(ctx context.Context, route data.NetworkRoute) error { + if c.DryRun { + return nil + } + + _, err := c.doRequest(ctx, "DELETE", "routes/"+route.ID, nil) + if err != nil { + return fmt.Errorf("NetBird API: DeleteNetworkRoute: %w", err) + } + return nil +} diff --git a/pkg/client/users.go b/pkg/client/users.go new file mode 100644 index 0000000..16d35b6 --- /dev/null +++ b/pkg/client/users.go @@ -0,0 +1,44 @@ +package client + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/Instabug/netbird-gitops/pkg/data" +) + +// ListUsers lists all NetBird users +func (c Client) ListUsers(ctx context.Context) ([]data.User, error) { + respBytes, err := c.doRequest(ctx, "GET", "users", nil) + if err != nil { + return nil, fmt.Errorf("NetBird API: ListUsers: %w", err) + } + var ret []data.User + + err = json.Unmarshal(respBytes, &ret) + if err != nil { + return nil, fmt.Errorf("NetBird API: ListUsers: %w", err) + } + + return ret, nil +} + +// UpdateUser updates a single NetBird user +func (c Client) UpdateUser(ctx context.Context, user data.User) error { + if c.DryRun { + return nil + } + + body := map[string]interface{}{ + "role": user.GetRole(), + "auto_groups": user.Groups, + "is_blocked": user.Blocked, + } + + _, err := c.doRequest(ctx, "PUT", "users/"+user.ID, body) + if err != nil { + return fmt.Errorf("NetBird API: UpdateUser: %w", err) + } + return nil +} diff --git a/pkg/controller/controller.go b/pkg/controller/controller.go new file mode 100644 index 0000000..e1f54e6 --- /dev/null +++ b/pkg/controller/controller.go @@ -0,0 +1,199 @@ +package controller + +import ( + "bytes" + "context" + "fmt" + "log/slog" + "os" + "path" + "strings" + "time" + + "github.com/Instabug/netbird-gitops/pkg/client" + "github.com/Instabug/netbird-gitops/pkg/data" + "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing/transport" + "github.com/nikoksr/notify" + "gopkg.in/yaml.v3" +) + +const localRepoPath = "/tmp/netbird-gitops" + +// Controller main logic controller +type Controller struct { + netbirdClient client.Client + *Options +} + +// Options controller settings +type Options struct { + GitRepoURL string + GitRelativePath string + GitBranch string + GitAuth transport.AuthMethod + NetBirdToken string + NetBirdAPI string + SyncOnceAndExit bool +} + +// NewController init +func NewController(opts Options) *Controller { + return &Controller{ + Options: &opts, + } +} + +// Start blocks and begins logic +func (c *Controller) Start(ctx context.Context) error { + // Init NetBird Client + c.netbirdClient = *client.NewClient(c.NetBirdAPI, c.NetBirdToken, true) + // Clone initial repository and handle sync logic + slog.Info("Cloning repository") + os.RemoveAll(localRepoPath) + repo, err := git.PlainCloneContext(ctx, localRepoPath, false, &git.CloneOptions{ + URL: c.GitRepoURL, + RemoteName: "origin", + Auth: c.GitAuth, + SingleBranch: true, + }) + if err != nil { + return fmt.Errorf("Failed to initialize repo pull: %w", err) + } + + defer func() { + os.RemoveAll(localRepoPath) + }() + + cfg, err := c.getCombinedConfig() + if err != nil { + return fmt.Errorf("error loading config: %w", err) + + } + + if err := c.doSync(ctx, cfg, c.SyncOnceAndExit || cfg.Config.AutoSync != "enforce"); err != nil { + slog.Error("Failed to sync", "err", err) + } + + if c.SyncOnceAndExit { + return nil + } + // Start polling loop for changes + latestHead, err := repo.Head() + if err != nil { + return fmt.Errorf("Failed to get repo HEAD: %w", err) + } + + workTree, err := repo.Worktree() + if err != nil { + return fmt.Errorf("Failed to get repo WorkTree: %w", err) + } + + slog.Info("Starting poll loop") + t := time.NewTicker(time.Minute) + for { + select { + case <-ctx.Done(): + return nil + case <-t.C: + } + slog.Info("Pulling changes") + err = workTree.PullContext(ctx, &git.PullOptions{ + RemoteName: "origin", + RemoteURL: c.GitRepoURL, + Auth: c.GitAuth, + SingleBranch: true, + }) + if err != nil && err != git.NoErrAlreadyUpToDate { + slog.Error("Failed to pull repo", "err", err) + notify.Send(ctx, "Pull failed", fmt.Sprintf("Failed to pull repository %s with error: %s", c.GitRepoURL, err.Error())) + continue + } + + curHead, err := repo.Head() + if err != nil { + slog.Error("Failed to get repo HEAD", "err", err) + notify.Send(ctx, "Get repo HEAD failed", fmt.Sprintf("Failed to get repository %s HEAD with error: %s", c.GitRepoURL, err.Error())) + continue + } + + cfg, err := c.getCombinedConfig() + if err != nil { + slog.Error("error loading config", "err", err) + notify.Send(ctx, "Failed to load config", fmt.Sprintf("Failed to load config from %s/%s with error: %s", c.GitRepoURL, c.GitRelativePath, err.Error())) + continue + } + + dryRun := true + switch cfg.Config.AutoSync { + case "enforce": + dryRun = false + case "update": + iter, err := repo.Log(&git.LogOptions{ + Order: git.LogOrderCommitterTime, + PathFilter: func(s string) bool { + return strings.HasPrefix(s, strings.TrimPrefix(c.GitRelativePath, "/")) + }, + }) + cmt, err := iter.Next() + for err == nil && cmt.Hash.String() != latestHead.Hash().String() { + fs, err := cmt.Stats() + if err != nil { + slog.Error("Error checking commit stats", "commit", cmt.Hash.String(), "err", err) + } + if len(fs) > 0 { + dryRun = false + } + cmt, err = iter.Next() + } + if err != nil { + slog.Error("Error iterating commits", "err", err) + continue + } + case "manual": + dryRun = true + } + + if err := c.doSync(ctx, cfg, dryRun); err != nil { + notify.Send(ctx, "Sync failed", fmt.Sprintf("Failed to sync %s/%s with error: %s", c.GitRepoURL, c.GitRelativePath, err.Error())) + slog.Error("Failed to sync", "err", err) + } + + latestHead = curHead + } +} + +func (c *Controller) getCombinedConfig() (*data.CombinedConfig, error) { + localPath := path.Join(localRepoPath, c.GitRelativePath) + files, err := os.ReadDir(localPath) + if err != nil { + return nil, err + } + + var filesBytes [][]byte + for _, f := range files { + ext := path.Ext(f.Name()) + if ext != ".yaml" && ext != ".yml" { + slog.Info("ignoring file", "name", f.Name()) + continue + } + slog.Info("found file", "name", f.Name()) + + fileBytes, err := os.ReadFile(path.Join(localPath, f.Name())) + if err != nil { + return nil, err + } + + filesBytes = append(filesBytes, fileBytes) + } + + totalBytes := bytes.Join(filesBytes, []byte("\n")) + + cfg := &data.CombinedConfig{} + err = yaml.Unmarshal(totalBytes, cfg) + if err != nil { + return nil, err + } + + return cfg, nil +} diff --git a/pkg/controller/sync.go b/pkg/controller/sync.go new file mode 100644 index 0000000..a1cdeb6 --- /dev/null +++ b/pkg/controller/sync.go @@ -0,0 +1,581 @@ +package controller + +import ( + "context" + "fmt" + "log/slog" + + "github.com/Instabug/netbird-gitops/pkg/data" + "github.com/Instabug/netbird-gitops/pkg/util" + "github.com/nikoksr/notify" +) + +func (c *Controller) doSync(ctx context.Context, cfg *data.CombinedConfig, dryRun bool) error { + c.netbirdClient.DryRun = dryRun + + groupNameID, groupIDName, err := c.syncGroups(ctx, *cfg) + if err != nil { + return fmt.Errorf("Failed syncGroups: %w", err) + } + + users, err := c.syncUsers(ctx, cfg, groupNameID, groupIDName) + if err != nil { + return fmt.Errorf("Failed syncUsers: %w", err) + } + + peers, err := c.syncPeers(ctx, cfg) + if err != nil { + return fmt.Errorf("Failed syncPeers: %w", err) + } + + err = c.syncPeerGroups(ctx, cfg, users, peers, groupNameID) + if err != nil { + return fmt.Errorf("Failed syncPeerGroups: %w", err) + } + + err = c.syncNetworkRoutes(ctx, cfg, groupNameID) + if err != nil { + return fmt.Errorf("Failed syncNetworkRoutes: %w", err) + } + + pcNameID, err := c.syncPostureChecks(ctx, cfg) + if err != nil { + return fmt.Errorf("Failed syncPostureChecks: %w", err) + } + + err = c.syncPolicies(ctx, cfg, pcNameID, groupNameID) + if err != nil { + return fmt.Errorf("Failed syncPolicies: %w", err) + } + + _, err = c.prunePostureChecks(ctx, cfg) + if err != nil { + return fmt.Errorf("Failed prunePostureChecks: %w", err) + } + + err = c.syncDNSSettings(ctx, cfg, groupNameID) + if err != nil { + return fmt.Errorf("Failed syncDNSSettings: %w", err) + } + + err = c.syncNameservers(ctx, cfg, groupNameID) + if err != nil { + return fmt.Errorf("Failed syncNameservers: %w", err) + } + + err = c.pruneGroups(ctx, groupNameID) + if err != nil { + return fmt.Errorf("Failed pruneGroups: %w", err) + } + + return nil +} + +func (c Controller) syncNameservers(ctx context.Context, cfg *data.CombinedConfig, groupNameID map[string]string) error { + nameservers, err := c.netbirdClient.ListNameservers(ctx) + if err != nil { + return err + } + + nsRevMap := util.SliceToMap(nameservers, func(ns data.Nameserver) string { return ns.Name }) + gitNSRevMap := util.SliceToMap(cfg.Nameservers, func(ns data.Nameserver) string { return ns.Name }) + + for k, v := range gitNSRevMap { + gitNS := data.Nameserver{ + Name: v.ID, + Description: v.Description, + Nameservers: v.Nameservers, + Enabled: v.Enabled, + Groups: util.Map(v.Groups, func(s string) string { return groupNameID[s] }), + Primary: v.Primary, + Domains: v.Domains, + SearchDomainsEnabled: v.SearchDomainsEnabled, + } + if nbns, ok := nsRevMap[k]; ok { + if nbns.Equals(gitNS) { + slog.Debug("Nameserver matches", "name", v.Name) + continue + } + slog.Warn("Updating Nameserver", "name", v.Name) + notify.Send(ctx, "", fmt.Sprintf("Updating nameserver %s with config: %+v", v.Name, v)) + gitNS.ID = nbns.ID + err = c.netbirdClient.UpdateNameserver(ctx, gitNS) + if err != nil { + return err + } + } else { + slog.Warn("Creating Nameserver", "name", v.Name) + notify.Send(ctx, "", fmt.Sprintf("Creating nameserver %s with config: %+v", v.Name, v)) + err = c.netbirdClient.CreateNameserver(ctx, gitNS) + if err != nil { + return err + } + } + } + + for k, v := range nsRevMap { + if _, ok := gitNSRevMap[k]; !ok { + slog.Warn("Deleting Nameserver", "name", v.Name) + notify.Send(ctx, "", fmt.Sprintf("Deleting Nameserver %s", v.Name)) + err = c.netbirdClient.DeleteNameserver(ctx, v) + if err != nil { + return err + } + } + } + + return nil +} + +func (c Controller) syncDNSSettings(ctx context.Context, cfg *data.CombinedConfig, groupNameID map[string]string) error { + settings, err := c.netbirdClient.GetDNSSettings(ctx) + if err != nil { + return err + } + + if util.SortedEqual(settings.Items.DisableFor, util.Map(cfg.DNS.DisableFor, func(s string) string { return groupNameID[s] })) { + slog.Debug("DNS Settings Matches") + return nil + } + + slog.Warn("Updating DNS Settings", "disabled_groups", cfg.DNS.DisableFor) + notify.Send(ctx, "", fmt.Sprintf("Updating DNS Settings with config: %+v", cfg.DNS)) + c.netbirdClient.UpdateDNSSettings(ctx, cfg.DNS) + return nil +} + +func (c Controller) syncPolicies(ctx context.Context, cfg *data.CombinedConfig, pcNameID, groupNameID map[string]string) error { + policies, err := c.netbirdClient.ListPolicies(ctx) + if err != nil { + return err + } + policyRevMap := util.SliceToMap(policies, func(p data.Policy) string { return p.Name }) + gitPolicyRevMap := util.SliceToMap(cfg.Policies, func(p data.Policy) string { return p.Name }) + + for k, v := range gitPolicyRevMap { + gitPolicy := data.Policy{ + Name: v.Name, + Description: v.Description, + Enabled: v.Enabled, + SourcePostureChecks: util.Map(v.SourcePostureChecks, func(p string) string { return pcNameID[p] }), + Action: v.Action, + Bidirectional: v.Bidirectional, + Protocol: v.Protocol, + Ports: v.Ports, + Sources: util.Map(v.Sources, func(p string) string { return groupNameID[p] }), + Destinations: util.Map(v.Destinations, func(p string) string { return groupNameID[p] }), + } + if nbp, ok := policyRevMap[k]; ok { + if nbp.Equals(gitPolicy) { + slog.Debug("Policies matching", "name", gitPolicy.Name) + continue + } + slog.Warn("Updating Policy", "name", gitPolicy.Name) + notify.Send(ctx, "", fmt.Sprintf("Updating Policy %s with config: %+v", gitPolicy.Name, gitPolicy)) + gitPolicy.ID = nbp.ID + err = c.netbirdClient.UpdatePolicy(ctx, gitPolicy) + if err != nil { + return err + } + } else { + slog.Warn("Creating Policy", "name", gitPolicy.Name) + notify.Send(ctx, "", fmt.Sprintf("Creating Policy %s with config: %+v", gitPolicy.Name, gitPolicy)) + err = c.netbirdClient.CreatePolicy(ctx, gitPolicy) + if err != nil { + return err + } + } + } + + for k, v := range policyRevMap { + if _, ok := gitPolicyRevMap[k]; !ok { + slog.Warn("Deleting Policy", "name", v.Name) + notify.Send(ctx, "", fmt.Sprintf("Deleting Policy %s", v.Name)) + err = c.netbirdClient.DeletePolicy(ctx, v) + if err != nil { + return err + } + } + } + return nil +} + +func (c Controller) syncPostureChecks(ctx context.Context, cfg *data.CombinedConfig) (map[string]string, error) { + pcNameID := make(map[string]string) + pcs, err := c.netbirdClient.ListPostureChecks(ctx) + if err != nil { + return nil, err + } + + pcRevMap := util.SliceToMap(pcs, func(p data.PostureCheck) string { return p.Name }) + gitPCRevMap := util.SliceToMap(cfg.PostureChecks, func(p data.PostureCheck) string { return p.Name }) + + for k, v := range gitPCRevMap { + if nbpc, ok := pcRevMap[k]; ok { + v.ID = nbpc.ID + slog.Warn("Updating postureCheck", "name", v.Name) + notify.Send(ctx, "", fmt.Sprintf("Updating postureCheck %s with config: %+v", v.Name, v)) + err = c.netbirdClient.UpdatePostureCheck(ctx, v) + if err != nil { + return nil, err + } + pcNameID[v.Name] = nbpc.ID + } else { + slog.Warn("Creating postureCheck", "name", v.Name) + notify.Send(ctx, "", fmt.Sprintf("Creating postureCheck %s with config: %+v", v.Name, v)) + pc, err := c.netbirdClient.CreatePostureCheck(ctx, v) + if err != nil { + return nil, err + } + pcNameID[v.Name] = pc.ID + } + } + + return pcNameID, nil +} + +func (c Controller) prunePostureChecks(ctx context.Context, cfg *data.CombinedConfig) (map[string]string, error) { + pcNameID := make(map[string]string) + pcs, err := c.netbirdClient.ListPostureChecks(ctx) + if err != nil { + return nil, err + } + + pcRevMap := util.SliceToMap(pcs, func(p data.PostureCheck) string { return p.Name }) + gitPCRevMap := util.SliceToMap(cfg.PostureChecks, func(p data.PostureCheck) string { return p.Name }) + + for k, v := range pcRevMap { + if _, ok := gitPCRevMap[k]; !ok { + slog.Warn("Deleting postureCheck", "name", v.Name) + notify.Send(ctx, "", fmt.Sprintf("Deleting postureCheck %s", v.Name)) + err = c.netbirdClient.DeletePostureCheck(ctx, v) + if err != nil { + return nil, err + } + } + } + return pcNameID, nil +} + +func (c Controller) syncNetworkRoutes(ctx context.Context, cfg *data.CombinedConfig, groupNameID map[string]string) error { + routes, err := c.netbirdClient.ListNetworkRoutes(ctx) + if err != nil { + return err + } + + routesRevMap := util.SliceToMap(routes, func(r data.NetworkRoute) string { return r.NetworkID }) + gitRoutesRevMap := util.SliceToMap(cfg.NetworkRoutes, func(r data.NetworkRoute) string { return r.NetworkID }) + + for k, v := range gitRoutesRevMap { + gitRoute := data.NetworkRoute{ + NetworkType: v.NetworkType, + Description: v.Description, + NetworkID: v.NetworkID, + Enabled: v.Enabled, + Peer: v.Peer, + PeerGroups: util.Map(v.PeerGroups, func(g string) string { return groupNameID[g] }), + Network: v.Network, + Domains: v.Domains, + Metric: v.Metric, + Masquerade: v.Masquerade, + Groups: util.Map(v.Groups, func(g string) string { return groupNameID[g] }), + KeepRoute: v.KeepRoute, + } + + if _, ok := routesRevMap[k]; !ok { + slog.Warn("Creating network route", "route", v) + notify.Send(ctx, "", fmt.Sprintf("Creating network route %s with config: %+v", v.NetworkID, gitRoute)) + err = c.netbirdClient.CreateNetworkRoute(ctx, gitRoute) + if err != nil { + return err + } + } else { + if routesRevMap[k].Equals(gitRoute) { + slog.Debug("Route matches", "network_id", v.NetworkID) + continue + } + gitRoute.ID = routesRevMap[k].ID + slog.Warn("Updating network route", "old_route", routesRevMap[k], "route", gitRoute) + notify.Send(ctx, "", fmt.Sprintf("Updating network route %s with config: %+v", v.NetworkID, gitRoute)) + err = c.netbirdClient.UpdateNetworkRoute(ctx, gitRoute) + if err != nil { + return err + } + } + } + + for k, v := range routesRevMap { + if _, ok := gitRoutesRevMap[k]; !ok { + slog.Warn("Deleting network route", "network_id", v.NetworkID) + notify.Send(ctx, "", fmt.Sprintf("Deleting network route %s", v.NetworkID)) + err = c.netbirdClient.DeleteNetworkRoute(ctx, v) + if err != nil { + return err + } + } + } + + return nil +} + +func (c Controller) syncPeerGroups(ctx context.Context, cfg *data.CombinedConfig, users map[string]data.User, peers map[string]data.Peer, groupNameID map[string]string) error { + groups, err := c.netbirdClient.ListGroups(ctx) + if err != nil { + return err + } + + reverseGroupMapping := make(map[string][]string) + for _, g := range groups { + for _, p := range g.PeerData { + reverseGroupMapping[g.ID] = append(reverseGroupMapping[g.ID], p.ID) + } + } + + reverseGroupMappingGit := make(map[string][]string) + if cfg.Config.IndividualPeerGroups { + for _, p := range cfg.Peers { + for _, g := range p.GroupNames { + reverseGroupMappingGit[groupNameID[g]] = append(reverseGroupMappingGit[groupNameID[g]], p.ID) + } + } + } else { + gitPeerRevMap := util.SliceToMap(cfg.Peers, func(p data.Peer) string { return p.ID }) + for k, p := range peers { + if p.UserID == "" { // Setup Key, use individual peer Group as per usual + slog.Debug("Using cfg.Peers", "peer", p.ID, "groups", gitPeerRevMap[k].GroupNames) + for _, g := range gitPeerRevMap[k].GroupNames { + reverseGroupMappingGit[groupNameID[g]] = append(reverseGroupMappingGit[groupNameID[g]], p.ID) + } + } else { + slog.Debug("Using user autogroups", "peer", p.ID, "groups", users[p.UserID].Groups) + for _, g := range users[p.UserID].Groups { + reverseGroupMappingGit[g] = append(reverseGroupMappingGit[g], p.ID) + } + } + } + } + + for _, g := range groups { + if g.Name == "All" { + continue + } + toAdd, toRemove := util.Diff(reverseGroupMappingGit[g.ID], reverseGroupMapping[g.ID]) + toAdd = util.Select(toAdd, func(s string) bool { _, ok := peers[s]; return ok }) + toRemove = util.Select(toRemove, func(s string) bool { _, ok := peers[s]; return ok }) + if len(toAdd)+len(toRemove) == 0 { + slog.Debug("Group peers matches", "name", g.Name) + continue + } + if len(toAdd) > 0 { + slog.Warn("Adding peers to group", "group_name", g.Name, "peers", toAdd) + notify.Send(ctx, "", fmt.Sprintf("Adding peers %+v to group %s", toAdd, g.Name)) + } + if len(toRemove) > 0 { + slog.Warn("Removing peers from group", "group_name", g.Name, "peers", toRemove) + notify.Send(ctx, "", fmt.Sprintf("Removing peers %+v from group %s", toRemove, g.Name)) + } + + g.Peers = reverseGroupMappingGit[g.ID] + + err = c.netbirdClient.UpdateGroup(ctx, g) + if err != nil { + return err + } + } + + return nil +} + +func (c Controller) syncPeers(ctx context.Context, cfg *data.CombinedConfig) (map[string]data.Peer, error) { + peers, err := c.netbirdClient.ListPeers(ctx) + if err != nil { + return nil, err + } + + gitPeerRevMap := util.SliceToMap(cfg.Peers, func(v data.Peer) string { return v.ID }) + + for _, p := range peers { + gitPeer := gitPeerRevMap[p.ID] + if _, ok := gitPeerRevMap[p.ID]; !ok { + // TODO: Delete? + if p.LoginExpirationEnabled && !p.SSHEnabled { + continue + } + slog.Warn("Peer exists in NetBird but not in Git, disabling SSH and enabling login expiration", "id", p.ID) + notify.Send(ctx, "", fmt.Sprintf("Peer %s doesn't exist in source control: disabling SSH and enabling login expiration", p.ID)) + p.LoginExpirationEnabled = true + p.SSHEnabled = false + } else if p.LoginExpirationEnabled == !gitPeer.ExpirationDisabled && p.Name == gitPeer.Name && p.SSHEnabled == gitPeer.SSHEnabled { + slog.Debug("Peer matches git", "id", p.ID, "name", p.Name) + continue + } else { + slog.Warn("Updating Peer", "id", p.ID, + "old_name", p.Name, "new_name", gitPeer.Name, + "old_expiration_enabled", p.LoginExpirationEnabled, "new_expiration_enabled", !gitPeer.ExpirationDisabled, + "old_ssh_enabled", p.SSHEnabled, "new_ssh_enabled", gitPeer.SSHEnabled) + notify.Send(ctx, "", fmt.Sprintf("Updating peer %s with config: %+v", p.ID, p)) + p.LoginExpirationEnabled = !gitPeer.ExpirationDisabled + p.SSHEnabled = gitPeer.SSHEnabled + p.Name = gitPeer.Name + p.GroupNames = gitPeer.GroupNames + } + + err = c.netbirdClient.UpdatePeer(ctx, p) + if err != nil { + return nil, err + } + } + + peerRevMap := util.SliceToMap(peers, func(p data.Peer) string { return p.ID }) + for k := range gitPeerRevMap { + if _, ok := peerRevMap[k]; !ok { + slog.Warn("Peer exists in Git but not NetBird, Deleted from upstream?", "id", k) + notify.Send(ctx, "", fmt.Sprintf("Peer %s exists in Git but not in NetBird, deleted from upstream?", k)) + } + } + + return peerRevMap, nil +} + +func (c Controller) syncUsers(ctx context.Context, cfg *data.CombinedConfig, groupNameID, groupIDName map[string]string) (map[string]data.User, error) { + users, err := c.netbirdClient.ListUsers(ctx) + if err != nil { + return nil, err + } + + userRevMap := make(map[string]data.User) + + emailMappingGit := util.SliceToMap(cfg.Users, func(v data.User) string { return v.Email }) + for _, u := range users { + userRevMap[u.ID] = u + if u.ServiceUser { + // TODO: Manage service users + continue + } + if u.Email == "" { + slog.Warn("User exists in NetBird with no email", "id", u.ID) + notify.Send(ctx, "", fmt.Sprintf("User ID %s exists in NetBird with no email, most likely deleted from SSO", u.ID)) + // TODO: Handle deleted user + } + gitUser := emailMappingGit[u.Email] + nbUserGroupNames := util.Map(u.Groups, func(a string) string { return groupIDName[a] }) + if gitUser.Email == "" { + // User exists in NetBird but not git + // TODO: Deletion or just blocking? + slog.Warn("User exists in NetBird but not in Git", "email", u.Email) + notify.Send(ctx, "", fmt.Sprintf("User %s exists in NetBird but not in Git, user blocked", u.Email)) + u.Blocked = true + u.Groups = []string{} + u.Role = "" + } else if util.SortedEqual(gitUser.Groups, nbUserGroupNames) && u.Role == gitUser.GetRole() { + slog.Debug("User matches in Netbird and Git", "email", u.Email) + // User autogroups and role equal + continue + } + + // User autogroups not equal + // Map group names to IDs + slog.Warn("Updating user", "email", u.Email, "old_groups", nbUserGroupNames, "new_groups", gitUser.Groups, "old_role", u.Role, "new_role", gitUser.GetRole()) + notify.Send(ctx, "", fmt.Sprintf("Updating user %s with config: %+v", u.Email, u)) + u.Groups = util.Map(gitUser.Groups, func(a string) string { return groupNameID[a] }) + u.Role = gitUser.GetRole() + + err := c.netbirdClient.UpdateUser(ctx, u) + if err != nil { + return nil, err + } + } + + return userRevMap, nil +} + +func (c Controller) syncGroups(ctx context.Context, cfg data.CombinedConfig) (groupNameToID map[string]string, groupIDToName map[string]string, err error) { + // Get all groups from all configuration + groupNameToID = make(map[string]string) + for _, g := range cfg.DNS.DisableFor { + groupNameToID[g] = "" + } + for _, route := range cfg.NetworkRoutes { + for _, g := range route.Groups { + groupNameToID[g] = "" + } + for _, g := range route.PeerGroups { + groupNameToID[g] = "" + } + } + for _, peer := range cfg.Peers { + for _, g := range peer.Groups { + groupNameToID[g.Name] = "" + } + } + for _, policy := range cfg.Policies { + for _, g := range policy.Sources { + groupNameToID[g] = "" + } + for _, g := range policy.Destinations { + groupNameToID[g] = "" + } + } + for _, user := range cfg.Users { + for _, g := range user.Groups { + groupNameToID[g] = "" + } + } + + // Get NetBird groups + groups, err := c.netbirdClient.ListGroups(ctx) + if err != nil { + return nil, nil, err + } + + groupIDToName = make(map[string]string) + + for _, g := range groups { + groupIDToName[g.ID] = g.Name + if _, ok := groupNameToID[g.Name]; ok { + groupNameToID[g.Name] = g.ID + } + } + + for k, v := range groupNameToID { + if v != "" { + continue + } + + slog.Warn("Creating group", "name", k) + notify.Send(ctx, "", fmt.Sprintf("Creating group %s", k)) + g, err := c.netbirdClient.CreateGroup(ctx, data.Group{ + Name: k, + }) + + if err != nil { + return nil, nil, err + } + slog.Info("Created group", "name", k, "id", g.ID) + + groupNameToID[g.Name] = g.ID + groupIDToName[g.ID] = g.Name + } + + return +} + +func (c Controller) pruneGroups(ctx context.Context, groupNameID map[string]string) error { + groups, err := c.netbirdClient.ListGroups(ctx) + if err != nil { + return err + } + + for _, g := range groups { + if _, ok := groupNameID[g.Name]; !ok { + if g.Name == "All" { + continue + } + slog.Warn("Deleting Group", "name", g.Name) + notify.Send(ctx, "", fmt.Sprintf("Deleting group %s as it's not used by any configuration", g.Name)) + c.netbirdClient.DeleteGroup(ctx, g) + } + } + + return nil +} diff --git a/pkg/data/config.go b/pkg/data/config.go new file mode 100644 index 0000000..ae30654 --- /dev/null +++ b/pkg/data/config.go @@ -0,0 +1,19 @@ +package data + +// Config holds program configuration +type Config struct { + AutoSync string `yaml:"autoSync"` + IndividualPeerGroups bool `yaml:"individualPeerGroups"` +} + +// CombinedConfig combined config of all files +type CombinedConfig struct { + Config Config `yaml:"config"` + Nameservers []Nameserver `yaml:"nameservers"` + DNS DNS `yaml:"dns"` + Peers []Peer `yaml:"peers"` + Policies []Policy `yaml:"policies"` + PostureChecks []PostureCheck `yaml:"posture_checks"` + NetworkRoutes []NetworkRoute `yaml:"network_routes"` + Users []User `yaml:"users"` +} diff --git a/pkg/data/dns.go b/pkg/data/dns.go new file mode 100644 index 0000000..27d294a --- /dev/null +++ b/pkg/data/dns.go @@ -0,0 +1,54 @@ +package data + +import ( + "slices" + + "github.com/Instabug/netbird-gitops/pkg/util" +) + +// DNS holds NetBird DNS Management settings +type DNS struct { + DisableFor []string `yaml:"disableFor" json:"disabled_management_groups"` +} + +// DNSResponse holds NetBird DNS Management Settings Response +type DNSResponse struct { + Items struct { + DisableFor []string `json:"disabled_management_groups"` + } `json:"items"` +} + +// Nameserver holds one nameserver group settings +type Nameserver struct { + ID string `json:"id"` + Name string `yaml:"name" json:"name"` + Description string `yaml:"description" json:"description"` + Nameservers []NameserverServer `yaml:"nameservers" json:"nameservers"` + Enabled bool `yaml:"enabled" json:"enabled"` + Groups []string `yaml:"groups" json:"groups"` + Primary bool `yaml:"primary" json:"primary"` + Domains []string `yaml:"domains" json:"domains"` + SearchDomainsEnabled bool `yaml:"search_domains_enabled" json:"search_domains_enabled"` +} + +// NameserverServer holds prot://ip:port +type NameserverServer struct { + IP string `yaml:"ip"` + NSType string `yaml:"ns_type"` + Port uint `yaml:"port"` +} + +// Equals == operator +func (ns Nameserver) Equals(o Nameserver) bool { + return ns.ID == o.ID && + ns.Name == o.Name && + ns.Description == o.Description && + slices.EqualFunc(ns.Nameservers, o.Nameservers, func(a, b NameserverServer) bool { + return a.IP == b.IP && a.NSType == b.NSType && a.Port == b.Port + }) && + ns.Enabled == o.Enabled && + util.SortedEqual(ns.Groups, o.Groups) && + ns.Primary == o.Primary && + util.SortedEqual(ns.Domains, o.Domains) && + ns.SearchDomainsEnabled == o.SearchDomainsEnabled +} diff --git a/pkg/data/group.go b/pkg/data/group.go new file mode 100644 index 0000000..003b9a7 --- /dev/null +++ b/pkg/data/group.go @@ -0,0 +1,9 @@ +package data + +// Group mapping of group ID and name +type Group struct { + Name string `json:"name"` + ID string `json:"id"` + Peers []string `json:"-"` + PeerData []Peer `json:"peers"` +} diff --git a/pkg/data/network_route.go b/pkg/data/network_route.go new file mode 100644 index 0000000..fe79366 --- /dev/null +++ b/pkg/data/network_route.go @@ -0,0 +1,36 @@ +package data + +import "github.com/Instabug/netbird-gitops/pkg/util" + +// NetworkRoute NetBird network route object +type NetworkRoute struct { + ID string `json:"id"` + NetworkType string `yaml:"network_type" json:"network_type"` + Description string `yaml:"description" json:"description"` + NetworkID string `yaml:"network_id" json:"network_id"` + Enabled bool `yaml:"enabled" json:"enabled"` + Peer string `yaml:"peer" json:"peer"` + PeerGroups []string `yaml:"peer_groups" json:"peer_groups"` + Network string `yaml:"network" json:"network"` + Domains []string `yaml:"domains" json:"domains"` + Metric int `yaml:"metric" json:"metric"` + Masquerade bool `yaml:"masquerade" json:"masquerade"` + Groups []string `yaml:"groups" json:"groups"` + KeepRoute bool `yaml:"keep_route" json:"keep_route"` +} + +// Equals returns if network routes are equal +func (n NetworkRoute) Equals(o NetworkRoute) bool { + return n.NetworkType == o.NetworkType && + n.Description == o.Description && + n.NetworkID == o.NetworkID && + n.Enabled == o.Enabled && + n.Peer == o.Peer && + util.SortedEqual(n.PeerGroups, o.PeerGroups) && + ((len(n.Domains) != 0 || len(o.Domains) != 0) || n.Network == o.Network) && + util.SortedEqual(n.Domains, o.Domains) && + n.Metric == o.Metric && + n.Masquerade == o.Masquerade && + util.SortedEqual(n.Groups, o.Groups) && + n.KeepRoute == o.KeepRoute +} diff --git a/pkg/data/peer.go b/pkg/data/peer.go new file mode 100644 index 0000000..f4a1872 --- /dev/null +++ b/pkg/data/peer.go @@ -0,0 +1,13 @@ +package data + +// Peer associates a peer with 0+ groups +type Peer struct { + ID string `yaml:"id" json:"id"` + Name string `yaml:"name" json:"name"` + Groups []Group `yaml:"-" json:"groups"` + GroupNames []string `yaml:"groups"` + SSHEnabled bool `yaml:"ssh_enabled" json:"ssh_enabled"` + ExpirationDisabled bool `yaml:"expiration_disabled"` + LoginExpirationEnabled bool `json:"login_expiration_enabled"` + UserID string `json:"user_id"` +} diff --git a/pkg/data/policy.go b/pkg/data/policy.go new file mode 100644 index 0000000..dab3a39 --- /dev/null +++ b/pkg/data/policy.go @@ -0,0 +1,67 @@ +package data + +import ( + "errors" + + "github.com/Instabug/netbird-gitops/pkg/util" +) + +// TODO: Make object conform to weird NetBird API + +// Policy holds NetBird ACL Policy object +type Policy struct { + ID string `json:"id"` + Name string `yaml:"name" json:"name"` + Enabled bool `yaml:"enabled" json:"enabled"` + Description string `yaml:"description" json:"description"` + SourcePostureChecks []string `yaml:"source_posture_checks"` + Action string `yaml:"action"` + Bidirectional bool `yaml:"bidirectional"` + Protocol string `yaml:"protocol"` + Sources []string `yaml:"sources"` + Destinations []string `yaml:"destinations"` + Rules []PolicyRule `json:"rules"` + Ports []string `yaml:"ports"` +} + +// PolicyRule Policy.Rules section +type PolicyRule struct { + SourceGroups []Group `json:"sources"` + DestinationGroups []Group `json:"destinations"` + Description string `json:"description"` + Action string `json:"action"` + Bidirectional bool `json:"bidirectional"` + Protocol string `json:"protocol"` + Ports []string `json:"ports"` +} + +// Equals == operator +func (p Policy) Equals(o Policy) bool { + return p.Name == o.Name && + p.Description == o.Description && + p.Enabled == o.Enabled && + util.SortedEqual(p.SourcePostureChecks, o.SourcePostureChecks) && + p.Action == o.Action && + p.Bidirectional == o.Bidirectional && + p.Protocol == o.Protocol && + util.SortedEqual(p.Sources, o.Sources) && + util.SortedEqual(p.Destinations, o.Destinations) +} + +// Flatten converts uselessly nested policy rule to policy object +func (p *Policy) Flatten() error { + if len(p.Rules) != 1 { + return errors.New("Policy should have 1 rule exactly") + } + + if p.Description == "" { + p.Description = p.Rules[0].Description + } + p.Action = p.Rules[0].Action + p.Bidirectional = p.Rules[0].Bidirectional + p.Protocol = p.Rules[0].Protocol + p.Ports = p.Rules[0].Ports + p.Sources = util.Map(p.Rules[0].SourceGroups, func(g Group) string { return g.ID }) + p.Destinations = util.Map(p.Rules[0].DestinationGroups, func(g Group) string { return g.ID }) + return nil +} diff --git a/pkg/data/posture_check.go b/pkg/data/posture_check.go new file mode 100644 index 0000000..1276add --- /dev/null +++ b/pkg/data/posture_check.go @@ -0,0 +1,65 @@ +package data + +// PostureCheck holds NetBird PostureCheck object +type PostureCheck struct { + ID string `json:"id"` + Name string `yaml:"name" json:"name"` + Description string `yaml:"description" json:"description"` + Checks PostureCheckDetails `yaml:"checks" json:"checks"` +} + +// PostureCheckDetails different checks in posture check +type PostureCheckDetails struct { + NBVersionCheck MinVersionDescriptor `yaml:"nb_version_check" json:"nb_version_check"` + OSVersionCheck OSVersionCheckObj `yaml:"os_version_check" json:"os_version_check"` + GeoLocationCheck GeoLocationCheckObj `yaml:"geo_location_check" json:"geo_location_check"` + PeerNetworkRangeCheck PeerNetworkRangeCheckObj `yaml:"peer_network_range_check" json:"peer_network_range_check"` + ProcessCheck ProcessCheckObj `yaml:"process_check" json:"process_check"` +} + +// OSVersionCheckObj Different OS types version checks +type OSVersionCheckObj struct { + Android MinVersionDescriptor `yaml:"android" json:"android"` + IOS MinVersionDescriptor `yaml:"ios" json:"ios"` + Darwin MinVersionDescriptor `yaml:"darwin" json:"darwin"` + Linux MinKernelVersionDescriptor `yaml:"linux" json:"linux"` + Windows MinKernelVersionDescriptor `yaml:"windows" json:"windows"` +} + +// MinVersionDescriptor descriptor for generic min version +type MinVersionDescriptor struct { + MinVersion string `yaml:"min_version" json:"min_version"` +} + +// MinKernelVersionDescriptor descriptor for kernel min version +type MinKernelVersionDescriptor struct { + MinKernelVersion string `yaml:"min_kernel_version" json:"min_kernel_version"` +} + +// GeoLocationCheckObj posture check geo location check +type GeoLocationCheckObj struct { + Locations []GeoLocation `yaml:"locations" json:"locations"` +} + +// GeoLocation descriptor for a geolocation +type GeoLocation struct { + CountryCode string `yaml:"country_code" json:"country_code"` + CityName string `yaml:"city_name" json:"city_name"` +} + +// PeerNetworkRangeCheckObj posture check network range check +type PeerNetworkRangeCheckObj struct { + Ranges []string `yaml:"ranges" json:"ranges"` +} + +// ProcessCheckObj posture check process checklist +type ProcessCheckObj struct { + Processes []OSProcess `yaml:"processes" json:"processes"` +} + +// OSProcess posture check for different paths for OS +type OSProcess struct { + LinuxPath string `yaml:"linux_path" json:"linux_path"` + MacPath string `yaml:"mac_path" json:"mac_path"` + WindowsPath string `yaml:"windows_path" json:"windows_path"` +} diff --git a/pkg/data/user.go b/pkg/data/user.go new file mode 100644 index 0000000..a82a1d9 --- /dev/null +++ b/pkg/data/user.go @@ -0,0 +1,20 @@ +package data + +// User NetBird User to groups mapping +type User struct { + Email string `yaml:"email" json:"email"` + Groups []string `yaml:"groups" json:"auto_groups"` + ID string `json:"id"` + Role string `yaml:"role" json:"role"` + Blocked bool `json:"is_blocked"` + ServiceUser bool `json:"is_service_user"` +} + +// GetRole returns role if valid, user otherwise +func (u User) GetRole() string { + if u.Role != "admin" && u.Role != "user" && u.Role != "owner" { + return "user" + } + + return u.Role +} diff --git a/pkg/util/maps.go b/pkg/util/maps.go new file mode 100644 index 0000000..7c08189 --- /dev/null +++ b/pkg/util/maps.go @@ -0,0 +1,10 @@ +package util + +// SliceToMap returns map[keyFn(v)] = v for each v in arr +func SliceToMap[K ~[]S, S interface{}, V comparable](arr K, keyFn func(S) V) map[V]S { + ret := make(map[V]S) + for _, v := range arr { + ret[keyFn(v)] = v + } + return ret +} diff --git a/pkg/util/slices.go b/pkg/util/slices.go new file mode 100644 index 0000000..bf1d297 --- /dev/null +++ b/pkg/util/slices.go @@ -0,0 +1,58 @@ +package util + +import ( + "cmp" + "slices" +) + +// SortedEqual returns true if a and b are equal if sorted +func SortedEqual[A ~[]S, S cmp.Ordered](a, b A) bool { + slices.Sort(a) + slices.Sort(b) + return slices.Equal(a, b) +} + +// Map returns mapping of arr using mapFn +func Map[A ~[]S, S interface{}, V interface{}](arr A, mapFn func(S) V) []V { + var ret []V + for _, x := range arr { + ret = append(ret, mapFn(x)) + } + return ret +} + +// Diff returns elements in a not in b and vice versa +func Diff[A ~[]S, S comparable](a, b A) (A, A) { + var retA, retB A + mapA := make(map[S]interface{}) + mapB := make(map[S]interface{}) + for _, k := range a { + mapA[k] = nil + } + for _, k := range b { + mapB[k] = nil + } + for _, k := range a { + if _, ok := mapB[k]; !ok { + retA = append(retA, k) + } + } + for _, k := range b { + if _, ok := mapA[k]; !ok { + retB = append(retB, k) + } + } + + return retA, retB +} + +// Select returns elements of arr where cmp(element)==true +func Select[A ~[]S, S interface{}](arr A, cmp func(S) bool) A { + var ret A + for _, v := range arr { + if cmp(v) { + ret = append(ret, v) + } + } + return ret +}