.github/* .env* go.* *.md *.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") }
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 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"