Repository: org-arl/fjage (language: python)

Issue:
Add support for auth & firewall in master container
- [x] Support `auth` field in `alive` JSON message
- [x] Provide plug-in interface to check auth info, with a default implementation based on flat files
- [x] Map credentials to a set of firewall rules, and apply them to incoming/outgoing messages
- [x] Provide plug-in interface to defining firewall rules, with a default implementation based on flat files


Discussion / hints:

Fix patch:
diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml
index a0e47ba2..1510e0ef 100644
--- a/.github/workflows/gradle.yml
+++ b/.github/workflows/gradle.yml
@@ -32,4 +32,4 @@ jobs:
     - name: Setup npm CI
       run: npm ci
     - name: Build with Gradle
-      run: ./gradlew -d test
+      run: ./gradlew test
diff --git a/src/main/java/org/arl/fjage/auth/AbstractFirewall.java b/src/main/java/org/arl/fjage/auth/AbstractFirewall.java
new file mode 100644
index 00000000..68d24c40
--- /dev/null
+++ b/src/main/java/org/arl/fjage/auth/AbstractFirewall.java
@@ -0,0 +1,37 @@
+/******************************************************************************
+
+Copyright (c) 2021, Mandar Chitre
+
+This file is part of fjage which is released under Simplified BSD License.
+See file LICENSE.txt or go to http://www.opensource.org/licenses/BSD-3-Clause
+for full license details.
+
+******************************************************************************/
+
+package org.arl.fjage.auth;
+
+import java.util.logging.Logger;
+import org.arl.fjage.AgentID;
+import org.arl.fjage.remote.JsonMessage;
+
+/**
+ * An abstract firewall class that allows all traffic to pass through once
+ * authenticated. Authentication is implemented by a class that extends this
+ * class by implemeting the {@link Firewall#authenticate()} method.
+ */
+public abstract class AbstractFirewall implements Firewall {
+
+  protected Logger log = Logger.getLogger(getClass().getName());
+  protected boolean auth = false;
+
+  @Override
+  public boolean permit(JsonMessage rq) {
+    return auth;
+  }
+
+  @Override
+  public boolean permit(AgentID aid) {
+    return auth;
+  }
+
+}
diff --git a/src/main/java/org/arl/fjage/auth/AllowAfterAuth.java b/src/main/java/org/arl/fjage/auth/AllowAfterAuth.java
new file mode 100644
index 00000000..6304aeff
--- /dev/null
+++ b/src/main/java/org/arl/fjage/auth/AllowAfterAuth.java
@@ -0,0 +1,33 @@
+/******************************************************************************
+
+Copyright (c) 2021, Mandar Chitre
+
+This file is part of fjage which is released under Simplified BSD License.
+See file LICENSE.txt or go to http://www.opensource.org/licenses/BSD-3-Clause
+for full license details.
+
+******************************************************************************/
+
+package org.arl.fjage.auth;
+
+import org.arl.fjage.connectors.Connector;
+
+/**
+ * A permissive firewall that allows all traffic to pass through, but requires
+ * an authentication message to be first sent with any credentials.
+ * <p>
+ * This is primarily useful for testing.
+ */
+public class AllowAfterAuth extends AbstractFirewall {
+
+  @Override
+  public boolean authenticate(Connector conn, String creds) {
+    if (creds == null) auth = false;
+    else {
+      log.fine("Authentication successful ["+creds+"]");
+      auth = true;
+    }
+    return auth;
+  }
+
+}
diff --git a/src/main/java/org/arl/fjage/auth/AllowAll.java b/src/main/java/org/arl/fjage/auth/AllowAll.java
new file mode 100644
index 00000000..1453e005
--- /dev/null
+++ b/src/main/java/org/arl/fjage/auth/AllowAll.java
@@ -0,0 +1,27 @@
+/******************************************************************************
+
+Copyright (c) 2021, Mandar Chitre
+
+This file is part of fjage which is released under Simplified BSD License.
+See file LICENSE.txt or go to http://www.opensource.org/licenses/BSD-3-Clause
+for full license details.
+
+******************************************************************************/
+
+package org.arl.fjage.auth;
+
+import org.arl.fjage.connectors.Connector;
+
+/**
+ * A permissive firewall that allows all traffic to pass through.
+ */
+public class AllowAll extends AbstractFirewall {
+
+  @Override
+  public boolean authenticate(Connector conn, String creds) {
+    if (creds != null) log.fine("Authentication successful [AllowAll]");
+    auth = true;
+    return auth;
+  }
+
+}
diff --git a/src/main/java/org/arl/fjage/auth/DenyAll.java b/src/main/java/org/arl/fjage/auth/DenyAll.java
new file mode 100644
index 00000000..3cb74216
--- /dev/null
+++ b/src/main/java/org/arl/fjage/auth/DenyAll.java
@@ -0,0 +1,27 @@
+/******************************************************************************
+
+Copyright (c) 2021, Mandar Chitre
+
+This file is part of fjage which is released under Simplified BSD License.
+See file LICENSE.txt or go to http://www.opensource.org/licenses/BSD-3-Clause
+for full license details.
+
+******************************************************************************/
+
+package org.arl.fjage.auth;
+
+import org.arl.fjage.connectors.Connector;
+
+/**
+ * A reluctant firewall that denies all traffic to pass through.
+ */
+public class DenyAll extends AbstractFirewall {
+
+  @Override
+  public boolean authenticate(Connector conn, String creds) {
+    if (creds != null) log.fine("Authentication unsuccessful [DenyAll]");
+    auth = false;
+    return auth;
+  }
+
+}
diff --git a/src/main/java/org/arl/fjage/auth/Firewall.java b/src/main/java/org/arl/fjage/auth/Firewall.java
new file mode 100644
index 00000000..f77e4b1e
--- /dev/null
+++ b/src/main/java/org/arl/fjage/auth/Firewall.java
@@ -0,0 +1,52 @@
+/******************************************************************************
+
+Copyright (c) 2021, Mandar Chitre
+
+This file is part of fjage which is released under Simplified BSD License.
+See file LICENSE.txt or go to http://www.opensource.org/licenses/BSD-3-Clause
+for full license details.
+
+******************************************************************************/
+
+package org.arl.fjage.auth;
+
+import org.arl.fjage.AgentID;
+import org.arl.fjage.connectors.Connector;
+import org.arl.fjage.remote.JsonMessage;
+
+/**
+ * Firewall interface for API access to fjage master containers.
+ */
+public interface Firewall {
+
+  /**
+   * Authenticates peer using specified credentials.
+   * <p>
+   * When a firewall is first bound to a connection handler, this method is
+   * called with null credentials to bind a connector to the firewall. The
+   * method is also called with null credentials when a connection is closed.
+   *
+   * @param conn connection to peer.
+   * @param creds credentials, or null if logging out.
+   * @return true if authenticated successfully, false otherwise.
+   */
+  public boolean authenticate(Connector conn, String creds);
+
+  /**
+   * Checks whether a JSON message can be accepted over this connection.
+   *
+   * @param rq incoming JSON request.
+   * @return true to accept, false to reject.
+   */
+  public boolean permit(JsonMessage rq);
+
+  /**
+   * Checks whether a message intended for the specified agent/topic may be sent
+   * over this connection.
+   *
+   * @param aid recipient agent/topic for the message.
+   * @return true to permit, false to reject.
+   */
+  public boolean permit(AgentID aid);
+
+}
diff --git a/src/main/java/org/arl/fjage/remote/Action.java b/src/main/java/org/arl/fjage/remote/Action.java
index 2b8c609b..8ca4dadf 100644
--- a/src/main/java/org/arl/fjage/remote/Action.java
+++ b/src/main/java/org/arl/fjage/remote/Action.java
@@ -16,6 +16,7 @@
  * JSON message actions.
  */
 enum Action {
+  @SerializedName("auth")             AUTH,
   @SerializedName("agents")           AGENTS,
   @SerializedName("containsAgent")    CONTAINS_AGENT,
   @SerializedName("services")         SERVICES,
diff --git a/src/main/java/org/arl/fjage/remote/ConnectionHandler.java b/src/main/java/org/arl/fjage/remote/ConnectionHandler.java
index 0db44fcc..c7ec1917 100644
--- a/src/main/java/org/arl/fjage/remote/ConnectionHandler.java
+++ b/src/main/java/org/arl/fjage/remote/ConnectionHandler.java
@@ -17,6 +17,7 @@
 import java.util.logging.Logger;
 import org.arl.fjage.AgentID;
 import org.arl.fjage.connectors.*;
+import org.arl.fjage.auth.*;
 
 /**
  * Handles a JSON/TCP connection with remote container.
@@ -35,10 +36,22 @@ class ConnectionHandler extends Thread {
   private boolean alive, keepAlive, closeOnDead;
   private ExecutorService pool = Executors.newSingleThreadExecutor();
   private Set<AgentID> watchList = new HashSet<>();
+  private Firewall fw;
 
   public ConnectionHandler(Connector conn, RemoteContainer container) {
     this.conn = conn;
     this.container = container;
+    this.fw = new AllowAll();
+    setName(conn.toString());
+    alive = false;
+    keepAlive = true;
+    closeOnDead = (conn instanceof TcpConnector) && (container instanceof MasterContainer);
+  }
+
+  public ConnectionHandler(Connector conn, RemoteContainer container, Firewall fw) {
+    this.conn = conn;
+    this.container = container;
+    this.fw = fw;
     setName(conn.toString());
     alive = false;
     keepAlive = true;
@@ -70,6 +83,7 @@ public void run() {
         println(ALIVE);
       }
     }
+    fw.authenticate(conn, null);
     while (conn != null) {
       String s = null;
       try {
@@ -98,7 +112,7 @@ public void run() {
       try {
         JsonMessage rq = JsonMessage.fromJson(s);
         if (rq.action == null) {
-          if (rq.id != null) {
+          if (fw.permit(rq) && rq.id != null) {
             // response to some request
             Object obj = pending.get(rq.id);
             if (obj != null) {
@@ -110,12 +124,14 @@ public void run() {
           }
         } else {
           // new request
-          pool.execute(new RemoteTask(rq));
+          if (rq.action == Action.AUTH) fw.authenticate(conn, rq.creds);
+          else if (fw.permit(rq)) pool.execute(new RemoteTask(rq));
         }
       } catch(Exception ex) {
         log.warning("Bad JSON request: "+ex.toString() + " in " + s);
       }
     }
+    fw.authenticate(conn, null);
     close();
     pool.shutdown();
   }
@@ -213,6 +229,7 @@ boolean isClosed() {
   }
 
   boolean wantsMessagesFor(AgentID aid) {
+    if (!fw.permit(aid)) return false;
     synchronized(watchList) {
       if (watchList.isEmpty()) return true;
       return watchList.contains(aid);
diff --git a/src/main/java/org/arl/fjage/remote/Gateway.java b/src/main/java/org/arl/fjage/remote/Gateway.java
index 43846a34..291ff9fe 100644
--- a/src/main/java/org/arl/fjage/remote/Gateway.java
+++ b/src/main/java/org/arl/fjage/remote/Gateway.java
@@ -159,6 +159,16 @@ public Platform getPlatform() {
     return container.getPlatform();
   }
 
+  /**
+   * Authenticate to server.
+   *
+   * @param creds credentials to authenticate with.
+   */
+  public void authenticate(String creds) {
+    if (container != null && container instanceof SlaveContainer)
+      ((SlaveContainer)container).authenticate(creds);
+  }
+
   /**
    * Gets the agent ID associated with the gateway.
    *
diff --git a/src/main/java/org/arl/fjage/remote/JsonMessage.java b/src/main/java/org/arl/fjage/remote/JsonMessage.java
index 2bea55fd..10fba29d 100644
--- a/src/main/java/org/arl/fjage/remote/JsonMessage.java
+++ b/src/main/java/org/arl/fjage/remote/JsonMessage.java
@@ -31,6 +31,7 @@ public class JsonMessage {
   public Boolean answer;
   public Message message;
   public Boolean relay;
+  public String creds;
 
   private static GsonBuilder gsonBuilder = new GsonBuilder()
     .setFieldNamingPolicy(FieldNamingPolicy.IDENTITY)
diff --git a/src/main/java/org/arl/fjage/remote/MasterContainer.java b/src/main/java/org/arl/fjage/remote/MasterContainer.java
index 22f37de6..5a5e5e73 100644
--- a/src/main/java/org/arl/fjage/remote/MasterContainer.java
+++ b/src/main/java/org/arl/fjage/remote/MasterContainer.java
@@ -14,6 +14,7 @@
 import java.io.IOException;
 import org.arl.fjage.*;
 import org.arl.fjage.connectors.*;
+import org.arl.fjage.auth.*;
 
 /**
  * Master container supporting multiple remote slave containers. Agents in linked
@@ -32,6 +33,7 @@ public class MasterContainer extends RemoteContainer implements ConnectionListen
   private TcpServer listener = null;
   private List<ConnectionHandler> slaves = new ArrayList<ConnectionHandler>();
   private boolean needsCleanup = false;
+  private Firewall fw = new AllowAll();
 
   ////////////// Constructors
 
@@ -46,99 +48,93 @@ public MasterContainer(Platform platform) {
   }
 
   /**
-   * Creates a master container, runs its TCP server on a specified port.
+   * Creates a master container, runs its TCP server on an automatically selected port,
+   * with a specified firewall.
    *
    * @param platform platform on which the container runs.
-   * @param port port on which the container's TCP server runs.
+   * @param fw firewall to use.
    */
-  public MasterContainer(Platform platform, int port) {
+  public MasterContainer(Platform platform, Firewall fw) {
     super(platform);
-    openTcpServer(port);
+    this.fw = fw;
+    openTcpServer(0);
   }
 
   /**
-   * Creates a named master container, runs its TCP server on an automatically selected port.
+   * Creates a master container, runs its TCP server on a specified port.
    *
    * @param platform platform on which the container runs.
-   * @param name name of the container.
+   * @param port port on which the container's TCP server runs.
    */
-  public MasterContainer(Platform platform, String name) {
-    super(platform, name);
-    openTcpServer(0);
+  public MasterContainer(Platform platform, int port) {
+    super(platform);
+    openTcpServer(port);
   }
 
   /**
-   * Creates a named master container, runs its TCP server on a specified port.
+   * Creates a master container, runs its TCP server on a specified port,
+   * with a specified firewall.
    *
    * @param platform platform on which the container runs.
-   * @param name of the container.
    * @param port port on which the container's TCP server runs.
+   * @param fw firewall to use.
    */
-  public MasterContainer(Platform platform, String name, int port) {
-    super(platform, name);
+  public MasterContainer(Platform platform, int port, Firewall fw) {
+    super(platform);
+    this.fw = fw;
     openTcpServer(port);
   }
 
   /**
-   * Creates a master container running a RS232 server.
+   * Creates a named master container, runs its TCP server on an automatically selected port.
    *
    * @param platform platform on which the container runs.
-   * @param devname device name of the RS232 port.
-   * @param baud baud rate for the RS232 port.
-   * @param settings RS232 settings (null for defaults, or "N81" for no parity, 8 bits, 1 stop bit).
+   * @param name name of the container.
    */
-  @Deprecated
-  public MasterContainer(Platform platform, String devname, int baud, String settings) throws IOException {
-    super(platform);
-    addConnector(new SerialPortConnector(devname, baud, settings));
+  public MasterContainer(Platform platform, String name) {
+    super(platform, name);
+    openTcpServer(0);
   }
 
   /**
-   * Creates a named master container running a RS232 server.
+   * Creates a named master container, runs its TCP server on an automatically selected port,
+   * with a specified firewall.
    *
    * @param platform platform on which the container runs.
-   * @param name of the container.
-   * @param devname device name of the RS232 port.
-   * @param baud baud rate for the RS232 port.
-   * @param settings RS232 settings (null for defaults, or "N81" for no parity, 8 bits, 1 stop bit).
+   * @param name name of the container.
+   * @param fw firewall to use.
    */
-  @Deprecated
-  public MasterContainer(Platform platform, String name, String devname, int baud, String settings) throws IOException {
-    super(platform);
-    addConnector(new SerialPortConnector(devname, baud, settings));
+  public MasterContainer(Platform platform, String name, Firewall fw) {
+    super(platform, name);
+    this.fw = fw;
+    openTcpServer(0);
   }
 
   /**
-   * Creates a master container running a RS232 server and a TCP server.
+   * Creates a named master container, runs its TCP server on a specified port.
    *
    * @param platform platform on which the container runs.
-   * @param port port on which the container's TCP server runs (0 to select port automatically).
-   * @param devname device name of the RS232 port.
-   * @param baud baud rate for the RS232 port.
-   * @param settings RS232 settings (null for defaults, or "N81" for no parity, 8 bits, 1 stop bit).
+   * @param name of the container.
+   * @param port port on which the container's TCP server runs.
    */
-  @Deprecated
-  public MasterContainer(Platform platform, int port, String devname, int baud, String settings) throws IOException {
-    super(platform);
+  public MasterContainer(Platform platform, String name, int port) {
+    super(platform, name);
     openTcpServer(port);
-    addConnector(new SerialPortConnector(devname, baud, settings));
   }
 
   /**
-   * Creates a named master container running a RS232 server and a TCP server.
+   * Creates a named master container, runs its TCP server on a specified port,
+   * with a specified firewall.
    *
    * @param platform platform on which the container runs.
    * @param name of the container.
-   * @param port port on which the container's TCP server runs (0 to select port automatically).
-   * @param devname device name of the RS232 port.
-   * @param baud baud rate for the RS232 port.
-   * @param settings RS232 settings (null for defaults, or "N81" for no parity, 8 bits, 1 stop bit).
+   * @param port port on which the container's TCP server runs.
+   * @param fw firewall to use.
    */
-  @Deprecated
-  public MasterContainer(Platform platform, String name, int port, String devname, int baud, String settings) throws IOException {
-    super(platform);
+  public MasterContainer(Platform platform, String name, int port, Firewall fw) {
+    super(platform, name);
+    this.fw = fw;
     openTcpServer(port);
-    addConnector(new SerialPortConnector(devname, baud, settings));
   }
 
   /**
@@ -152,13 +148,30 @@ public int getPort() {
   }
 
   /**
-   * Adds a connector over which the master container listens.
+   * Adds a connector over which the master container listens. The connector uses
+   * any default firewall configured for the container.
    *
    * @param conn connector.
    */
   public void addConnector(Connector conn) {
     log.info("Listening on "+conn.getName());
-    ConnectionHandler t = new ConnectionHandler(conn, MasterContainer.this);
+    ConnectionHandler t = new ConnectionHandler(conn, MasterContainer.this, fw);
+    synchronized(slaves) {
+      slaves.add(t);
+    }
+    t.start();
+  }
+
+  /**
+   * Adds a connector over which the master container listens, but with a custom
+   * firewall for that connector.
+   *
+   * @param conn connector.
+   * @param fw firewall.
+   */
+  public void addConnector(Connector conn, Firewall fw) {
+    log.info("Listening on "+conn.getName());
+    ConnectionHandler t = new ConnectionHandler(conn, MasterContainer.this, fw);
     synchronized(slaves) {
       slaves.add(t);
     }
@@ -364,7 +377,7 @@ public void connectionClosed(ConnectionHandler handler) {
   @Override
   public void connected(Connector conn) {
     log.info("Incoming connection "+conn.toString());
-    ConnectionHandler t = new ConnectionHandler(conn, MasterContainer.this);
+    ConnectionHandler t = new ConnectionHandler(conn, MasterContainer.this, fw);
     synchronized(slaves) {
       slaves.add(t);
     }
diff --git a/src/main/java/org/arl/fjage/remote/SlaveContainer.java b/src/main/java/org/arl/fjage/remote/SlaveContainer.java
index af7ce1a6..310c1d94 100644
--- a/src/main/java/org/arl/fjage/remote/SlaveContainer.java
+++ b/src/main/java/org/arl/fjage/remote/SlaveContainer.java
@@ -106,6 +106,23 @@ public SlaveContainer(Platform platform, String name, String devname, int baud,
     connectToMaster();
   }
 
+  /////////////// slave-specific methods
+
+  /**
+   * Authenticate to master container.
+   *
+   * @param creds credentials to authenticate with.
+   */
+  public void authenticate(String creds) {
+    if (master == null) return;
+    JsonMessage rq = new JsonMessage();
+    rq.action = Action.AUTH;
+    rq.creds = creds;
+    rq.id = UUID.randomUUID().toString();
+    String json = rq.toJson();
+    master.println(json);
+  }
+
   /////////////// Container interface methods to override
 
   @Override


Test patch:
diff --git a/src/test/java/org/arl/fjage/test/BasicTests.java b/src/test/java/org/arl/fjage/test/BasicTests.java
index d63e15bf..72c2f5a5 100644
--- a/src/test/java/org/arl/fjage/test/BasicTests.java
+++ b/src/test/java/org/arl/fjage/test/BasicTests.java
@@ -15,6 +15,7 @@
 import org.arl.fjage.remote.MasterContainer;
 import org.arl.fjage.remote.SlaveContainer;
 import org.arl.fjage.persistence.Store;
+import org.arl.fjage.auth.*;
 import org.arl.fjage.shell.*;
 import org.arl.fjage.param.*;
 import org.junit.Before;
@@ -180,6 +181,87 @@ public void testGateway() throws IOException {
     platform.shutdown();
   }
 
+  @Test
+  public void testFirewall1() throws IOException {
+    log.info("testFirewall1");
+    Platform platform = new RealTimePlatform();
+    MasterContainer master = new MasterContainer(platform, new AllowAll());
+    ServerAgent server = new ServerAgent();
+    master.add("S", server);
+    platform.start();
+    Gateway gw = new Gateway("localhost", master.getPort());
+    AgentID s = gw.agentForService("server");
+    assertNotNull(s);
+    Message req = new RequestMessage(server.getAgentID());
+    Message rsp = gw.request(req, 1000);
+    assertNotNull(rsp);
+    gw.close();
+    platform.shutdown();
+    assertEquals(1, server.requests);
+  }
+
+  @Test
+  public void testFirewall2() throws IOException {
+    log.info("testFirewall2");
+    Platform platform = new RealTimePlatform();
+    MasterContainer master = new MasterContainer(platform, new DenyAll());
+    ServerAgent server = new ServerAgent();
+    master.add("S", server);
+    platform.start();
+    Gateway gw = new Gateway("localhost", master.getPort());
+    AgentID s = gw.agentForService("server");
+    assertNull(s);
+    Message req = new RequestMessage(server.getAgentID());
+    Message rsp = gw.request(req, 1000);
+    assertNull(rsp);
+    gw.close();
+    platform.shutdown();
+    assertEquals(0, server.requests);
+  }
+
+  @Test
+  public void testFirewall3() throws IOException {
+    log.info("testFirewall3");
+    Platform platform = new RealTimePlatform();
+    MasterContainer master = new MasterContainer(platform, new AllowAfterAuth());
+    ServerAgent server = new ServerAgent();
+    master.add("S", server);
+    platform.start();
+    Gateway gw = new Gateway("localhost", master.getPort());
+    AgentID s = gw.agentForService("server");
+    assertNull(s);
+    gw.authenticate("somecreds");
+    s = gw.agentForService("server");
+    assertNotNull(s);
+    Message req = new RequestMessage(server.getAgentID());
+    Message rsp = gw.request(req, 1000);
+    assertNotNull(rsp);
+    gw.close();
+    platform.shutdown();
+    assertEquals(1, server.requests);
+  }
+
+  @Test
+  public void testFirewall4() throws IOException {
+    log.info("testFirewall4");
+    Platform platform = new RealTimePlatform();
+    MasterContainer master = new MasterContainer(platform, new AllowAfterAuth());
+    platform.start();
+    Gateway gw = new Gateway("localhost", master.getPort());
+    gw.subscribe(new AgentID("test1", true));
+    master.send(new NuisanceMessage(new AgentID("test1", true)));
+    Message msg = gw.receive(1000);
+    assertNull(msg);
+    gw.authenticate("somecreds");
+    gw.subscribe(new AgentID("test1", true));
+    master.send(new NuisanceMessage(new AgentID("test1", true)));
+    msg = gw.receive(1000);
+    assertNotNull(msg);
+    assertSame(msg.getClass(), NuisanceMessage.class);
+    gw.close();
+    platform.shutdown();
+  }
+
   @Test
   public void testFSM() {
     log.info("testFSM");