Overview

In this Lab we’ll set up the foundation of our agent. Specifically, we’ll create 3 components

  • Config system: where we can easily set different config parameters like target host, port, sleep, jitter etc.
  • Main agent logic: where our all-important runLoop() function, the “heart” of the agent, will live.
  • Main agent entrypoint: Where the high-level “orchestrating” of our agent occurs.

With that, let’s get to it.

internal/agent/config/config.go

We’ll create these new directors + file where we’ll place our config system.

The first thing we want to create is our Config struct, which is our definition of our custom data type we’re going to use to represent the configuration of any agent.

type Config struct {
	// Server connection details
	TargetHost string
	TargetPort string

	// Connection behavior
	RequestTimeout    time.Duration

	// Operational behavior
	Sleep  time.Duration
	Jitter float64 // As a percentage (0-100)

	// Identity
	AgentUUID string

	//Check-in Endpoint
	Endpoint string
}

Now a struct by itself is really just a blueprint. Much like how in OOP we need to instantiate a class to have an object we can actually work with, so we also need to instantiate a struct.

There are a few ways to do this, and we’ll cover all of them through the course of today’s workshop, but the first way I want to show you how to create an instance is probably the most idiomatic way - using a constructor function. Note that it is both similar in some regards, and different in others, that a constructor method in OOP.

So I’ll just show you what it looks like, then we can discuss it right afterward.


func NewConfig() *Config {
	return &Config{
		TargetHost: "127.0.0.1",
		TargetPort: "7777",

		RequestTimeout:    60 * time.Second,

		Sleep:  10 * time.Second,
		Jitter: 50.00,
		
		AgentUUID: "",

		Endpoint: "/",
	}

}

First thing, it’s conventional to name a constructor as “New” + whatever it instantiates. So, here we are instantiating the Config struct, hence NewConfig().

We can then see it’s returning a pointer to our instantiated struct, and then inside the function itself it’s pretty straightforward - we give every field a suitable value. Note that for now UUID is blank, we’ll generate a value for it in the following lab.

Also one thing to note is think of these as all being “suitable default” values - our constructor ensures that, at minimum, our Config struct instance has all the info it needs to run. But we are always to, at a later point, based on for example user input via the UI, to override any of these values.

Now that we have our config in place we’ll set up our agent’s core logic.

internal/agent/agent.go

Struct and Constructor

The first thing we want to do is, in similar fashion to our config, define what an instance of an Agent will look like by defining it’s struct.

type Agent struct {
	Config *config.Config

	client *http.Client

	stopChan  chan struct{}
	running   bool
	connected bool
}

The interesting, and also highly idiomatic, thing to note here is that we are of course embedding our config struct inside of Agent struct. At its core, embedding a struct in Go is the language’s idiomatic approach to “composition over inheritance”. This is a very interesting design consideration which we don’t have time for here, but I encourage you look it up.

We’ll also similarly use a constructor to instantiate our Agent struct.

func NewAgent(config *config.Config) *Agent {

	return &Agent{
		Config: config,
		client: &http.Client{
			Timeout: config.RequestTimeout},
		stopChan:  make(chan struct{}),
		running:   false,
		connected: false,
	}
}

The client is, in similar fashion to our listener/server, an instance we get from the net/http standard library. We can see here we only need a single argument to call it - RequestTimeout - which in this case we already defined in our config.

One this is done we’ll give our agent the two most basic commands - the ability to Start(), and to Stop().

Stop() and Start()

func (a *Agent) Start() error {
	if a.running {
		return fmt.Errorf("agent already running")
	}

	a.running = true

	go a.runLoop()

	return nil
}

With Start() we’ll first check to ensure it’s not running yet, if so we’ll exit. If not, we’ll set it as running, and then call another function, a receiver function called runLoop(), in its own goroutine.

Our Stop() function will be similar in some regards

func (a *Agent) Stop() error {
	if !a.running {
		return fmt.Errorf("agent not running")
	}

	close(a.stopChan)
	a.running = false

	fmt.Println("Agent stopped")
	return nil
}

Now we check it’s already not running, and exit if indeed so. If not we call a built-in function called close(), and pass it as argument. This will essentially “trigger” our stopChan, which will serve as a signal for the agent to stop. How exactly? You’ll see that when we build our runLoop() function next.

runLoop()

As I mentioned, this is really where the heart of our agent is, an infinite for loop that is going to send requests, receive and process responses, sleep, and do it over and over again until it’s signalled to stop (or crashes).


func (a *Agent) runLoop() {
	for {
		select {
		case <-a.stopChan:
			return
		default:
			sleepTime := a.CalculateSleepWithJitter()

			err := a.Connect()
			if err != nil {
				fmt.Printf("Connection error: %v\n", err)
				time.Sleep(sleepTime)
				continue
			}

			resp, err := a.SendRequest(a.Config.Endpoint)
			if err != nil {
				fmt.Printf("Request error: %v\n", err)
				time.Sleep(sleepTime)
				continue
			}

			body, err := io.ReadAll(resp.Body)
			resp.Body.Close()

			if err != nil {
				log.Printf("Error reading response body: %v\n", err)
			} else {
				log.Printf("Response: %s\n", string(body))
			}

			time.Sleep(sleepTime)
		}
	}
}

The first thing to note is we have a select statement, and unlike the one we used in Lab 2, this time it we’ve introduced some logic into the fray. You can think of a select statement structurally like a switch statement, but instead of making decision based on input/argument values, we do so based on channel signals.

And the one here is a very specific form where we only have one real (non-default) case - stopChan. Recall that in Stop() I said that when we call close() on our stopChan, it will essentially “trigger” it? Well, here we are saying: in case stopChan is ever triggered, do this thing. And what do we do - we break out of the for{} loop, thus causing the agent to stop.

And then with this pattern, all the code we want to run continuously when stopChan has not yet been called we simply place in the “default” case. In other words, all the main operational logic of our agent goes here.

First, we’ll calculate sleepTime as the return value from a helper method we’ve not created yet called CalculateSleepWithJitter(). We’ll discuss that later.

We’ll then connet by calling a Connect(), which also does not exist yet. Ditto for sending a request using SendRequest().

For our response we use the library function io.ReadAll(), which can take body of the HTTP response (resp), and functionally convert it from an open stream into a byte slice ([]byte) we can work with in Go. We then also call Close() on it to terminate the stream and release all resources associated with it.

Once we’re done with that we’ll sleep, before repeating the whole loop again.

Let’s now go build out all the functions we referenced here, but which don’t yet exist.

Connect() and GetTargetAddress()

func (a *Agent) Connect() error {
	url := fmt.Sprintf("http://%s/", a.GetTargetAddress())

	req, err := http.NewRequest("HEAD", url, nil)
	if err != nil {
		return fmt.Errorf("failed to create request: %v", err)
	}

	resp, err := a.client.Do(req)
	if err != nil {
		return fmt.Errorf("connection failed: %v", err)
	}
	defer resp.Body.Close()

	a.connected = true
	return nil
}

We construct the url using another helper function GetTargetAddress(), we’ve also yet to create. Here we simply use a lightweight HEAD request as a means to force the underlying network stack to establish a TCP connection (if needed).

Let’s also take care of our new helper function, which aims to simply make our lives easier by combining our target’s IP and port. But why bother? We’ll instead of having to use fmt.Sprintf numerous times each time we need to combine them, we can just create this function, and call on it to abstract all that away. It’s just a bit of tidying, but nothing essential obviously.

func (a *Agent) GetTargetAddress() string {
	return fmt.Sprintf("%s:%s", a.Config.TargetHost, a.Config.TargetPort)
}

SendRequest

After we’ve ensured we’re connected we’ll send a request:

func (a *Agent) SendRequest(endpoint string) (*http.Response, error) {
	// Check if we're connected
	if !a.connected {
		return nil, fmt.Errorf("not connected to server")
	}

	// Create the full URL
	url := fmt.Sprintf("http://%s%s", a.GetTargetAddress(), endpoint)

	// Create the request
	req, err := http.NewRequest("GET", url, nil)
	if err != nil {
		return nil, fmt.Errorf("failed to create request: %v", err)
	}

	// Add basic headers
	req.Header.Set("User-Agent", "Mozilla/5.0")

	// Send the request
	resp, err := a.client.Do(req)
	if err != nil {
		a.connected = false 
		return nil, fmt.Errorf("request failed: %v", err)
	}

	return resp, nil
}

Everything should be well explained by the accompanying comments, just note as well our ability to manually set our HTTP Headers. In this case we’ve set User-Agent, just to illustrate, but this ability will come in use in our next lab.

CalculateSleepWithJitter()

We now get to our final helper function, which will calculate our sleep time as a product of sleep and jitter each time it’s called.

func (a *Agent) CalculateSleepWithJitter() time.Duration {
	// Apply jitter as a percentage of the base sleep time
	jitterFactor := 1.0 + (rand.Float64() * a.Config.Jitter / 100.0)
	return time.Duration(float64(a.Config.Sleep) * jitterFactor)
}

We now have our agent’s config and operational logic in place, the only thing left to do now is create our agent’s main entrypoint to orchestrate its execution.

cmd/agent/main.go

package main

import (
		// imports here 
)

func main() {
	// Set up signal handling for graceful shutdown
	sigChan := make(chan os.Signal, 1)
	signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)

	// Initialize configuration with defaults
	agentConfig := config.NewConfig()

	// Create agent instance
	c2Agent := agent.NewAgent(agentConfig)

	// Start agent
	err := c2Agent.Start()
	if err != nil {
		fmt.Printf("Failed to start agent: %v\n", err)
		os.Exit(1)
	}

	fmt.Println("Agent started!")
	fmt.Printf("Connected to: %s\n", c2Agent.GetTargetAddress())

	// Wait for termination signal
	<-sigChan

	// Gracefully stop the agent
	fmt.Println("Shutting down agent...")
	c2Agent.Stop()

}

Right at the top we’ll set up signal handling to allow for graceful shutdown. Now of course in actual practice we would not need this since an agent is running on someone else’s system to which we don’t have access to. This is really just to help us now while developing to give us an ability to stop our agent gracefully.

We’ll then initialize our Config struct by calling the constructor. This allows us to then create our actual agent by calling its constructor, passing the config as an argument.

We then Start() our agent, after which we wait for the termination signal (SIGTERM). Once called it will unblock the main thread, causing the Stop() function to be called on the agent.

Test

Let’s first start our server (go run ./cmd/server), and then we can start our agent (go run ./cmd/agent).

We’ll immediately see that our agent will automatically hit our server’s endpoint every 5 - 7.5 seconds, and we’ll periodically see the expected output on both the server and agent console.

lab03

lab03


|TOC| |PREV| |NEXT|