Merge "Improve web_proxy reliability"
diff --git a/WORKSPACE b/WORKSPACE
index 7d72ef1..5b3bea3 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -249,6 +249,13 @@
     path = "third_party/bazel-toolchain",
 )
 
+http_archive(
+    name = "RangeHTTPServer",
+    sha256 = "98a8e4980f91d048dc9159cfc5f115280d0b5ec59a5b01df0422b887212fa4f0",
+    strip_prefix = "RangeHTTPServer-9070394508a135789238a33259793f3c6f3c127a",
+    url = "https://github.com/jkuszmaul/RangeHTTPServer/archive/9070394508a135789238a33259793f3c6f3c127a.zip",
+)
+
 load("@com_grail_bazel_toolchain//toolchain:rules.bzl", "llvm", "llvm_toolchain")
 
 llvm_version = "17.0.2"
@@ -582,8 +589,8 @@
 http_archive(
     name = "pandoc",
     build_file = "@//debian:pandoc.BUILD",
-    sha256 = "9f7a7adb3974a1f14715054c349ff3edc2909e920dbe3438fca437a83845f3c4",
-    url = "https://software.frc971.org/Build-Dependencies/pandoc.tar.gz",
+    sha256 = "3c98503f29f2a7f771647b24a4b591bbe5539119b6b5a006ff09be7bec47bc0e",
+    url = "https://software.frc971.org/Build-Dependencies/pandoc-2023.12.14.tar.gz",
 )
 
 http_archive(
@@ -702,9 +709,9 @@
     deps = ["@//third_party/allwpilib/wpimath"],
 )
 """,
-    sha256 = "dd2de00f2ac6be5db36bf0d280c9548ac97deb872a078f9790fbfb7f0e2251ad",
+    sha256 = "f72e7d6e9756500d0e712b3f7cbbe4df639698bfe82623926a86137bec6de480",
     urls = [
-        "https://maven.ctr-electronics.com/release/com/ctre/phoenix6/api-cpp/24.0.0-beta-3/api-cpp-24.0.0-beta-3-headers.zip",
+        "https://maven.ctr-electronics.com/release/com/ctre/phoenix6/api-cpp/24.0.0-beta-4/api-cpp-24.0.0-beta-4-headers.zip",
     ],
 )
 
@@ -726,9 +733,9 @@
     target_compatible_with = ['@//tools/platforms/hardware:roborio'],
 )
 """,
-    sha256 = "1f9cc3479ccb2747292522120fe9fc1c2505595ca16f8f32d4a77704e126183b",
+    sha256 = "c562ed6edce68b9c0d50f2453c8b4054288d8d0c0d4521c6ef6c4cc2a9cf4bf4",
     urls = [
-        "https://maven.ctr-electronics.com/release/com/ctre/phoenix6/api-cpp/24.0.0-beta-3/api-cpp-24.0.0-beta-3-linuxathena.zip",
+        "https://maven.ctr-electronics.com/release/com/ctre/phoenix6/api-cpp/24.0.0-beta-4/api-cpp-24.0.0-beta-4-linuxathena.zip",
     ],
 )
 
@@ -741,9 +748,9 @@
     hdrs = glob(['ctre/**/*.h', 'ctre/**/*.hpp']),
 )
 """,
-    sha256 = "2b97f575210261566468c11ced919b86245927322bd8435f14582f2da18285ac",
+    sha256 = "bed99dd568082954ac692d30be851bc88430fef4e286ad27f2be1772a861ca24",
     urls = [
-        "https://maven.ctr-electronics.com/release/com/ctre/phoenix6/tools/24.0.0-beta-3/tools-24.0.0-beta-3-headers.zip",
+        "https://maven.ctr-electronics.com/release/com/ctre/phoenix6/tools/24.0.0-beta-4/tools-24.0.0-beta-4-headers.zip",
     ],
 )
 
@@ -765,9 +772,9 @@
     target_compatible_with = ['@//tools/platforms/hardware:roborio'],
 )
 """,
-    sha256 = "527ff6aa042b4e61c2d9511cc6908f9e85fb2418c20c8805ff62bffa0dc8336f",
+    sha256 = "4f9b387b69b6432044f12ef8da8baeffcd2c78b572307888eb2f7b4c42ca3a9f",
     urls = [
-        "https://maven.ctr-electronics.com/release/com/ctre/phoenix6/tools/24.0.0-beta-3/tools-24.0.0-beta-3-linuxathena.zip",
+        "https://maven.ctr-electronics.com/release/com/ctre/phoenix6/tools/24.0.0-beta-4/tools-24.0.0-beta-4-linuxathena.zip",
     ],
 )
 
@@ -780,9 +787,9 @@
     hdrs = glob(['ctre/phoenix/**/*.h']),
 )
 """,
-    sha256 = "d902ccd756b49e5aa152904f98fa9a31bc7508be8bf0ec7f978d16e33c760828",
+    sha256 = "af76d61d8ff3a8bc7ea6035ba3673d3cc2afef2e8538488f6c8948353e511a80",
     urls = [
-        "https://maven.ctr-electronics.com/release/com/ctre/phoenix/api-cpp/5.32.0-beta-1/api-cpp-5.32.0-beta-1-headers.zip",
+        "https://maven.ctr-electronics.com/release/com/ctre/phoenix/api-cpp/5.32.0-beta-2/api-cpp-5.32.0-beta-2-headers.zip",
     ],
 )
 
@@ -804,9 +811,9 @@
     target_compatible_with = ['@//tools/platforms/hardware:roborio'],
 )
 """,
-    sha256 = "882741ba5cf28881425393be33f3b7d1f564995a87c614c3f3b189ac2941c2dc",
+    sha256 = "9b73e7036098225ae342f60fe983c2d3335d4f14705a356dd985924869eba44b",
     urls = [
-        "https://maven.ctr-electronics.com/release/com/ctre/phoenix/api-cpp/5.32.0-beta-1/api-cpp-5.32.0-beta-1-linuxathena.zip",
+        "https://maven.ctr-electronics.com/release/com/ctre/phoenix/api-cpp/5.32.0-beta-2/api-cpp-5.32.0-beta-2-linuxathena.zip",
     ],
 )
 
@@ -819,9 +826,9 @@
     hdrs = glob(['ctre/phoenix/**/*.h']),
 )
 """,
-    sha256 = "e751e319ebcc337d8ab538027fb424cda03e1a80e10c94de07c980e8a0ec0bee",
+    sha256 = "46d56b58e8a5b5b33725e681f7d0e185bdf349af0c4b85fc2d7f08985388f476",
     urls = [
-        "https://maven.ctr-electronics.com/release/com/ctre/phoenix/cci/5.32.0-beta-1/cci-5.32.0-beta-1-headers.zip",
+        "https://maven.ctr-electronics.com/release/com/ctre/phoenix/cci/5.32.0-beta-2/cci-5.32.0-beta-2-headers.zip",
     ],
 )
 
@@ -843,9 +850,9 @@
     target_compatible_with = ['@//tools/platforms/hardware:roborio'],
 )
 """,
-    sha256 = "b9c23b25ebeec0acb4063424ee7685b9e1ddecd3b31c84c353342d22228e33b5",
+    sha256 = "bee3ebe458419451177d27d0f97cc4d6dc6efeb276205f7e2e16db9e2835904b",
     urls = [
-        "https://maven.ctr-electronics.com/release/com/ctre/phoenix/cci/5.32.0-beta-1/cci-5.32.0-beta-1-linuxathena.zip",
+        "https://maven.ctr-electronics.com/release/com/ctre/phoenix/cci/5.32.0-beta-2/cci-5.32.0-beta-2-linuxathena.zip",
     ],
 )
 
@@ -1540,9 +1547,9 @@
     srcs = glob(["**"]),
     visibility = ["//visibility:public"],
 )""",
-    sha256 = "629b150e4c71679e1ac30b8a2dfa558a04bbcca7ad0edd61bd6878d3b243edb6",
+    sha256 = "68513024efb60dcdfc9b130d3e0466d194a8599fbd85f8e99d01c148d03e5887",
     url =
-        "https://software.frc971.org/Build-Dependencies/foxglove-d6b00825.tar.gz",
+        "https://software.frc971.org/Build-Dependencies/foxglove-9857d637e90dfeecd63ad47fa760046791f8d43c.tar.gz",
 )
 
 #
diff --git a/aos/events/logging/logfile_sorting.cc b/aos/events/logging/logfile_sorting.cc
index b8530fb..f67a88a 100644
--- a/aos/events/logging/logfile_sorting.cc
+++ b/aos/events/logging/logfile_sorting.cc
@@ -2085,6 +2085,14 @@
   return sorter.SortParts();
 }
 
+std::set<std::string> LoggerNodes(const std::vector<LogFile> &log_files) {
+  std::set<std::string> nodes;
+  for (const aos::logger::LogFile &log : log_files) {
+    nodes.insert(log.logger_node);
+  }
+  return nodes;
+}
+
 std::ostream &operator<<(std::ostream &stream, const LogFile &file) {
   stream << "{\n";
   if (!file.log_event_uuid.empty()) {
diff --git a/aos/events/logging/logfile_sorting.h b/aos/events/logging/logfile_sorting.h
index e1aa620..825a210 100644
--- a/aos/events/logging/logfile_sorting.h
+++ b/aos/events/logging/logfile_sorting.h
@@ -150,6 +150,10 @@
 // Sort parts of a single log.
 std::vector<LogFile> SortParts(const LogSource &log_source);
 
+// Returns a list of all the logger nodes for the specified set of log files.
+// For single-node systems, the empty string will represent the logfile set.
+std::set<std::string> LoggerNodes(const std::vector<LogFile> &log_files);
+
 // Validates that collection of log files or log parts shares the same configs.
 bool HasMatchingConfigs(const std::vector<LogFile> &items);
 
diff --git a/aos/util/log_to_mcap.cc b/aos/util/log_to_mcap.cc
index 0d51e52..d0a2415 100644
--- a/aos/util/log_to_mcap.cc
+++ b/aos/util/log_to_mcap.cc
@@ -37,19 +37,19 @@
   const std::vector<aos::logger::LogFile> logfiles =
       aos::logger::SortParts(aos::logger::FindLogs(argc, argv));
   CHECK(!logfiles.empty());
-  const std::string logger_node = logfiles.at(0).logger_node;
-  bool all_logs_from_same_node = true;
-  for (const aos::logger::LogFile &log : logfiles) {
-    if (log.logger_node != logger_node) {
-      all_logs_from_same_node = false;
-      break;
-    }
-  }
+  const std::set<std::string> logger_nodes = aos::logger::LoggerNodes(logfiles);
+  CHECK_LT(0u, logger_nodes.size());
+  const std::string logger_node = *logger_nodes.begin();
   std::string replay_node = FLAGS_node;
-  if (replay_node.empty() && all_logs_from_same_node) {
-    LOG(INFO) << "Guessing \"" << logger_node
-              << "\" as node given that --node was not specified.";
-    replay_node = logger_node;
+  if (replay_node.empty()) {
+    if (logger_nodes.size() == 1u) {
+      LOG(INFO) << "Guessing \"" << logger_node
+                << "\" as node given that --node was not specified.";
+      replay_node = logger_node;
+    } else {
+      LOG(ERROR) << "Must supply a --node for log_to_mcap.";
+      return 1;
+    }
   }
 
   std::optional<aos::FlatbufferDetachedBuffer<aos::Configuration>> config;
diff --git a/debian/download_packages.py b/debian/download_packages.py
index 2d284d7..82f01ce 100755
--- a/debian/download_packages.py
+++ b/debian/download_packages.py
@@ -147,7 +147,7 @@
     contents.sort()
     print("_files = {")
     for deb in contents:
-        print('  "%s": "%s",' % (deb, sha256_checksum(deb)))
+        print('    "%s": "%s",' % (deb, sha256_checksum(deb)))
     print("}")
 
 
diff --git a/debian/packages.bzl b/debian/packages.bzl
index babc0c0..d39f613 100644
--- a/debian/packages.bzl
+++ b/debian/packages.bzl
@@ -1,8 +1,9 @@
 load("@rules_pkg//:pkg.bzl", "pkg_tar")
 load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_file")
 
-# In order to use deb packages in the build you have to follow these steps:
+# In order to use deb packages in the build you have to follow these steps.
 #
+# Adding new packages:
 # 1. Create a "download_packages" build step in //debian/BUILD. List the
 #    packages you care about and exclude the ones you don't care about.
 #    Invoke "bazel run" on the "download_packages" target you just created.
@@ -23,6 +24,13 @@
 #    and upload the resulting tarball to https://software.frc971.org/Build-Dependencies.
 # 6. Add a new "new_http_archive" entry to the WORKSPACE file for the tarball
 #    you just uploaded.
+#
+# Updating existing packages:
+# 1. Read above instructions.
+# 2. The "download_packages" build step already exists, run it.
+# 3. The .bzl file with the file list already exists.  Update it with the
+#    output from the previous step.
+# 4. Follow steps 2., 5., and 6. from "adding new packages".
 
 def download_packages(name, packages, excludes = [], force_includes = [], force_excludes = [], target_compatible_with = None, release = "bullseye"):
     """Downloads a set of packages as well as their dependencies.
diff --git a/debian/pandoc.bzl b/debian/pandoc.bzl
index f203db0..70982b0 100644
--- a/debian/pandoc.bzl
+++ b/debian/pandoc.bzl
@@ -1,10 +1,9 @@
 files = {
-    "libffi6_3.1-2+deb8u1_amd64.deb": "100343fca79ff265abc62467c7085fca68b8764e8c2551302ab741c771e7f0aa",
-    "libgmp10_6.0.0+dfsg-6_amd64.deb": "155a31b0f716aa3dcd7ee68e9bd57e0b76a6b31f4e41fb2d953e986315437082",
-    "libicu52_52.1-8+deb8u7_amd64.deb": "708d4499e2f6344a77d903c2c03a958f5edac32f9adf5ff3da7b572bf307e980",
-    "liblua5.1-0_5.1.5-7.1_amd64.deb": "91dfecba874f9330c8a67f17060594ed148c34c44532b60cdc10a480060e38a9",
-    "libstdc++6_4.9.2-10+deb8u1_amd64.deb": "a8f4ef6773b90bb39a8a8a0a5e3e20ca8501de6896204f665eb114d5b79f164f",
-    "libyaml-0-2_0.1.6-3_amd64.deb": "5885db15ac425eb7231c436903525b78381e034bcc53928a97997a745295d222",
-    "pandoc-data_1.12.4.2~dfsg-1_all.deb": "52c4cebd39582e3c2636febd40806a5f1ec71c7be9653addcabbe2732f52a730",
-    "pandoc_1.12.4.2~dfsg-1+b14_amd64.deb": "e2e2a33d398d92ead214391c93afdbd3f590f6ab50d9a88492e2421826484d1c",
+    "libcmark-gfm-extensions0_0.29.0.gfm.0-6_amd64.deb": "0ad7f7217a7c8b62f17974875c9f1f4fdab4279312258c8979d4fca799ca88d6",
+    "libcmark-gfm0_0.29.0.gfm.0-6_amd64.deb": "37ab49845b79e8cd077f4d171c4a3ed49efc0a36cfc85e36dda57b055a676ed9",
+    "libffi7_3.3-6_amd64.deb": "30ca89bfddae5fa6e0a2a044f22b6e50cd17c4bc6bc850c579819aeab7101f0f",
+    "libgmp10_6.2.1+dfsg-1+deb11u1_amd64.deb": "fc117ccb084a98d25021f7e01e4dfedd414fa2118fdd1e27d2d801d7248aebbc",
+    "libpcre3_8.39-13_amd64.deb": "48efcf2348967c211cd9408539edf7ec3fa9d800b33041f6511ccaecc1ffa9d0",
+    "pandoc-data_2.9.2.1-1+deb11u1_all.deb": "cc00e1c933a246017cf79667faf4de35787d3de7cf95277deaff40fb53650fd3",
+    "pandoc_2.9.2.1-1+deb11u1_amd64.deb": "af69ad43f17be275921fb05d1ae33b4bb59ba66818845175c140abcc441d1d7c",
 }
diff --git a/frc971/analysis/BUILD b/frc971/analysis/BUILD
index 442a7de..9b9ab86 100644
--- a/frc971/analysis/BUILD
+++ b/frc971/analysis/BUILD
@@ -177,6 +177,21 @@
 )
 
 cc_binary(
+    name = "trim_log_to_enabled",
+    srcs = [
+        "trim_log_to_enabled.cc",
+    ],
+    target_compatible_with = ["@platforms//os:linux"],
+    deps = [
+        "//aos:init",
+        "//aos/events:simulated_event_loop",
+        "//aos/events/logging:log_reader",
+        "//aos/util:simulation_logger",
+        "//frc971/input:joystick_state_fbs",
+    ],
+)
+
+cc_binary(
     name = "log_to_match",
     srcs = [
         "log_to_match.cc",
@@ -201,3 +216,14 @@
         "//third_party/seasocks",
     ],
 )
+
+py_binary(
+    name = "trim_and_plot_foxglove",
+    srcs = ["trim_and_plot_foxglove.py"],
+    data = [
+        ":trim_log_to_enabled",
+        "//aos/util:log_to_mcap",
+        "@foxglove_studio",
+    ],
+    deps = ["@RangeHTTPServer"],
+)
diff --git a/frc971/analysis/trim_and_plot_foxglove.py b/frc971/analysis/trim_and_plot_foxglove.py
new file mode 100644
index 0000000..6812d60
--- /dev/null
+++ b/frc971/analysis/trim_and_plot_foxglove.py
@@ -0,0 +1,51 @@
+import http.server as SimpleHTTPServer
+from RangeHTTPServer import RangeRequestHandler
+import argparse
+import os
+import shutil
+import subprocess
+from tempfile import mkdtemp
+
+parser = argparse.ArgumentParser(
+    description="""Trims & generates MCAP file from log.
+
+Serves foxglove locally, and prints out a URL by which the log can be accessed.
+
+By default, will trim a log to the time period during which the robot was
+enabled. Skips this stip if --skip_trim is passed.""")
+parser.add_argument('--port',
+                    action='store',
+                    default=8000,
+                    type=int,
+                    help='Specify port on which to serve foxglove.')
+parser.add_argument('--skip_trim',
+                    action='store_true',
+                    default=False,
+                    help='If set, do not trim the logfile..')
+parser.add_argument('log',
+                    action='store',
+                    default=None,
+                    type=str,
+                    nargs='+',
+                    help='Log(s) to plot.')
+args = parser.parse_args()
+
+tmpdir = mkdtemp(prefix="foxglove_")
+shutil.copytree("external/foxglove_studio", tmpdir, dirs_exist_ok=True)
+
+trimmed_aos_log = args.log if args.skip_trim else [tmpdir + "/trimmed/"]
+output_mcap = tmpdir + "/log.mcap"
+
+if not args.skip_trim:
+    subprocess.run(["frc971/analysis/trim_log_to_enabled", "--output_folder"] +
+                   trimmed_aos_log + args.log).check_returncode()
+subprocess.run(["aos/util/log_to_mcap", "--output_path", output_mcap] +
+               trimmed_aos_log).check_returncode()
+
+mcap_url = f"http://localhost:{args.port}/log.mcap"
+url_parameters = f"?ds=remote-file&ds.url={mcap_url}"
+print(f"Serving files from {tmpdir}")
+print(f"Local URL: http://localhost:{args.port}/{url_parameters}")
+print(f"Live Website URL: https://studio.foxglove.dev/{url_parameters}")
+os.chdir(tmpdir)
+SimpleHTTPServer.test(HandlerClass=RangeRequestHandler, port=args.port)
diff --git a/frc971/analysis/trim_log_to_enabled.cc b/frc971/analysis/trim_log_to_enabled.cc
new file mode 100644
index 0000000..0853ea5
--- /dev/null
+++ b/frc971/analysis/trim_log_to_enabled.cc
@@ -0,0 +1,102 @@
+#include <optional>
+
+#include "gflags/gflags.h"
+
+#include "aos/events/logging/log_reader.h"
+#include "aos/init.h"
+#include "aos/util/simulation_logger.h"
+#include "frc971/input/joystick_state_generated.h"
+
+DEFINE_string(output_folder, "/tmp/trimmed/",
+              "Name of the folder to write the trimmed log to.");
+DEFINE_double(pre_enable_time_sec, 10.0,
+              "Amount of time to leave in the new log before the first enable "
+              "signal happens.");
+DEFINE_double(post_enable_time_sec, 1.0,
+              "Amount of time to leave in the new log after the final enable "
+              "signal ends.");
+
+int main(int argc, char *argv[]) {
+  gflags::SetUsageMessage(
+      "Trims the sections at the start/end of a log where the robot is "
+      "disabled.");
+  aos::InitGoogle(&argc, &argv);
+  const std::vector<aos::logger::LogFile> logfiles =
+      aos::logger::SortParts(aos::logger::FindLogs(argc, argv));
+  std::optional<aos::monotonic_clock::time_point> start_time;
+  std::optional<aos::monotonic_clock::time_point> end_time;
+  bool printed_match = false;
+  // We need to do two passes through the logfile; one to figure out when the
+  // start/end times are, one to actually do the trimming.
+  {
+    aos::logger::LogReader reader(logfiles);
+    const aos::Node *roborio =
+        aos::configuration::GetNode(reader.configuration(), "roborio");
+    reader.Register();
+    std::unique_ptr<aos::EventLoop> event_loop =
+        reader.event_loop_factory()->MakeEventLoop("roborio", roborio);
+    event_loop->MakeWatcher(
+        "/aos", [&start_time, &end_time, &printed_match,
+                 &event_loop](const aos::JoystickState &msg) {
+          if (!printed_match && msg.match_type() != aos::MatchType::kNone) {
+            LOG(INFO) << "Match Type: "
+                      << aos::EnumNameMatchType(msg.match_type());
+            LOG(INFO) << "Match #: " << msg.match_number();
+            printed_match = true;
+          }
+
+          if (msg.enabled()) {
+            // Note that time is monotonic, so we don't need to e.g. do min's or
+            // max's on the start/end time.
+            if (!start_time.has_value()) {
+              start_time = event_loop->context().monotonic_event_time;
+            }
+            end_time = event_loop->context().monotonic_event_time;
+          }
+        });
+
+    reader.event_loop_factory()->Run();
+
+    if (!printed_match) {
+      LOG(INFO) << "No match info.";
+    }
+  }
+  if (!start_time.has_value()) {
+    LOG(WARNING) << "Log does not ontain any JoystickState messages.";
+    return 1;
+  }
+  LOG(INFO) << "First enable at " << start_time.value();
+  LOG(INFO) << "Final enable at " << end_time.value();
+  start_time.value() -= std::chrono::duration_cast<std::chrono::nanoseconds>(
+      std::chrono::duration<double>(FLAGS_pre_enable_time_sec));
+  end_time.value() += std::chrono::duration_cast<std::chrono::nanoseconds>(
+      std::chrono::duration<double>(FLAGS_post_enable_time_sec));
+
+  {
+    aos::logger::LogReader reader(logfiles);
+    const aos::Node *roborio =
+        aos::configuration::GetNode(reader.configuration(), "roborio");
+    reader.Register();
+    std::unique_ptr<aos::EventLoop> event_loop =
+        reader.event_loop_factory()->MakeEventLoop("roborio", roborio);
+    auto exit_timer = event_loop->AddTimer(
+        [&reader]() { reader.event_loop_factory()->Exit(); });
+    exit_timer->Schedule(start_time.value());
+    reader.event_loop_factory()->Run();
+    const std::set<std::string> logger_nodes =
+        aos::logger::LoggerNodes(logfiles);
+    // Only start up loggers that generated the original set of logfiles.
+    // This mostly exists to make it so that utilities like log_to_mcap can
+    // easily auto-detect which node to replay as when consuming the input logs.
+    auto loggers = aos::util::MakeLoggersForNodes(
+        reader.event_loop_factory(), {logger_nodes.begin(), logger_nodes.end()},
+        FLAGS_output_folder);
+    exit_timer->Schedule(end_time.value());
+
+    reader.event_loop_factory()->Run();
+  }
+
+  LOG(INFO) << "Trimmed logs written to " << FLAGS_output_folder;
+
+  return EXIT_SUCCESS;
+}
diff --git a/tools/ssh_config/local_config_template.txt b/tools/ssh_config/local_config_template.txt
new file mode 100644
index 0000000..722c07d0
--- /dev/null
+++ b/tools/ssh_config/local_config_template.txt
@@ -0,0 +1,145 @@
+# Host for connecting to the build server
+Host frc971
+    HostName build.frc971.org
+    User {{username}}
+    Port 2222
+    IdentityFile ~/.ssh/{{identity_file}}
+    LocalForward 9971 127.0.0.1:3389
+
+# Hosts for connecting to specific RoboRIOs
+Host roborio
+   HostName 10.9.71.2
+   User admin
+
+Host practice-roborio
+   HostName 10.99.71.2
+   User admin
+
+Host third-roborio
+   HostName 10.89.71.2
+   User admin
+
+# Hosts for connecting to Raspberry Pis
+Host pi1
+   HostName 10.9.71.101
+   User pi
+
+Host pi2
+   HostName 10.9.71.102
+   User pi
+
+Host pi3
+   HostName 10.9.71.103
+   User pi
+
+Host pi4
+   HostName 10.9.71.104
+   User pi
+
+Host pi5
+   HostName 10.9.71.105
+   User pi
+
+Host pi6
+   HostName 10.9.71.106
+   User pi
+
+# Hosts for connecting to practice Raspberry Pis
+Host practice-pi1
+   HostName 10.99.71.101
+   User pi
+
+Host practice-pi2
+   HostName 10.99.71.102
+   User pi
+
+Host practice-pi3
+   HostName 10.99.71.103
+   User pi
+
+Host practice-pi4
+   HostName 10.99.71.104
+   User pi
+
+Host practice-pi5
+   HostName 10.99.71.105
+   User pi
+
+Host practice-pi6
+   HostName 10.99.71.106
+   User pi
+
+# Hosts for connecting to Box of Pi's
+Host box-pi1
+   HostName 10.79.71.101
+   User pi
+
+Host box-pi2
+   HostName 10.79.71.102
+   User pi
+
+Host box-pi3
+   HostName 10.79.71.103
+   User pi
+
+Host box-pi4
+   HostName 10.79.71.104
+   User pi
+
+Host box-pi5
+   HostName 10.79.71.105
+   User pi
+
+Host box-pi6
+   HostName 10.79.71.106
+   User pi
+
+Host frc971-roborio
+   HostName build.frc971.org
+   Port 2222
+   User {{username}}
+   IdentityFile ~/.ssh/{{identity_file}}
+   ForwardAgent yes
+   RemoteForward 10999 10.9.71.2:22
+   RemoteForward 10998 10.9.71.101:22
+   RemoteForward 10997 10.9.71.102:22
+   RemoteForward 10996 10.9.71.103:22
+   RemoteForward 10995 10.9.71.104:22
+   RemoteForward 10994 10.9.71.105:22
+   RemoteForward 10993 10.9.71.106:22
+
+Host frc971-practice-roborio
+   HostName build.frc971.org
+   Port 2222
+   User {{username}}
+   IdentityFile ~/.ssh/{{identity_file}}
+   ForwardAgent yes
+   RemoteForward 10989 10.99.71.2:22
+   RemoteForward 10988 10.99.71.101:22
+   RemoteForward 10987 10.99.71.102:22
+   RemoteForward 10986 10.99.71.103:22
+   RemoteForward 10985 10.99.71.104:22
+   RemoteForward 10984 10.99.71.105:22
+   RemoteForward 10983 10.99.71.106:22
+
+Host frc971-third-roborio
+   HostName build.frc971.org
+   Port 2222
+   IdentityFile ~/.ssh/{{identity_file}}
+   User {{username}}
+   ForwardAgent yes
+   RemoteForward 10979 10.89.71.2:22
+
+Host frc971-box-roborio
+   HostName build.frc971.org
+   Port 2222
+   User {{username}}
+   IdentityFile ~/.ssh/{{identity_file}}
+   ForwardAgent yes
+   RemoteForward 10979 10.79.71.2:22
+   RemoteForward 10978 10.79.71.101:22
+   RemoteForward 10977 10.79.71.102:22
+   RemoteForward 10976 10.79.71.103:22
+   RemoteForward 10975 10.79.71.104:22
+   RemoteForward 10974 10.79.71.105:22
+   RemoteForward 10973 10.79.71.106:22
diff --git a/tools/ssh_config/remote_config_template.txt b/tools/ssh_config/remote_config_template.txt
new file mode 100644
index 0000000..3bbd9f2
--- /dev/null
+++ b/tools/ssh_config/remote_config_template.txt
@@ -0,0 +1,111 @@
+host roborio
+  User admin
+  HostName localhost
+  Port 10999
+  StrictHostKeyChecking no
+
+host practice-roborio
+  User admin
+  HostName localhost
+  Port 10989
+  StrictHostKeyChecking no
+
+host third-roborio
+  User admin
+  HostName localhost
+  Port 10979
+  StrictHostKeyChecking no
+
+host pi1
+  User pi
+  HostName localhost
+  Port 10998
+  IdentityFile ~/.ssh/{{identity_file}}
+host pi2
+  User pi
+  HostName localhost
+  Port 10997
+  IdentityFile ~/.ssh/{{identity_file}}
+host pi3
+  User pi
+  HostName localhost
+  Port 10996
+  IdentityFile ~/.ssh/{{identity_file}}
+host pi4
+  User pi
+  HostName localhost
+  Port 10995
+  IdentityFile ~/.ssh/{{identity_file}}
+host pi5
+  User pi
+  HostName localhost
+  Port 10994
+  IdentityFile ~/.ssh/{{identity_file}}
+host pi6
+  User pi
+  HostName localhost
+  Port 10993
+  IdentityFile ~/.ssh/{{identity_file}}
+
+host practice-pi1
+  User pi
+  HostName localhost
+  Port 10988
+  IdentityFile ~/.ssh/{{identity_file}}
+host practice-pi2
+  User pi
+  HostName localhost
+  Port 10987
+  IdentityFile ~/.ssh/{{identity_file}}
+host practice-pi3
+  User pi
+  HostName localhost
+  Port 10986
+  IdentityFile ~/.ssh/{{identity_file}}
+host practice-pi4
+  User pi
+  HostName localhost
+  Port 10985
+  IdentityFile ~/.ssh/{{identity_file}}
+host practice-pi6
+  User pi
+  HostName localhost
+  Port 10984
+  IdentityFile ~/.ssh/{{identity_file}}
+host practice-pi5
+  User pi
+  HostName localhost
+  Port 10983
+  IdentityFile ~/.ssh/{{identity_file}}
+
+host box-pi1
+  User pi
+  HostName localhost
+  Port 10978
+  IdentityFile ~/.ssh/{{identity_file}}
+host box-pi2
+  User pi
+  HostName localhost
+  Port 10977
+  IdentityFile ~/.ssh/{{identity_file}}
+host box-pi3
+  User pi
+  HostName localhost
+  Port 10976
+  IdentityFile ~/.ssh/{{identity_file}}
+host box-pi4
+  User pi
+  HostName localhost
+  Port 10975
+  IdentityFile ~/.ssh/{{identity_file}}
+host box-pi6
+  User pi
+  HostName localhost
+  Port 10974
+  IdentityFile ~/.ssh/{{identity_file}}
+host box-pi5
+  User pi
+  HostName localhost
+  Port 10973
+  IdentityFile ~/.ssh/{{identity_file}}
+  
\ No newline at end of file
diff --git a/tools/ssh_config/setup_ssh_configs.sh b/tools/ssh_config/setup_ssh_configs.sh
new file mode 100644
index 0000000..2c6f089
--- /dev/null
+++ b/tools/ssh_config/setup_ssh_configs.sh
@@ -0,0 +1,50 @@
+#!/bin/bash
+
+# This script sets up SSH configuration for tunnel forwarding so you can
+# build and deploy code without having the code locally.
+# It creates or appends to the local SSH config file (~/.ssh/config)
+# and connects to the build server to create or append the remote SSH config.
+# It prompts the user for their SSH username, the name of the local identity file,
+# and the name of the remote identity file.
+
+# Instructions:
+# 1. Run this script locally on your machine.
+# 2. Make the script executable: chmod +x setup_ssh_configs.sh
+# 3. Run the script: ./setup_ssh_configs.sh
+
+# Note: Ensure that the local and remote SSH config template files are 
+# in the same directory as this script.
+
+# TODO: Use jinja for better templating
+# TODO: Use python to also support windows devices.
+
+# Get the script's directory
+script_dir=$(dirname "$(realpath "$0")")
+
+# Prompt for user input
+read -p "Enter your username for SSH connection: " username
+read -p "Enter the name of the local identity file (e.g., id_971_ed25519): " local_identity_file
+read -p "Enter the name of the remote identity file (e.g., id_971_ed25519): " remote_identity_file
+
+local_ssh_config_template="$script_dir/local_config_template.txt"
+remote_ssh_config_template="$script_dir/remote_config_template.txt"
+
+
+local_ssh_config_content=$(cat "$local_ssh_config_template" | sed -e "s/{{username}}/$username/g" -e "s/{{identity_file}}/$local_identity_file/g")
+local_ssh_config="$HOME/.ssh/config"
+
+# Check if the local SSH config file already exists
+if [ -f "$local_ssh_config" ]; then
+    # Append
+    echo "$local_ssh_config_content" >> "$local_ssh_config"
+else
+    echo "$local_ssh_config_content" > "$local_ssh_config"
+    chmod 600 "$local_ssh_config"
+fi
+
+remote_ssh_config_content=$(cat "$remote_ssh_config_template" | sed -e "s/{{username}}/$username/g" -e "s/{{identity_file}}/$remote_identity_file/g")
+remote_ssh_config="~/.ssh/config"
+
+ssh "frc971" "echo '$remote_ssh_config_content' >> $remote_ssh_config"
+
+echo "SSH configuration for tunnel forwarding has been set up locally and on the build server."