TCP based file transfer in go

Introduction to TCP

Transmission Control Protocol (TCP) is a fundamental communication protocol that plays a pivotal role in ensuring reliable and orderly data transmission across computer networks. It is one of the core components of the Internet Protocol Suite, commonly referred to as the TCP/IP stack.

TCP is commonly used for file-sharing applications due to its reliability and connection-oriented nature. When sharing files over a network, especially in scenarios where data integrity is crucial, TCP provides several benefits:

  1. Reliable Data Transfer: TCP ensures that data is delivered accurately and in the correct order. If any packets are lost or arrive out of order during transmission, TCP automatically requests re-transmission, minimising the risk of data corruption in file sharing.

  2. Flow Control: TCP includes mechanisms for flow control, preventing the sender from overwhelming the receiver with data. This ensures that the receiver can manage and process the incoming file data at its own pace.

  3. Error Detection and Correction: TCP employs checksum to detect errors in data transmission. If errors are detected, TCP requests re-transmission of the corrupted packets, enhancing the overall reliability of file sharing.

  4. Acknowledgements: TCP uses acknowledgements to confirm the successful receipt of data packets. This acknowledgement mechanism allows the sender to know when it can safely send the next segment of the file.

  5. Connection Establishment and Termination: TCP establishes and terminates connections through a process known as the TCP handshake. This ensures that both parties are ready for data exchange and that the connection is closed gracefully when the file transfer is complete.

  6. Port-Based Communication: TCP uses port numbers to identify specific applications or services on a device. This feature is essential for routing the file-sharing traffic to the correct application.

System flow

Since TCP follows a client-server architecture, data transfer occurs between client and server. We will make use of two types of requests -

  • Header: Sends metadata regarding the incoming data

  • Segment: Sends the actual file data

Header

To recreate a file on the server, we need the data and the name. For example, if we were to send results.pdf to the server, the server would require data from the file and the name "results.pdf" to recreate the file.

To ensure that the application can send a file of any size, we send file data in segments of 1024 bytes. The number of segments that need to be sent is also a part of the header.

The structure of a typical header is -

Start (all 1s) - 1 byte
Reps (number of segments) - 4 bytes
Lengthofname - 4 bytes
Name - `lengthofname` bytes
End (all 0s) - 1 byte;

Segment

Each segment is 1024 bytes. A segment consists of the actual file data. We send it along with the segment number as part of 1024 bytes. The maximum size of data can be 1014 bytes. Hence, to determine the number of segments we use 1014 bytes as the available size.

The structure of a typical segment is -

Start (all 0s) - 1 byte
Segment number - 4 bytes
Lengthofdata - 4 bytes
Data - `lengthofdata` bytes
End (all 1s) - 1 byte

Setting up the client

const (
    HOST = "localhost"
    PORT = "9001"
    TYPE = "tcp"
)

type MetaData struct {
    name     string
    fileSize uint32
    reps     uint32
}

We define the HOST, PORT and TYPE properties required to set up a TCP connection along with a MetaData struct that will hold properties of the file.

func prepareMetadata(file *os.File) MetaData {

    fileInfo, err := file.Stat()

    if err != nil {
        log.Fatal(err)
    }

    size := fileInfo.Size()

    header := MetaData{
        name:     file.Name(),
        fileSize: uint32(size),
        reps:     uint32(size / 1014) + 1,
    }

    return header
}

To determine the MetaData we create a prepareMetadata function that accepts a file object as input and returns a MetaData struct with the required properties.

func main() {
    tcpServer, err := net.ResolveTCPAddr(TYPE, HOST+":"+PORT)
    if err != nil {
        log.Fatal(err)
    }

    conn, err := net.DialTCP(TYPE, nil, tcpServer)
    if err != nil {
        log.Fatal(err)
    }

    sendFile("<FILE PATH>", conn)

    received := make([]byte, 1024)

    _, err = conn.Read(received)

    if err != nil {
        log.Fatal(err)
    }

    println(string(received))
}

We define the main function in which we set up a TCP client and connect to a server. We pass the file path and connection object to a sendFile, a custom function that will perform the transfer.

func sendFile(path string, conn *net.TCPConn) {
    file, err := os.OpenFile(path, os.O_RDONLY, 0755)

    if err != nil {
        log.Fatal(err)
    }

    header := prepareMetadata(file)

    dataBuffer := make([]byte, 1014)

    // Start (all 1s) - 1 byte, reps - 4 bytes, lengthofname - 4 bytes, name - `lengthofname` bytes, End (all 0s) - 1 byte;
    headerBuffer := []byte{1}

    // Start (all 0s) - 1 byte, Segment number - 4 bytes, lengthofdata - 4 bytes, Data - `lengthofdata` bytes, End (all 1s) - 1 byte
    segmentBuffer := []byte{0}

    // Temporary buffer for uint32
    temp := make([]byte, 4)

    // Temporary buffer for responses received
    received := make([]byte, 100);

    for i := 0; i < int(header.reps); i++ {
        n, _ := file.ReadAt(dataBuffer, int64(i*1014))

        if i == 0 {
            // Number of segments
            binary.BigEndian.PutUint32(temp, header.reps)
            headerBuffer = append(headerBuffer, temp...)

            // Length of name
            binary.BigEndian.PutUint32(temp, uint32(len(header.name)))
            headerBuffer = append(headerBuffer, temp...)

            // Name
            headerBuffer = append(headerBuffer, []byte(header.name)...)

            headerBuffer = append(headerBuffer, 0)

            _, err := conn.Write(headerBuffer)

            if err != nil {
                log.Fatal(err)
            }

            _, err = conn.Read(received)

            if err != nil {
                log.Fatal(err)
            }

            println(string(received))
        }

        // Segment number
        binary.BigEndian.PutUint32(temp, uint32(i))
        segmentBuffer = append(segmentBuffer, temp...);

        // Length of data
        binary.BigEndian.PutUint32(temp, uint32(n))
        segmentBuffer = append(segmentBuffer, temp...)

        // Data
        segmentBuffer = append(segmentBuffer, dataBuffer...)

        segmentBuffer = append(segmentBuffer, 1)

        _, err = conn.Write(segmentBuffer);

        if err != nil {
            log.Fatal(err);
        }

        _, err = conn.Read(received);
        fmt.Println(string(received));

        if err != nil {
            log.Fatal(err)
        }

        // Reset segment buffer
        segmentBuffer = []byte{0};
    }

}

We define buffers for header, data, and segment. Since we know the number of segments required to send the data we create a loop. All buffers work with uint32 variables. 32 bits translates to 4 bytes for each integer value in the header or segment.

In the first iteration, we use the header buffer to send the header before the segment. In every following iteration, we only send the segment. At the end of every iteration, we reset the segment buffer.

Setting up the server

const (
    HOST = "localhost"
    PORT = "9001"
    TYPE = "tcp"
)

func main(){
    listen, err := net.Listen(TYPE, HOST + ":" + PORT)
    if err != nil {
        log.Fatal(err)
    }
    defer listen.Close()

    println("Server has started on PORT " + PORT)

    for {
        conn, err := listen.Accept()
        if err != nil {
            log.Fatal(err)
        }
        println("Hello");
        go handleIncomingRequests(conn)
    }
}

The constants defined in the server are the same as the ones defined in the client. The difference begins with the main function. We create a TCP server that listens on the PORT defined.

Upon receiving a connection, the server hands it over to handleIncomingRequests function in a goroutine.

func handleIncomingRequests(conn net.Conn){
    println("Received a request: " + conn.RemoteAddr().String());
    headerBuffer := make([]byte, 1024);

    _, err := conn.Read(headerBuffer);
    if err != nil {
        log.Fatal(err);
    }

    var name string;
    var reps uint32;

    if(headerBuffer[0] == byte(1) && headerBuffer[1023] == byte(0)){
        reps = binary.BigEndian.Uint32(headerBuffer[1:5]);
        lengthOfName := binary.BigEndian.Uint32(headerBuffer[5:9]);
        name = string(headerBuffer[9:9+lengthOfName]);
    } else {
        log.Fatal("Invalid header");
    }

    conn.Write([]byte("Header Received"));

    dataBuffer := make([]byte, 1024);

    file, err := os.Create("./received/" + name);

    if err != nil {
        log.Fatal(err);
    }

    for i := 0; i<int(reps); i++ {
        _, err := conn.Read(dataBuffer);
        if err != nil {
            log.Fatal(err);
        }

        if(dataBuffer[0] == byte(0) && dataBuffer[1023] == byte(1)){
            segmentNumber := dataBuffer[1:5];
            fmt.Printf("Segment Number: %d\n", binary.BigEndian.Uint32(segmentNumber));
            length := binary.BigEndian.Uint32(dataBuffer[5:9]);
            fmt.Printf("File Data: %s\n", hex.EncodeToString(dataBuffer[9:9+length]));
            file.Write(dataBuffer[9:9+length]);
        } else {
            log.Fatal("Invalid Segment");
        }

        conn.Write([]byte("Segment Received"));
    }

    time := time.Now().UTC().Format("Monday, 02-Jan-06 15:04:05 MST");

    conn.Write([]byte(time));

    file.Close();
    conn.Close();
}

In this function, we reverse the process that the client follows. The server parses the header to set two variables - the name of the file and the number of segments that the server expects to receive.

In each iteration of the loop, the server parses the segment data that it has received and appends the file data to the file object created earlier. At the end, it closes the file and the connection.

Everything in action

Let's run the application to see the flow of data across the client and server. I have used a PNG file with the name Me.png to test the application. It is an image file of size 192.9 KB so it's sufficiently larger than the buffer size of 1024 bytes (1 KB). Considering the data size of 1014 B, we can expect around 193 segments. Both server and client are configured to run locally.

Using Wireshark, we can see the data flowing around in our system.

As seen from the picture above, Wireshark captures all the packets sent through the application. We see the metadata TCP adds, source address, destination address, packet length and IP.

Notice the first 3 captured frames.

These 3 frames indicate the 3-way handshake that takes place at the start of every TCP connection.

Establishing the 3-way handshake

  • Step 1 (SYN): The client sends a TCP packet with the SYN (Synchronise) flag set to the server. This packet indicates the client’s desire to establish a connection and initiates the process. It also includes an initial sequence number.

  • Step 2 (SYN-ACK): Upon receiving the SYN packet, the server responds with a TCP packet of its own. This packet has the SYN and ACK (Acknowledgement) flags set. It acknowledges the receipt of the client’s SYN packet, indicates its readiness to establish the connection, and provides its initial sequence number.

  • Step 3 (ACK): Finally, the client acknowledges the server’s response by sending a TCP packet with the ACK flag set. This packet confirms that the connection is established, and both parties are synchronised. The data transfer can now begin.

Examining the Header

Wireshark can parse the frame and display its contents. We can see Me.png along with other details parsed as part of the Header.

Examining the Segment

We can pick a random segment to examine.

We can also view the raw segment data. We notice that the data is 1024 B as expected.


The full code is available in this repository

3
Subscribe to my newsletter

Read articles from Srinivasa Varanasi directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Srinivasa Varanasi
Srinivasa Varanasi