Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions pkg/acl/hosts/hosts.go
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
// pkg/acl/hosts/hosts.go

package hosts

import (
Expand Down
212 changes: 140 additions & 72 deletions pkg/acl/nodeattributes/nodeattributes.go
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
// pkg/acl/nodeattributes/nodeattributes.go

package nodeattrs

import (
Expand All @@ -10,15 +12,27 @@ import (
tsclient "github.com/tailscale/tailscale-client-go/v2"
)

// RegisterRoutes wires up CRUD endpoints at /nodeattrs.
//
// For example:
//
// GET /nodeattrs => list all NodeAttrGrant
// GET /nodeattrs/:index => get one by index
// POST /nodeattrs => create new
// PUT /nodeattrs => update existing by { "index": N, "grant": {...} }
// DELETE /nodeattrs => remove by { "index": N }
// NodeAttrGrantInput => incoming JSON in POST/PUT for the "grant" object.
type NodeAttrGrantInput struct {
Target []string `json:"target" binding:"required"`
Attr []string `json:"attr,omitempty"`
App map[string][]AppConnectorInput `json:"app,omitempty"`
}

// AppConnectorInput => each item in app
type AppConnectorInput struct {
Name string `json:"name,omitempty"`
Connectors []string `json:"connectors,omitempty"`
Domains []string `json:"domains,omitempty"`
}

// ExtendedNodeAttrGrant => we store in TACL. target => required, attr OR app => exactly one
type ExtendedNodeAttrGrant struct {
tsclient.NodeAttrGrant
App map[string][]AppConnectorInput `json:"app,omitempty"`
}

// RegisterRoutes => /nodeattrs
func RegisterRoutes(r *gin.Engine, state *common.State) {
n := r.Group("/nodeattrs")
{
Expand All @@ -40,8 +54,7 @@ func RegisterRoutes(r *gin.Engine, state *common.State) {
}
}

// listNodeAttrs => GET /nodeattrs
// Returns all NodeAttrGrant objects in state.Data["nodeAttrs"].
// listNodeAttrs => GET /nodeattrs => returns array of ExtendedNodeAttrGrant
func listNodeAttrs(c *gin.Context, state *common.State) {
grants, err := getNodeAttrsFromState(state)
if err != nil {
Expand All @@ -65,7 +78,6 @@ func getNodeAttrByIndex(c *gin.Context, state *common.State) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to parse node attributes"})
return
}

if i < 0 || i >= len(grants) {
c.JSON(http.StatusNotFound, gin.H{"error": "NodeAttr index out of range"})
return
Expand All @@ -74,66 +86,103 @@ func getNodeAttrByIndex(c *gin.Context, state *common.State) {
}

// createNodeAttr => POST /nodeattrs
// Appends a new NodeAttrGrant to the existing slice.
// Expects NodeAttrGrantInput => either "attr" or "app" must be set, not both
// createNodeAttr => POST /nodeattrs
func createNodeAttr(c *gin.Context, state *common.State) {
var newGrant tsclient.NodeAttrGrant
if err := c.ShouldBindJSON(&newGrant); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}

grants, err := getNodeAttrsFromState(state)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to parse node attributes"})
return
}

grants = append(grants, newGrant)
if err := state.UpdateKeyAndSave("nodeAttrs", grants); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save node attribute"})
return
}
c.JSON(http.StatusCreated, newGrant)
var input NodeAttrGrantInput
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}

// check exclusivity
if !exactlyOneOfAttrOrApp(input) {
c.JSON(http.StatusBadRequest, gin.H{"error": "Either `attr` or `app` must be set, but not both"})
return
}

// If we're dealing with `app`, force target = ["*"]
if len(input.App) > 0 {
input.Target = []string{"*"}
}

grants, err := getNodeAttrsFromState(state)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to parse node attributes"})
return
}

newGrant := ExtendedNodeAttrGrant{
NodeAttrGrant: tsclient.NodeAttrGrant{
Target: input.Target,
Attr: input.Attr,
},
App: convertAppConnectors(input.App),
}

grants = append(grants, newGrant)
if err := state.UpdateKeyAndSave("nodeAttrs", grants); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save node attribute"})
return
}
c.JSON(http.StatusCreated, newGrant)
}

// updateNodeAttr => PUT /nodeattrs
// Expects JSON with an 'index' and a 'grant' object.
// updateNodeAttr => PUT /nodeattrs => { index, grant: NodeAttrGrantInput }
// updateNodeAttr => PUT /nodeattrs => { index, grant: NodeAttrGrantInput }
func updateNodeAttr(c *gin.Context, state *common.State) {
type updateRequest struct {
Index int `json:"index"`
Grant tsclient.NodeAttrGrant `json:"grant"`
}
var req updateRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.Index < 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Missing or invalid 'index' field"})
return
}

grants, err := getNodeAttrsFromState(state)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to parse node attributes"})
return
}

if req.Index >= len(grants) {
c.JSON(http.StatusNotFound, gin.H{"error": "NodeAttr index out of range"})
return
}

grants[req.Index] = req.Grant
if err := state.UpdateKeyAndSave("nodeAttrs", grants); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update node attribute"})
return
}
c.JSON(http.StatusOK, req.Grant)
type updateRequest struct {
Index int `json:"index"`
Grant NodeAttrGrantInput `json:"grant"`
}
var req updateRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.Index < 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid index"})
return
}

if !exactlyOneOfAttrOrApp(req.Grant) {
c.JSON(http.StatusBadRequest, gin.H{"error": "Either `attr` or `app` must be set, but not both"})
return
}

grants, err := getNodeAttrsFromState(state)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to parse node attributes"})
return
}
if req.Index >= len(grants) {
c.JSON(http.StatusNotFound, gin.H{"error": "NodeAttr index out of range"})
return
}

// If `app` is present, force target = ["*"]
if len(req.Grant.App) > 0 {
req.Grant.Target = []string{"*"}
}

updated := ExtendedNodeAttrGrant{
NodeAttrGrant: tsclient.NodeAttrGrant{
Target: req.Grant.Target,
Attr: req.Grant.Attr,
},
App: convertAppConnectors(req.Grant.App),
}

grants[req.Index] = updated
if err := state.UpdateKeyAndSave("nodeAttrs", grants); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update node attribute"})
return
}
c.JSON(http.StatusOK, updated)
}

// deleteNodeAttr => DELETE /nodeattrs
// Expects JSON with an 'index'.

// deleteNodeAttr => DELETE /nodeattrs => { index }
func deleteNodeAttr(c *gin.Context, state *common.State) {
type deleteRequest struct {
Index int `json:"index"`
Expand All @@ -149,7 +198,6 @@ func deleteNodeAttr(c *gin.Context, state *common.State) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to parse node attributes"})
return
}

if req.Index < 0 || req.Index >= len(grants) {
c.JSON(http.StatusNotFound, gin.H{"error": "NodeAttr index out of range"})
return
Expand All @@ -163,19 +211,39 @@ func deleteNodeAttr(c *gin.Context, state *common.State) {
c.JSON(http.StatusOK, gin.H{"message": "Node attribute deleted"})
}

// getNodeAttrsFromState => re-marshal state.Data["nodeAttrs"] -> []tsclient.NodeAttrGrant
func getNodeAttrsFromState(state *common.State) ([]tsclient.NodeAttrGrant, error) {
raw := state.GetValue("nodeAttrs") // uses RLock internally
// getNodeAttrsFromState => read state.Data["nodeAttrs"]
func getNodeAttrsFromState(state *common.State) ([]ExtendedNodeAttrGrant, error) {
raw := state.GetValue("nodeAttrs")
if raw == nil {
return []tsclient.NodeAttrGrant{}, nil
return []ExtendedNodeAttrGrant{}, nil
}
b, err := json.Marshal(raw)
if err != nil {
return nil, err
}
var grants []tsclient.NodeAttrGrant
var grants []ExtendedNodeAttrGrant
if err := json.Unmarshal(b, &grants); err != nil {
return nil, err
}
return grants, nil
}

func convertAppConnectors(in map[string][]AppConnectorInput) map[string][]AppConnectorInput {
if in == nil {
return nil
}
out := make(map[string][]AppConnectorInput, len(in))
for key, arr := range in {
list := make([]AppConnectorInput, len(arr))
copy(list, arr) // or expand if needed
out[key] = list
}
return out
}

func exactlyOneOfAttrOrApp(input NodeAttrGrantInput) bool {
hasAttr := len(input.Attr) > 0
hasApp := len(input.App) > 0
// true only if one is true and the other is false
return (hasAttr || hasApp) && !(hasAttr && hasApp)
}
2 changes: 2 additions & 0 deletions pkg/acl/postures/postures.go
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
// pkg/acl/postures/postures.go

package postures

import (
Expand Down
2 changes: 2 additions & 0 deletions pkg/common/state.go
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
// pkg/common/state.go

package common

import (
Expand Down
2 changes: 2 additions & 0 deletions pkg/sync/sync.go
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
// pkg/sync/sync.go

package sync

import (
Expand Down
32 changes: 32 additions & 0 deletions terraform/examples/nodeattrs/example.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
terraform {
required_providers {
tacl = {
source = "lbrlabs/tacl"
version = "~> 1.0"
}
}
}

provider "tacl" {
endpoint = "http://tacl:8080"
}


resource "tacl_nodeattr" "example_attr" {
target = ["*"]
attr = ["nextdns:abc123", "nextdns:no-device-info"]
}

resource "tacl_nodeattr" "google" {
target = ["*"]

app_json = jsonencode({
"tailscale.com/app-connectors" = [
{
"name" = "google",
"connectors" = ["tag:router"],
"domains" = ["google.com"]
}
]
})
}
Loading