264 lines
7.5 KiB
Go
264 lines
7.5 KiB
Go
package routeros
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const managedCommentPrefix = "k8s-mikrotik/"
|
|
|
|
var managedFields = []string{
|
|
"action", "chain", "comment", "protocol", "dst-port", "dst-address",
|
|
"dst-address-list", "in-interface", "in-interface-list", "src-address",
|
|
"src-address-list", "to-addresses", "to-ports",
|
|
}
|
|
|
|
type TLSConfig struct {
|
|
InsecureSkipVerify bool
|
|
CABundle []byte
|
|
}
|
|
|
|
// Rule is the complete portion of a RouterOS dst-nat rule owned by the operator.
|
|
type Rule struct {
|
|
Comment string
|
|
Protocol string
|
|
DestinationPort string
|
|
DestinationIP string
|
|
DestinationList string
|
|
InInterface string
|
|
InInterfaceList string
|
|
SourceAddress string
|
|
SourceList string
|
|
ToAddress string
|
|
ToPort string
|
|
}
|
|
|
|
func (r Rule) fields(includeEmpty bool) map[string]string {
|
|
all := map[string]string{
|
|
"action": "dst-nat", "chain": "dstnat", "comment": r.Comment,
|
|
"protocol": r.Protocol, "dst-port": r.DestinationPort,
|
|
"dst-address": r.DestinationIP, "dst-address-list": r.DestinationList,
|
|
"in-interface": r.InInterface, "in-interface-list": r.InInterfaceList,
|
|
"src-address": r.SourceAddress, "src-address-list": r.SourceList,
|
|
"to-addresses": r.ToAddress, "to-ports": r.ToPort,
|
|
}
|
|
if includeEmpty {
|
|
return all
|
|
}
|
|
for key, value := range all {
|
|
if value == "" {
|
|
delete(all, key)
|
|
}
|
|
}
|
|
return all
|
|
}
|
|
|
|
type API interface {
|
|
Sync(context.Context, []Rule, map[string]struct{}) (int, error)
|
|
}
|
|
|
|
type Client struct {
|
|
baseURL *url.URL
|
|
username string
|
|
password string
|
|
http *http.Client
|
|
}
|
|
|
|
func NewClient(endpoint, username, password string, tlsConfig TLSConfig) (*Client, error) {
|
|
base, err := url.Parse(strings.TrimRight(endpoint, "/"))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse endpoint: %w", err)
|
|
}
|
|
if base.Scheme != "https" && base.Scheme != "http" {
|
|
return nil, errors.New("endpoint scheme must be https or http")
|
|
}
|
|
if base.Host == "" {
|
|
return nil, errors.New("endpoint must include a hostname")
|
|
}
|
|
if base.User != nil {
|
|
return nil, errors.New("endpoint must not contain credentials")
|
|
}
|
|
if base.Path != "" || base.RawQuery != "" || base.Fragment != "" {
|
|
return nil, errors.New("endpoint must be an origin without a path, query, or fragment")
|
|
}
|
|
if tlsConfig.InsecureSkipVerify && len(tlsConfig.CABundle) > 0 {
|
|
return nil, errors.New("insecureSkipVerify and a CA bundle are mutually exclusive")
|
|
}
|
|
|
|
tlsClientConfig := &tls.Config{MinVersion: tls.VersionTLS12, InsecureSkipVerify: tlsConfig.InsecureSkipVerify} // #nosec G402 -- explicit CRD opt-in
|
|
if len(tlsConfig.CABundle) > 0 {
|
|
roots, err := x509.SystemCertPool()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("load system CA pool: %w", err)
|
|
}
|
|
if !roots.AppendCertsFromPEM(tlsConfig.CABundle) {
|
|
return nil, errors.New("CA Secret contains no valid PEM certificates")
|
|
}
|
|
tlsClientConfig.RootCAs = roots
|
|
}
|
|
transport := http.DefaultTransport.(*http.Transport).Clone()
|
|
transport.TLSClientConfig = tlsClientConfig
|
|
return &Client{
|
|
baseURL: base, username: username, password: password,
|
|
http: &http.Client{Transport: transport, Timeout: 20 * time.Second},
|
|
}, nil
|
|
}
|
|
|
|
func (c *Client) Sync(ctx context.Context, desired []Rule, preserve map[string]struct{}) (int, error) {
|
|
current, err := c.listNAT(ctx)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
byComment := make(map[string][]map[string]string)
|
|
for _, rule := range current {
|
|
if strings.HasPrefix(rule["comment"], managedCommentPrefix) {
|
|
byComment[rule["comment"]] = append(byComment[rule["comment"]], rule)
|
|
}
|
|
}
|
|
slices.SortFunc(desired, func(a, b Rule) int { return strings.Compare(a.Comment, b.Comment) })
|
|
desiredComments := make(map[string]struct{}, len(desired))
|
|
|
|
for _, rule := range desired {
|
|
if !strings.HasPrefix(rule.Comment, managedCommentPrefix) {
|
|
return 0, fmt.Errorf("refusing unmanaged rule comment %q", rule.Comment)
|
|
}
|
|
desiredComments[rule.Comment] = struct{}{}
|
|
matches := byComment[rule.Comment]
|
|
if len(matches) == 0 {
|
|
if err := c.create(ctx, rule.fields(false)); err != nil {
|
|
return 0, fmt.Errorf("create %s: %w", rule.Comment, err)
|
|
}
|
|
continue
|
|
}
|
|
if !sameManagedFields(matches[0], rule.fields(true)) {
|
|
if err := c.patch(ctx, matches[0][".id"], rule.fields(true)); err != nil {
|
|
return 0, fmt.Errorf("update %s: %w", rule.Comment, err)
|
|
}
|
|
}
|
|
for _, duplicate := range matches[1:] {
|
|
if err := c.delete(ctx, duplicate[".id"]); err != nil {
|
|
return 0, fmt.Errorf("delete duplicate %s: %w", rule.Comment, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
managedCount := len(desired)
|
|
for comment, rules := range byComment {
|
|
if _, wanted := desiredComments[comment]; wanted {
|
|
continue
|
|
}
|
|
if commentPreserved(comment, preserve) {
|
|
managedCount++
|
|
continue
|
|
}
|
|
for _, rule := range rules {
|
|
if err := c.delete(ctx, rule[".id"]); err != nil {
|
|
return 0, fmt.Errorf("delete stale %s: %w", comment, err)
|
|
}
|
|
}
|
|
}
|
|
return managedCount, nil
|
|
}
|
|
|
|
func commentPreserved(comment string, preserve map[string]struct{}) bool {
|
|
if _, ok := preserve[comment]; ok {
|
|
return true
|
|
}
|
|
for prefix := range preserve {
|
|
if strings.HasSuffix(prefix, "/") && strings.HasPrefix(comment, prefix) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func sameManagedFields(current, desired map[string]string) bool {
|
|
for _, key := range managedFields {
|
|
if current[key] != desired[key] {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (c *Client) listNAT(ctx context.Context) ([]map[string]string, error) {
|
|
var response []map[string]string
|
|
if err := c.do(ctx, http.MethodGet, []string{"rest", "ip", "firewall", "nat"}, nil, &response); err != nil {
|
|
return nil, fmt.Errorf("list NAT rules: %w", err)
|
|
}
|
|
return response, nil
|
|
}
|
|
|
|
func (c *Client) create(ctx context.Context, fields map[string]string) error {
|
|
return c.do(ctx, http.MethodPut, []string{"rest", "ip", "firewall", "nat"}, fields, nil)
|
|
}
|
|
|
|
func (c *Client) patch(ctx context.Context, id string, fields map[string]string) error {
|
|
if id == "" {
|
|
return errors.New("RouterOS returned a NAT rule without .id")
|
|
}
|
|
return c.do(ctx, http.MethodPatch, []string{"rest", "ip", "firewall", "nat", id}, fields, nil)
|
|
}
|
|
|
|
func (c *Client) delete(ctx context.Context, id string) error {
|
|
if id == "" {
|
|
return errors.New("RouterOS returned a NAT rule without .id")
|
|
}
|
|
return c.do(ctx, http.MethodDelete, []string{"rest", "ip", "firewall", "nat", id}, nil, nil)
|
|
}
|
|
|
|
func (c *Client) do(ctx context.Context, method string, path []string, input, output any) error {
|
|
requestURL := c.baseURL.JoinPath(path...).String()
|
|
var body io.Reader
|
|
if input != nil {
|
|
encoded, err := json.Marshal(input)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
body = bytes.NewReader(encoded)
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, method, requestURL, body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.SetBasicAuth(c.username, c.password)
|
|
if input != nil {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
resp, err := c.http.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode >= http.StatusBadRequest {
|
|
message, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
|
return fmt.Errorf("RouterOS returned %s: %s", resp.Status, strings.TrimSpace(string(message)))
|
|
}
|
|
if output != nil {
|
|
if err := json.NewDecoder(resp.Body).Decode(output); err != nil {
|
|
return fmt.Errorf("decode RouterOS response: %w", err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func ManagedComment(namespace, name string) string {
|
|
return managedCommentPrefix + namespace + "/" + name
|
|
}
|
|
|
|
func ManagedForwardingComment(namespace, service, forwarding string) string {
|
|
return ManagedComment(namespace, service) + "/" + forwarding
|
|
}
|