Files
feedc0de fd5f5c454f
Validate and publish / Test image and chart (push) Successful in 2m33s
Validate and publish / Publish image and OCI chart (push) Successful in 22s
Support multiple service port forwardings
2026-08-31 14:13:56 +02:00

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
}