Skip to content

Latest commit

 

History

History
48 lines (32 loc) · 11.5 KB

codebase.md

File metadata and controls

48 lines (32 loc) · 11.5 KB

.aidigestignore

.github/* .env* go.* *.md *.pem

.gitignore

.env auth.pem

cmd/teleport-discord-bot/main.go

package main import ( "context" "fmt" "os" "os/signal" "syscall" "github.com/dwarvesf/teleport-discord-bot/internal/config" "github.com/dwarvesf/teleport-discord-bot/internal/discord" "github.com/dwarvesf/teleport-discord-bot/internal/teleport" ) func main() { // Load configuration cfg, err := config.Load() if err != nil { fmt.Fprintf(os.Stderr, "Failed to load configuration: %v\n", err) os.Exit(1) } // Create Discord client discordClient := discord.NewClient(cfg) // Create Teleport plugin plugin, err := teleport.NewPlugin(cfg, discordClient) if err != nil { fmt.Fprintf(os.Stderr, "Failed to create Teleport plugin: %v\n", err) os.Exit(1) } defer plugin.Close() // Create a context that can be cancelled ctx, cancel := context.WithCancel(context.Background()) defer cancel() // Set up signal handling for graceful shutdown sigChan := make(chan os.Signal, 1) signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) // Run the plugin in a separate goroutine errChan := make(chan error, 1) go func() { errChan <- plugin.Run(ctx) }() // Wait for either a signal or an error select { case sig := <-sigChan: fmt.Printf("Received signal %v, shutting down...\n", sig) cancel() case err := <-errChan: if err != nil { fmt.Fprintf(os.Stderr, "Plugin error: %v\n", err) os.Exit(1) } } // Wait for the plugin to finish <-errChan fmt.Println("Teleport Discord bot shutdown complete") }

Dockerfile

FROM golang:1.23-alpine AS builder # Set working directory WORKDIR /app # Install git and ca-certificates RUN apk add --no-cache git ca-certificates \ gcc \ musl-dev # Copy go mod and sum files COPY go.mod go.sum ./ # Download dependencies RUN go mod download # Copy the source code COPY . . # Build the application RUN go install --tags musl ./... # Final stage FROM alpine:latest # Install ca-certificates for HTTPS RUN apk --no-cache add ca-certificates # Create a non-root user RUN addgroup -S appgroup && adduser -S appuser -G appgroup # Set working directory WORKDIR /app # Copy the built binary from builder COPY --from=builder /go/bin/* /usr/bin/ COPY auth.pem auth.pem # Change to non-root user USER appuser # Command to run the executable ENTRYPOINT ["teleport-discord-bot"]

internal/config/config.go

package config import ( "fmt" "os" "github.com/joho/godotenv" ) // Config represents the application configuration type Config struct { ProxyAddr string DiscordWebhookURL string WatcherList string AuthPemPath string } // Load reads configuration from environment variables func Load() (*Config, error) { // Load .env file if it exists if err := godotenv.Load(); err != nil { fmt.Println("No .env file found, using environment variables") } cfg := &Config{ ProxyAddr: os.Getenv("PROXY_ADDR"), DiscordWebhookURL: os.Getenv("DISCORD_WEBHOOK_URL"), WatcherList: os.Getenv("WATCHER_LIST"), AuthPemPath: os.Getenv("AUTH_PEM_PATH"), } // Validate required configuration if cfg.ProxyAddr == "" { return nil, fmt.Errorf("PROXY_ADDR is required") } if cfg.DiscordWebhookURL == "" { return nil, fmt.Errorf("DISCORD_WEBHOOK_URL is required") } if cfg.AuthPemPath == "" { return nil, fmt.Errorf("AUTH_PEM_PATH is required") } return cfg, nil }

internal/discord/client.go

package discord import ( "fmt" "strings" "time" "github.com/dwarvesf/teleport-discord-bot/internal/config" "github.com/gravitational/teleport/api/types" "github.com/gtuk/discordwebhook" ) // Client handles Discord webhook notifications type Client struct { url string cfg *config.Config } // NewClient creates a new Discord webhook client func NewClient(cfg *config.Config) *Client { return &Client{ url: cfg.DiscordWebhookURL, cfg: cfg, } } // sendWebhookNotification sends a message to the configured Discord webhook func (d *Client) sendWebhookNotification(message discordwebhook.Message) error { return discordwebhook.SendMessage(d.url, message) } // ptrString is a helper to convert string to pointer func ptrString(s string) *string { return &s } // ptrBool is a helper to convert bool to pointer func ptrBool(b bool) *bool { return &b } // HandleNewAccessRequest creates a notification for a new access request func (d *Client) HandleNewAccessRequest(r types.AccessRequest) error { fields := []discordwebhook.Field{ { Name: ptrString("Request ID"), Value: ptrString(r.GetName()), Inline: ptrBool(false), }, { Name: ptrString("User"), Value: ptrString(r.GetUser()), Inline: ptrBool(true), }, { Name: ptrString("Roles"), Value: ptrString(strings.Join(r.GetRoles(), ", ")), Inline: ptrBool(true), }, { Name: ptrString("Session TTL"), Value: ptrString(r.GetSessionTLL().Sub(r.GetCreationTime()).Round(time.Second).String()), Inline: ptrBool(true), }, } if r.GetRequestReason() != "" { fields = append(fields, discordwebhook.Field{ Name: ptrString("Request Reason"), Value: ptrString(r.GetRequestReason()), Inline: ptrBool(false), }) } embed := discordwebhook.Embed{ Title: ptrString("New Access Request"), Description: ptrString(fmt.Sprintf("Approve request by running command %s: \`\`\`tctl requests approve %s\`\`\`", d.cfg.WatcherList, r.GetName())), Color: ptrString("3093206"), Fields: &fields, } message := discordwebhook.Message{ Embeds: &[]discordwebhook.Embed{embed}, } if err := d.sendWebhookNotification(message); err != nil { return fmt.Errorf("failed to send new access request notification: %w", err) } return nil } // HandleApproveAccessRequest creates a notification for an approved access request func (d *Client) HandleApproveAccessRequest(r types.AccessRequest) error { embed := discordwebhook.Embed{ Title: ptrString("Access Request Approved"), Color: ptrString("2021216"), Fields: &[]discordwebhook.Field{ { Name: ptrString("Request ID"), Value: ptrString(r.GetName()), Inline: ptrBool(false), }, { Name: ptrString("User"), Value: ptrString(r.GetUser()), Inline: ptrBool(true), }, { Name: ptrString("Roles"), Value: ptrString(strings.Join(r.GetRoles(), ", ")), Inline: ptrBool(true), }, }, } message := discordwebhook.Message{ Embeds: &[]discordwebhook.Embed{embed}, } if err := d.sendWebhookNotification(message); err != nil { return fmt.Errorf("failed to send access request approval notification: %w", err) } return nil } // HandleDenyAccessRequest creates a notification for a denied access request func (d *Client) HandleDenyAccessRequest(r types.AccessRequest) error { embed := discordwebhook.Embed{ Title: ptrString("Access Request Denied"), Color: ptrString("15158332"), Fields: &[]discordwebhook.Field{ { Name: ptrString("Request ID"), Value: ptrString(r.GetName()), Inline: ptrBool(false), }, { Name: ptrString("User"), Value: ptrString(r.GetUser()), Inline: ptrBool(true), }, { Name: ptrString("Roles"), Value: ptrString(strings.Join(r.GetRoles(), ", ")), Inline: ptrBool(true), }, { Name: ptrString("Reason"), Value: ptrString(r.GetResolveReason()), Inline: ptrBool(false), }, }, } message := discordwebhook.Message{ Embeds: &[]discordwebhook.Embed{embed}, } if err := d.sendWebhookNotification(message); err != nil { return fmt.Errorf("failed to send access request denial notification: %w", err) } return nil }

internal/teleport/plugin.go

package teleport import ( "context" "fmt" "github.com/gravitational/teleport/api/client" "github.com/gravitational/teleport/api/types" "github.com/gravitational/trace" "google.golang.org/grpc" "github.com/dwarvesf/teleport-discord-bot/internal/config" ) // EventHandler defines the interface for handling Teleport events type EventHandler interface { HandleNewAccessRequest(r types.AccessRequest) error HandleApproveAccessRequest(r types.AccessRequest) error HandleDenyAccessRequest(r types.AccessRequest) error } // Plugin manages Teleport access request monitoring type Plugin struct { client *client.Client eventHandler EventHandler } // NewPlugin creates a new Teleport access request plugin func NewPlugin(cfg *config.Config, eventHandler EventHandler) (*Plugin, error) { ctx := context.Background() // Create a new Teleport client teleportClient, err := client.New(ctx, client.Config{ Addrs: []string{cfg.ProxyAddr}, Credentials: []client.Credentials{ client.LoadIdentityFile(cfg.AuthPemPath), }, DialOpts: []grpc.DialOption{ grpc.WithReturnConnectionError(), }, }) if err != nil { return nil, trace.Wrap(err, "failed to create Teleport client") } return &Plugin{ client: teleportClient, eventHandler: eventHandler, }, nil } // Run starts watching for access request events func (p *Plugin) Run(ctx context.Context) error { // Create a new watcher for access requests watch, err := p.client.NewWatcher(ctx, types.Watch{ Kinds: []types.WatchKind{ {Kind: types.KindAccessRequest}, }, }) if err != nil { return trace.Wrap(err, "failed to create watcher") } defer watch.Close() fmt.Println("Starting the access request watcher") for { select { case e := <-watch.Events(): if err := p.handleEvent(ctx, e); err != nil { return trace.Wrap(err, "error handling event") } case <-watch.Done(): fmt.Println("The watcher job is finished") return nil case <-ctx.Done(): return ctx.Err() } } } // handleEvent processes individual Teleport events func (p *Plugin) handleEvent(ctx context.Context, event types.Event) error { if event.Resource == nil { return nil } // Check if this is a watch status event (initial connection) if _, ok := event.Resource.(*types.WatchStatusV1); ok { fmt.Println("Successfully started listening for Access Requests...") return nil } // Try to cast the resource to an AccessRequest r, ok := event.Resource.(types.AccessRequest) if !ok { fmt.Printf("Unknown (%v) event received, skipping.\n", event.Resource) return nil } // Handle different access request states switch r.GetState() { case types.RequestState_PENDING: return p.eventHandler.HandleNewAccessRequest(r) case types.RequestState_APPROVED: return p.eventHandler.HandleApproveAccessRequest(r) case types.RequestState_DENIED: return p.eventHandler.HandleDenyAccessRequest(r) default: fmt.Printf("Unhandled access request state: %v\n", r.GetState()) return nil } } // Close terminates the Teleport client connection func (p *Plugin) Close() { if p.client != nil { p.client.Close() } }

Makefile

# Makefile for Teleport Discord Bot # Go parameters GOCMD=go GOBUILD=$(GOCMD) build GOTEST=$(GOCMD) test GOMOD=$(GOCMD) mod BINARY_NAME=teleport-discord-bot MAIN_PATH=./cmd/teleport-discord-bot # Environment export GO111MODULE=on # Default target .PHONY: all all: test build # Build the application .PHONY: build build: $(GOBUILD) -o $(BINARY_NAME) $(MAIN_PATH) # Run tests .PHONY: test test: $(GOTEST) -v ./... # Run tests with coverage .PHONY: test-coverage test-coverage: $(GOTEST) -coverprofile=coverage.out ./... $(GOCMD) tool cover -html=coverage.out # Clean up build artifacts .PHONY: clean clean: rm -f $(BINARY_NAME) rm -f coverage.out # Lint the code .PHONY: lint lint: golangci-lint run # Install dependencies .PHONY: deps deps: $(GOMOD) download $(GOMOD) tidy # Run the application locally .PHONY: run run: $(GOBUILD) -o $(BINARY_NAME) $(MAIN_PATH) ./$(BINARY_NAME) # Development setup .PHONY: setup setup: @echo "Installing development tools..." go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest .PHONY: digest digest: npx ai-digest --whitespace-removal # Help target .PHONY: help help: @echo "Available targets:" @echo " all - Run tests and build the application" @echo " build - Build the application" @echo " test - Run tests" @echo " test-coverage - Run tests with coverage report" @echo " clean - Remove build artifacts" @echo " lint - Run golangci-lint" @echo " deps - Download and tidy dependencies" @echo " run - Build and run the application" @echo " setup - Install development tools" @echo " help - Show this help message"