// Copyright (c) 2022 Gitpod GmbH. All rights reserved. // Licensed under the GNU Affero General Public License (AGPL). // See License-AGPL.txt in the project root for license information. package netlimit import ( "context" "fmt" "os" "os/exec" "runtime" "strconv" "sync" "time" "github.com/gitpod-io/gitpod/common-go/kubernetes" "github.com/gitpod-io/gitpod/common-go/log" "github.com/gitpod-io/gitpod/ws-daemon/pkg/dispatch" "github.com/gitpod-io/gitpod/ws-daemon/pkg/nsinsider" "github.com/google/nftables" "github.com/prometheus/client_golang/prometheus" "github.com/vishvananda/netns" ) type ConnLimiter struct { mu sync.RWMutex limited map[string]struct{} droppedBytes *prometheus.GaugeVec droppedPackets *prometheus.GaugeVec config Config } func NewConnLimiter(config Config, prom prometheus.Registerer) *ConnLimiter { s := &ConnLimiter{ droppedBytes: prometheus.NewGaugeVec(prometheus.GaugeOpts{ Name: "netlimit_connections_dropped_bytes", Help: "Number of bytes dropped due to connection limiting", }, []string{"node", "workspace"}), droppedPackets: prometheus.NewGaugeVec(prometheus.GaugeOpts{ Name: "netlimit_connections_dropped_packets", Help: "Number of packets dropped due to connection limiting", }, []string{"node", "workspace"}), limited: map[string]struct{}{}, } s.config = config if config.Enabled { prom.MustRegister( s.droppedBytes, s.droppedPackets, ) } return s } func (c *ConnLimiter) WorkspaceAdded(ctx context.Context, ws *dispatch.Workspace) error { c.mu.Lock() defer c.mu.Unlock() _, hasAnnotation := ws.Pod.Annotations[kubernetes.WorkspaceNetConnLimitAnnotation] if !hasAnnotation { return nil } return c.limitWorkspace(ctx, ws) } func (c *ConnLimiter) WorkspaceUpdated(ctx context.Context, ws *dispatch.Workspace) error { c.mu.Lock() defer c.mu.Unlock() _, hasAnnotation := ws.Pod.Annotations[kubernetes.WorkspaceNetConnLimitAnnotation] if !hasAnnotation { return nil } if _, ok := c.limited[ws.InstanceID]; ok { return nil } return c.limitWorkspace(ctx, ws) } func (n *ConnLimiter) GetConnectionDropCounter(pid uint64) (*nftables.CounterObj, error) { runtime.LockOSThread() defer runtime.UnlockOSThread() netns, err := netns.GetFromPid(int(pid)) if err != nil { return nil, fmt.Errorf("could not get handle for network namespace: %w", err) } nftconn, err := nftables.New(nftables.WithNetNSFd(int(netns))) if err != nil { return nil, fmt.Errorf("could not establish netlink connection for nft: %w", err) } gitpodTable := &nftables.Table{ Name: "gitpod", Family: nftables.TableFamilyIPv4, } counterObject, err := nftconn.GetObject(&nftables.CounterObj{ Table: gitpodTable, Name: "ws-connection-drop-stats", }) if err != nil { return nil, fmt.Errorf("could not get connection drop stats: %w", err) } dropCounter, ok := counterObject.(*nftables.CounterObj) if !ok { return nil, fmt.Errorf("could not cast counter object") } return dropCounter, nil } func (c *ConnLimiter) limitWorkspace(ctx context.Context, ws *dispatch.Workspace) error { log.WithFields(ws.OWI()).Infof("will limit network connections") disp := dispatch.GetFromContext(ctx) if disp == nil { return fmt.Errorf("no dispatch available") } pid, err := disp.Runtime.ContainerPID(context.Background(), ws.ContainerID) if err != nil { return fmt.Errorf("could not get pid for container %s of workspace %s", ws.ContainerID, ws.WorkspaceID) } err = nsinsider.Nsinsider(ws.InstanceID, int(pid), func(cmd *exec.Cmd) { cmd.Args = append(cmd.Args, "setup-connection-limit", "--limit", strconv.Itoa(int(c.config.ConnectionsPerMinute)), "--bucketsize", strconv.Itoa(int(c.config.BucketSize))) if c.config.Enforce { cmd.Args = append(cmd.Args, "--enforce") } }, nsinsider.EnterMountNS(false), nsinsider.EnterNetNS(true)) if err != nil { log.WithError(err).WithFields(ws.OWI()).Error("cannot enable connection limiting") return err } c.limited[ws.InstanceID] = struct{}{} go func(*dispatch.Workspace) { ticker := time.NewTicker(30 * time.Second) defer ticker.Stop() for { select { case <-ticker.C: counter, err := c.GetConnectionDropCounter(pid) if err != nil { log.WithError(err).Errorf("could not get connection drop stats for %s", ws.WorkspaceID) continue } nodeName := os.Getenv("NODENAME") c.droppedBytes.WithLabelValues(nodeName, ws.Pod.Name).Set(float64(counter.Bytes)) c.droppedPackets.WithLabelValues(nodeName, ws.Pod.Name).Set(float64(counter.Packets)) case <-ctx.Done(): c.mu.Lock() delete(c.limited, ws.InstanceID) c.mu.Unlock() return } } }(ws) return nil } func (c *ConnLimiter) Update(config Config) { c.mu.Lock() defer c.mu.Unlock() c.config = config log.WithField("config", config).Info("updating network connection limits") }