From 374f7c8249455233ac8893cb34c5e1df6acba81c Mon Sep 17 00:00:00 2001
From: Tobias Tangemann <tobias@tangemann.org>
Date: Sun, 10 Nov 2024 18:47:06 +0100
Subject: [PATCH 1/7] Add rcon client to container

---
 docker/Dockerfile              |  11 +-
 docker/files/players-online.sh |  11 ++
 docker/rcon/Makefile           |  13 ++
 docker/rcon/main.c             | 217 +++++++++++++++++++++++++++++++++
 4 files changed, 251 insertions(+), 1 deletion(-)
 create mode 100755 docker/files/players-online.sh
 create mode 100644 docker/rcon/Makefile
 create mode 100644 docker/rcon/main.c

diff --git a/docker/Dockerfile b/docker/Dockerfile
index 2aaec4c..4b1743a 100755
--- a/docker/Dockerfile
+++ b/docker/Dockerfile
@@ -1,5 +1,13 @@
-FROM debian:stable-slim
+FROM debian:stable-slim AS builder
+RUN apt-get -q update \
+    && DEBIAN_FRONTEND=noninteractive apt-get -qy install build-essential
 
+WORKDIR /src
+COPY rcon/* /src
+RUN make
+
+# build factorio image
+FROM debian:stable-slim
 LABEL maintainer="https://github.com/factoriotools/factorio-docker"
 
 ARG USER=factorio
@@ -81,6 +89,7 @@ RUN set -ox pipefail \
 
 COPY files/*.sh /
 COPY files/config.ini /opt/factorio/config/config.ini
+COPY --from=builder /src/rcon /bin/rcon
 
 VOLUME /factorio
 EXPOSE $PORT/udp $RCON_PORT/tcp
diff --git a/docker/files/players-online.sh b/docker/files/players-online.sh
new file mode 100755
index 0000000..5a78001
--- /dev/null
+++ b/docker/files/players-online.sh
@@ -0,0 +1,11 @@
+#!/bin/bash
+
+PLAYERS=$(docker exec xenodochial_spence rcon /players)
+ONLINE_COUNT=$(echo "$PLAYERS" | grep -c " (online)$")
+
+if [[ "$ONLINE_COUNT" -gt "0" ]]; then
+    echo "$PLAYERS"
+    # exit with 75 (EX_TEMPFAIL) for watchtower
+    # https://containrrr.dev/watchtower/lifecycle-hooks/
+    exit 75
+fi
diff --git a/docker/rcon/Makefile b/docker/rcon/Makefile
new file mode 100644
index 0000000..71131c0
--- /dev/null
+++ b/docker/rcon/Makefile
@@ -0,0 +1,13 @@
+# Optimization
+OPT = -O3 -flto
+TARGET = rcon
+
+CC = gcc
+CFLAGS = -std=c17 -Wall -Wextra -pedantic $(OPT)
+REMOVE = rm -f
+
+all:
+	$(CC) $(CFLAGS) main.c -o $(TARGET)
+
+clean:
+	$(REMOVE) $(TARGET)
diff --git a/docker/rcon/main.c b/docker/rcon/main.c
new file mode 100644
index 0000000..5eb7206
--- /dev/null
+++ b/docker/rcon/main.c
@@ -0,0 +1,217 @@
+#include <stdio.h>
+#include <stdint.h>
+#include <stdbool.h>
+#include <unistd.h>
+#include <netdb.h>
+#include <stdlib.h>
+#include <string.h>
+#include <time.h>
+
+#include <arpa/inet.h>
+
+#define MIN_PACKET 10
+#define MAX_PACKET 4096
+#define MAX_BODY (MAX_PACKET - (3 * sizeof(uint32_t)) - 2)
+
+#define RCON_HOST "127.0.0.1"
+
+typedef enum {
+    RCON_TYPE_RESPONSE = 0,
+    RCON_TYPE_EXECCOMMAND = 2,
+    RCON_TYPE_AUTH_RESPONSE = 2,
+    RCON_TYPE_AUTH = 3,
+} packet_type;
+
+typedef struct {
+    uint32_t length;
+    uint32_t id;
+    packet_type type;
+    char body[MAX_BODY];
+} packet;
+
+int rcon_open(const char *port);
+void rcon_create(packet* pkt, packet_type type, const char* body);
+bool rcon_send(int rcon_socket, const packet* pkt);
+bool rcon_auth(int rcon_socket, const char* password);
+bool rcon_recv(int rcon_socket, packet* pkt, packet_type expected_type);
+char* combine_args(int argc, char* argv[]);
+char* read_password(const char* conf_dir);
+
+int main(int argc, char* argv[]) {
+    if (argc < 2) {
+        fprintf(stderr, "error: missing command argument\n");
+        return EXIT_FAILURE;
+    }
+    
+    srand((unsigned int)time(NULL));
+    
+    const char* port = getenv("RCON_PORT");
+    if (port == NULL) {
+        fprintf(stderr, "error: missing $RCON_PORT env\n");
+        return EXIT_FAILURE;
+    }
+    
+    const char* conf_dir = getenv("CONFIG");
+    if (conf_dir == NULL) {
+        fprintf(stderr, "error: missing $CONFIG env");
+        exit(EXIT_FAILURE);
+    }
+    
+    int rcon_socket = rcon_open(port);
+    if (rcon_socket == -1) {
+        fprintf(stderr, "error: could not connect\n");
+        return EXIT_FAILURE;
+    }
+    
+    if (!rcon_auth(rcon_socket, read_password(conf_dir))) {
+        fprintf(stderr, "error: login failed\n");
+        return EXIT_FAILURE;
+    }
+    
+    packet pkt;
+    rcon_create(&pkt, RCON_TYPE_EXECCOMMAND, combine_args(argc, argv));
+    if (!rcon_send(rcon_socket, &pkt)) {
+        fprintf(stderr, "error: send command failed\n");
+        return EXIT_FAILURE;
+    }
+    
+    if (rcon_recv(rcon_socket, &pkt, RCON_TYPE_RESPONSE) && pkt.length > 0) {
+        puts(pkt.body);
+    }
+    
+    return EXIT_SUCCESS;
+}
+
+char* combine_args(int argc, char* argv[]) {
+    // combine all cli arguments
+    char* command = malloc(MAX_BODY);
+    memset(command, 0, MAX_BODY);
+    strcat(command, argv[1]);
+    
+    for (int idx = 2; idx < argc; idx++) {
+        strcat(command, " ");
+        strcat(command, argv[idx]);
+    }
+    
+    return command;
+}
+
+char* read_password(const char* conf_dir) {
+    char* path = malloc(strlen(conf_dir) + 64);
+    strcpy(path, conf_dir);
+    strcat(path, "/rconpw");
+    
+    FILE* fptr = fopen(path, "r");
+    fseek(fptr, 0, SEEK_END);
+    long fsize = ftell(fptr);
+    fseek(fptr, 0, SEEK_SET);  /* same as rewind(f); */
+
+    char *password = malloc(fsize + 1);
+    fread(password, fsize, 1, fptr);
+    fclose(fptr);
+
+    password[fsize] = 0;
+    if (password[fsize-1] == '\n') {
+        password[fsize-1] = 0;
+    }
+    
+    return password;
+}
+
+int rcon_open(const char *port) {
+    struct sockaddr_in address = {
+        .sin_family = AF_INET,
+        .sin_port = htons(atoi(port))
+    };
+    inet_aton(RCON_HOST, &address.sin_addr);
+    
+    int rcon_socket = socket(AF_INET, SOCK_STREAM, 0);
+    if (connect(rcon_socket, (struct sockaddr*) &address, sizeof(address)) < 0) {
+        return -1;
+    } else {
+        return rcon_socket;
+    }
+}
+
+void rcon_create(packet* pkt, packet_type type, const char* body) {
+    size_t body_length = strlen(body);
+    if (body_length >= MAX_BODY - 2) {
+        fprintf(stderr, "error: command to long");
+        exit(EXIT_FAILURE);
+    }
+    
+    pkt->id = abs(rand());
+    pkt->type = type;
+    pkt->length = (uint32_t)(sizeof(pkt->id) + sizeof(pkt->type) + body_length + 2);
+    
+    memset(pkt->body, 0, MAX_BODY);
+    strncpy(pkt->body, body, MAX_BODY);
+}
+
+bool rcon_recv(int rcon_socket, packet* pkt, packet_type expected_type) {
+    memset(pkt, 0, sizeof(*pkt));
+    
+    // Read response packet length
+    ssize_t expected_length_bytes = sizeof(pkt->length);
+    ssize_t rx_bytes = recv(rcon_socket, &(pkt->length), expected_length_bytes, 0);
+    
+    if (rx_bytes == -1) {
+        perror("error: socket error");
+        return false;
+    } else if (rx_bytes == 0) {
+        fprintf(stderr, "error: no data recieved\n");
+        return false;
+    } else if (rx_bytes < expected_length_bytes || pkt->length < MIN_PACKET || pkt->length > MAX_PACKET) {
+        fprintf(stderr, "error: invalid data\n");
+        return false;
+    }
+    
+    ssize_t received = 0;
+    while (received < pkt->length) {
+        rx_bytes = recv(rcon_socket, (char *)pkt + sizeof(pkt->length) + received, pkt->length - received, 0);
+        if (rx_bytes < 0) {
+            perror("error: socket error");
+            return false;
+        } else if (rx_bytes == 0) {
+            fprintf(stderr, "error: connection lost\n");
+            return false;
+        }
+        
+        received += rx_bytes;
+    }
+    
+    return pkt->type == expected_type;
+}
+
+bool rcon_send(int rcon_socket, const packet* pkt) {
+    size_t length = sizeof(pkt->length) + pkt->length;
+    char *ptr = (char*) pkt;
+    
+    while (length > 0) {
+        ssize_t ret = send(rcon_socket, ptr, length, 0);
+        
+        if (ret == -1) {
+            return false;
+        }
+        
+        ptr += ret;
+        length -= ret;
+    }
+    
+    return true;
+}
+
+bool rcon_auth(int rcon_socket, const char* password) {
+    packet pkt;
+    rcon_create(&pkt, RCON_TYPE_AUTH, password);
+    
+    if (!rcon_send(rcon_socket, &pkt)) {
+        return false;
+    }
+    
+    if (!rcon_recv(rcon_socket, &pkt, RCON_TYPE_AUTH_RESPONSE)) {
+        return false;
+    }
+    
+    return true;
+}

From ca85d49187c6016528acdae5afb7431eea20742a Mon Sep 17 00:00:00 2001
From: Tobias Tangemann <tobias@tangemann.org>
Date: Sun, 10 Nov 2024 18:44:05 +0100
Subject: [PATCH 2/7] Explain rcon command

---
 README.md | 9 +++++++++
 1 file changed, 9 insertions(+)

diff --git a/README.md b/README.md
index 7387068..d65db66 100644
--- a/README.md
+++ b/README.md
@@ -96,6 +96,15 @@ docker run -d -it  \
 docker attach factorio
 ```
 
+### RCON
+
+Alternativly (e.g. for scripting) the RCON connection can be used to send commands to the running factorio server.
+This does not require the RCON connection to be exposed.
+
+```shell
+docker exec factorio rcon /h
+```
+
 ### Upgrading
 
 Before upgrading backup the save. It's easy to make a save in the client.

From 16f2df493455e75a44686646c15db8bb59940356 Mon Sep 17 00:00:00 2001
From: Tobias Tangemann <tobias@tangemann.org>
Date: Sun, 10 Nov 2024 19:00:31 +0100
Subject: [PATCH 3/7] Remove test container name

---
 docker/files/players-online.sh | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/docker/files/players-online.sh b/docker/files/players-online.sh
index 5a78001..aa2444d 100755
--- a/docker/files/players-online.sh
+++ b/docker/files/players-online.sh
@@ -1,6 +1,6 @@
 #!/bin/bash
 
-PLAYERS=$(docker exec xenodochial_spence rcon /players)
+PLAYERS=$(rcon /players)
 ONLINE_COUNT=$(echo "$PLAYERS" | grep -c " (online)$")
 
 if [[ "$ONLINE_COUNT" -gt "0" ]]; then

From e6e53c35a1df1085de666f6da3235759fd9808a9 Mon Sep 17 00:00:00 2001
From: Tobias <tobias@tangemann.org>
Date: Mon, 11 Nov 2024 13:25:45 +0100
Subject: [PATCH 4/7] Apply suggestions

Co-authored-by: Florian Kinder <florian.kinder@fankserver.com>
---
 docker/Dockerfile | 5 +++--
 1 file changed, 3 insertions(+), 2 deletions(-)

diff --git a/docker/Dockerfile b/docker/Dockerfile
index 4b1743a..d03f8bf 100755
--- a/docker/Dockerfile
+++ b/docker/Dockerfile
@@ -1,4 +1,5 @@
-FROM debian:stable-slim AS builder
+# build rcon client
+FROM debian:stable-slim AS rcon-builder
 RUN apt-get -q update \
     && DEBIAN_FRONTEND=noninteractive apt-get -qy install build-essential
 
@@ -89,7 +90,7 @@ RUN set -ox pipefail \
 
 COPY files/*.sh /
 COPY files/config.ini /opt/factorio/config/config.ini
-COPY --from=builder /src/rcon /bin/rcon
+COPY --from=rcon-builder /src/rcon /bin/rcon
 
 VOLUME /factorio
 EXPOSE $PORT/udp $RCON_PORT/tcp

From 8d972fdf696cd387b6f4010aecb31e9c0f5d4fb9 Mon Sep 17 00:00:00 2001
From: Tobias Tangemann <tobias@tangemann.org>
Date: Wed, 13 Nov 2024 22:47:04 +0100
Subject: [PATCH 5/7] Add example docker-compose file

---
 docker-compose.yml | 35 +++++++++++++++++++++++++++++++++++
 1 file changed, 35 insertions(+)
 create mode 100644 docker-compose.yml

diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..8d07bf6
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,35 @@
+version: "2"
+services:
+  factorio:
+    container_name: factorio
+    image: factoriotools/factorio:stable
+    restart: unless-stopped
+    ports:
+      - "34197:34197/udp"
+      - "27015:27015/tcp"
+    volumes:
+      - ./data:/factorio
+    environment:
+      - UPDATE_MODS_ON_START=true
+    #labels:
+    #  # Labels to allow autoupdate only if no players are online
+    #  - com.centurylinklabs.watchtower.enable=true
+    #  - com.centurylinklabs.watchtower.scope=factorio
+    #  - com.centurylinklabs.watchtower.lifecycle.pre-check="/players-online.sh"
+
+  # Uncomment the following files to use watchtower for updating the factorio container
+  # Full documentation of watchtower: https://github.com/containrrr/watchtower
+  #watchtower:
+  #  container_name: watchtower_factorio
+  #  image: containrrr/watchtower
+  #  restart: unless-stopped
+  #  volumes:
+  #   - /var/run/docker.sock:/var/run/docker.sock
+  #  environment:
+  #    # Only update containers which have the option 'watchtower.enable=true' set
+  #    - WATCHTOWER_TIMEOUT=30s
+  #    - WATCHTOWER_LABEL_ENABLE=true
+  #    - WATCHTOWER_POLL_INTERVAL=3600
+  #    - WATCHTOWER_LIFECYCLE_HOOKS=true
+  #  labels:
+  #    - com.centurylinklabs.watchtower.scope=factorio

From 725018f8b4327c411c69855139e1b01e4ca28be2 Mon Sep 17 00:00:00 2001
From: Tobias Tangemann <tobias@tangemann.org>
Date: Wed, 13 Nov 2024 22:47:55 +0100
Subject: [PATCH 6/7] Clarify return code

---
 docker/files/players-online.sh | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/docker/files/players-online.sh b/docker/files/players-online.sh
index aa2444d..5814b74 100755
--- a/docker/files/players-online.sh
+++ b/docker/files/players-online.sh
@@ -5,7 +5,7 @@ ONLINE_COUNT=$(echo "$PLAYERS" | grep -c " (online)$")
 
 if [[ "$ONLINE_COUNT" -gt "0" ]]; then
     echo "$PLAYERS"
-    # exit with 75 (EX_TEMPFAIL) for watchtower
+    # exit with 75 (EX_TEMPFAIL) so watchtower skips the update
     # https://containrrr.dev/watchtower/lifecycle-hooks/
     exit 75
 fi

From 54a03517ba33db85c5f85fb63d578ba2874463c1 Mon Sep 17 00:00:00 2001
From: Tobias Tangemann <tobias@tangemann.org>
Date: Wed, 13 Nov 2024 22:53:58 +0100
Subject: [PATCH 7/7] Switch to pre-update

---
 docker-compose.yml | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/docker-compose.yml b/docker-compose.yml
index 8d07bf6..a3e7e8f 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -15,7 +15,7 @@ services:
     #  # Labels to allow autoupdate only if no players are online
     #  - com.centurylinklabs.watchtower.enable=true
     #  - com.centurylinklabs.watchtower.scope=factorio
-    #  - com.centurylinklabs.watchtower.lifecycle.pre-check="/players-online.sh"
+    #  - com.centurylinklabs.watchtower.lifecycle.pre-update="/players-online.sh"
 
   # Uncomment the following files to use watchtower for updating the factorio container
   # Full documentation of watchtower: https://github.com/containrrr/watchtower