diff --git a/datapath/linux/compat/include/linux/openvswitch.h b/datapath/linux/compat/include/linux/openvswitch.h
index 99a1624..9d37075 100644
--- a/datapath/linux/compat/include/linux/openvswitch.h
+++ b/datapath/linux/compat/include/linux/openvswitch.h
@@ -671,6 +671,7 @@ struct ovs_action_push_tnl {
  * @OVS_CT_ATTR_LABEL: %OVS_CT_LABEL_LEN value followed by %OVS_CT_LABEL_LEN
  * mask. For each bit set in the mask, the corresponding bit in the value is
  * copied to the connection tracking label field in the connection.
+ * @OVS_CT_ATTR_HELPER: variable length string defining conntrack ALG.
  */
 enum ovs_ct_attr {
 	OVS_CT_ATTR_UNSPEC,
@@ -678,6 +679,8 @@ enum ovs_ct_attr {
 	OVS_CT_ATTR_ZONE,       /* u16 zone id. */
 	OVS_CT_ATTR_MARK,       /* mark to associate with this connection. */
 	OVS_CT_ATTR_LABEL,      /* label to associate with this connection. */
+	OVS_CT_ATTR_HELPER,     /* netlink helper to assist detection of
+				   related connections. */
 	__OVS_CT_ATTR_MAX
 };
 
diff --git a/include/sparse/netinet/in.h b/include/sparse/netinet/in.h
index f66f205..1223553 100644
--- a/include/sparse/netinet/in.h
+++ b/include/sparse/netinet/in.h
@@ -74,6 +74,8 @@ struct sockaddr_in6 {
 #define IPPROTO_DSTOPTS 60
 #define IPPROTO_SCTP 132
 
+#define IPPORT_FTP 21
+
 /* All the IP options documented in Linux ip(7). */
 #define IP_ADD_MEMBERSHIP 0
 #define IP_DROP_MEMBERSHIP 1
diff --git a/include/windows/netinet/in.h b/include/windows/netinet/in.h
index 7143cf5..e416999 100644
--- a/include/windows/netinet/in.h
+++ b/include/windows/netinet/in.h
@@ -18,5 +18,6 @@
 #define __NETINET_IN_H 1
 
 #define IPPROTO_GRE 47
+#define IPPORT_FTP 21
 
 #endif /* netinet/in.h */
diff --git a/lib/netlink.c b/lib/netlink.c
index 09723b2..66b4927 100644
--- a/lib/netlink.c
+++ b/lib/netlink.c
@@ -316,6 +316,17 @@ nl_msg_put_odp_port(struct ofpbuf *msg, uint16_t type, odp_port_t value)
     nl_msg_put_u32(msg, type, odp_to_u32(value));
 }
 
+/* Appends a Netlink attribute of the given 'type' with the 'len' characters
+ * of 'value', followed by the null byte to 'msg'. */
+void
+nl_msg_put_string__(struct ofpbuf *msg, uint16_t type, const char *value,
+                    size_t len)
+{
+    char *data = nl_msg_put_unspec_uninit(msg, type, len + 1);
+
+    memcpy(data, value, len);
+    data[len] = '\0';
+}
 
 /* Appends a Netlink attribute of the given 'type' and the given
  * null-terminated string 'value' to 'msg'. */
diff --git a/lib/netlink.h b/lib/netlink.h
index 6068f5d..210cab5 100644
--- a/lib/netlink.h
+++ b/lib/netlink.h
@@ -70,6 +70,8 @@ void nl_msg_put_be16(struct ofpbuf *, uint16_t type, ovs_be16 value);
 void nl_msg_put_be32(struct ofpbuf *, uint16_t type, ovs_be32 value);
 void nl_msg_put_be64(struct ofpbuf *, uint16_t type, ovs_be64 value);
 void nl_msg_put_odp_port(struct ofpbuf *, uint16_t type, odp_port_t value);
+void nl_msg_put_string__(struct ofpbuf *, uint16_t type, const char *value,
+                         size_t len);
 void nl_msg_put_string(struct ofpbuf *, uint16_t type, const char *value);
 
 size_t nl_msg_start_nested(struct ofpbuf *, uint16_t type);
diff --git a/lib/odp-util.c b/lib/odp-util.c
index 7f119b5..1515d32 100644
--- a/lib/odp-util.c
+++ b/lib/odp-util.c
@@ -52,6 +52,7 @@ VLOG_DEFINE_THIS_MODULE(odp_util);
 /* The set of characters that may separate one action or one key attribute
  * from another. */
 static const char *delimiters = ", \t\r\n";
+static const char *delimiters_end = ", \t\r\n)";
 
 struct attr_len_tbl {
     int len;
@@ -548,6 +549,8 @@ static const struct nl_policy ovs_conntrack_policy[] = {
                            .min_len = sizeof(uint32_t) * 2 },
     [OVS_CT_ATTR_LABEL] = { .type = NL_A_UNSPEC, .optional = true,
                             .min_len = sizeof(ovs_u128) * 2 },
+    [OVS_CT_ATTR_HELPER] = { .type = NL_A_STRING, .optional = true,
+                             .min_len = 1, .max_len = 16 },
 };
 
 static void
@@ -556,6 +559,7 @@ format_odp_conntrack_action(struct ds *ds, const struct nlattr *attr)
     struct nlattr *a[ARRAY_SIZE(ovs_conntrack_policy)];
     const ovs_u128 *label;
     const uint32_t *mark;
+    const char *helper;
     uint16_t zone;
     bool commit;
 
@@ -568,9 +572,10 @@ format_odp_conntrack_action(struct ds *ds, const struct nlattr *attr)
     zone = a[OVS_CT_ATTR_ZONE] ? nl_attr_get_u16(a[OVS_CT_ATTR_ZONE]) : 0;
     mark = a[OVS_CT_ATTR_MARK] ? nl_attr_get(a[OVS_CT_ATTR_MARK]) : NULL;
     label = a[OVS_CT_ATTR_LABEL] ? nl_attr_get(a[OVS_CT_ATTR_LABEL]): NULL;
+    helper = a[OVS_CT_ATTR_HELPER] ? nl_attr_get(a[OVS_CT_ATTR_HELPER]) : NULL;
 
     ds_put_format(ds, "ct");
-    if (commit || zone || mark || label) {
+    if (commit || zone || mark || label || helper) {
         ds_put_cstr(ds, "(");
         if (commit) {
             ds_put_format(ds, "commit,");
@@ -586,6 +591,9 @@ format_odp_conntrack_action(struct ds *ds, const struct nlattr *attr)
             ds_put_format(ds, "label=");
             format_u128(ds, label, label + 1, true);
         }
+        if (helper) {
+            ds_put_format(ds, "helper=%s,", helper);
+        }
         ds_chomp(ds, ',');
         ds_put_cstr(ds, ")");
     }
@@ -1027,6 +1035,8 @@ parse_conntrack_action(const char *s_, struct ofpbuf *actions)
     const char *s = s_;
 
     if (ovs_scan(s, "ct")) {
+        const char *helper = NULL;
+        size_t helper_len = 0;
         bool commit = false;
         uint16_t zone = 0;
         struct {
@@ -1084,6 +1094,16 @@ parse_conntrack_action(const char *s_, struct ofpbuf *actions)
                     s += retval;
                     continue;
                 }
+                if (ovs_scan(s, "helper=%n", &n)) {
+                    s += n;
+                    helper_len = strcspn(s, delimiters_end);
+                    if (!helper_len || helper_len > 15) {
+                        return -EINVAL;
+                    }
+                    helper = s;
+                    s += helper_len;
+                    continue;
+                }
 
                 return -EINVAL;
             }
@@ -1105,6 +1125,10 @@ parse_conntrack_action(const char *s_, struct ofpbuf *actions)
             nl_msg_put_unspec(actions, OVS_CT_ATTR_LABEL, &ct_label,
                               sizeof(ct_label));
         }
+        if (helper) {
+            nl_msg_put_string__(actions, OVS_CT_ATTR_HELPER, helper,
+                                helper_len);
+        }
         nl_msg_end_nested(actions, start);
     }
 
diff --git a/lib/ofp-actions.c b/lib/ofp-actions.c
index c2b37e8..cc3b961 100644
--- a/lib/ofp-actions.c
+++ b/lib/ofp-actions.c
@@ -15,6 +15,8 @@
  */
 
 #include <config.h>
+#include <netinet/in.h>
+
 #include "ofp-actions.h"
 #include "bundle.h"
 #include "byte-order.h"
@@ -4680,7 +4682,9 @@ struct nx_action_conntrack {
     };
     uint8_t recirc_table;       /* Recirculate to a specific table, or
                                    NX_CT_RECIRC_NONE for no recirculation. */
-    uint8_t pad[5];             /* Zeroes */
+    uint8_t pad[3];             /* Zeroes */
+    ovs_be16 alg;               /* Well-known port number for the protocol.
+                                 * 0 indicates no ALG is required. */
     /* Followed by a sequence of ofpact elements, until the end of the action
      * is reached. */
 };
@@ -4730,6 +4734,7 @@ decode_NXAST_RAW_CT(const struct nx_action_conntrack *nac,
         goto out;
     }
     conntrack->recirc_table = nac->recirc_table;
+    conntrack->alg = ntohs(nac->alg);
 
     ofpbuf_pull(out, sizeof(*conntrack));
 
@@ -4777,6 +4782,7 @@ encode_CT(const struct ofpact_conntrack *conntrack,
         nac->zone_imm = htons(conntrack->zone_imm);
     }
     nac->recirc_table = conntrack->recirc_table;
+    nac->alg = htons(conntrack->alg);
 
     len = ofpacts_put_openflow_actions(conntrack->actions,
                                        ofpact_ct_get_action_len(conntrack),
@@ -4821,6 +4827,8 @@ parse_CT(char *arg, struct ofpbuf *ofpacts,
                     return error;
                 }
             }
+        } else if (!strcmp(key, "alg")) {
+            error = str_to_connhelper(value, &oc->alg);
         } else if (!strcmp(key, "exec")) {
             /* Hide existing actions from ofpacts_parse_copy(), so the
              * nesting can be handled transparently. */
@@ -4844,6 +4852,16 @@ parse_CT(char *arg, struct ofpbuf *ofpacts,
 }
 
 static void
+format_alg(int port, struct ds *s)
+{
+    if (port == IPPORT_FTP) {
+        ds_put_format(s, "alg=ftp,");
+    } else if (port) {
+        ds_put_format(s, "alg=%d,", port);
+    }
+}
+
+static void
 format_CT(const struct ofpact_conntrack *a, struct ds *s)
 {
     ds_put_cstr(s, "ct(");
@@ -4865,6 +4883,7 @@ format_CT(const struct ofpact_conntrack *a, struct ds *s)
         ofpacts_format(a->actions, ofpact_ct_get_action_len(a), s);
         ds_put_format(s, "),");
     }
+    format_alg(a->alg, s);
     ds_chomp(s, ',');
     ds_put_char(s, ')');
 }
diff --git a/lib/ofp-actions.h b/lib/ofp-actions.h
index a0e270c..1311a55 100644
--- a/lib/ofp-actions.h
+++ b/lib/ofp-actions.h
@@ -496,6 +496,7 @@ struct {                                \
     uint16_t flags;                     \
     uint16_t zone_imm;                  \
     struct mf_subfield zone_src;        \
+    uint16_t alg;                       \
     uint8_t recirc_table;               \
 }
 
diff --git a/lib/ofp-parse.c b/lib/ofp-parse.c
index 5950f06..8437656 100644
--- a/lib/ofp-parse.c
+++ b/lib/ofp-parse.c
@@ -21,6 +21,7 @@
 #include <ctype.h>
 #include <errno.h>
 #include <stdlib.h>
+#include <netinet/in.h>
 
 #include "byte-order.h"
 #include "dynamic-string.h"
@@ -168,6 +169,20 @@ str_to_ip(const char *str, ovs_be32 *ip)
     return NULL;
 }
 
+/* Parses 'str' as a conntrack helper into 'alg'.
+ *
+ * Returns NULL if successful, otherwise a malloc()'d string describing the
+ * error.  The caller is responsible for freeing the returned string. */
+char * OVS_WARN_UNUSED_RESULT
+str_to_connhelper(const char *str, uint16_t *alg)
+{
+    if (!strcmp(str, "ftp")) {
+        *alg = IPPORT_FTP;
+        return NULL;
+    }
+    return xasprintf("invalid conntrack helper \"%s\"", str);
+}
+
 struct protocol {
     const char *name;
     uint16_t dl_type;
diff --git a/lib/ofp-parse.h b/lib/ofp-parse.h
index b64a32e..36f9acc 100644
--- a/lib/ofp-parse.h
+++ b/lib/ofp-parse.h
@@ -99,5 +99,6 @@ char *str_to_u64(const char *str, uint64_t *valuep) OVS_WARN_UNUSED_RESULT;
 char *str_to_be64(const char *str, ovs_be64 *valuep) OVS_WARN_UNUSED_RESULT;
 char *str_to_mac(const char *str, struct eth_addr *mac) OVS_WARN_UNUSED_RESULT;
 char *str_to_ip(const char *str, ovs_be32 *ip) OVS_WARN_UNUSED_RESULT;
+char *str_to_connhelper(const char *str, uint16_t *alg) OVS_WARN_UNUSED_RESULT;
 
 #endif /* ofp-parse.h */
diff --git a/ofproto/ofproto-dpif-xlate.c b/ofproto/ofproto-dpif-xlate.c
index 16e0550..d51bf99 100644
--- a/ofproto/ofproto-dpif-xlate.c
+++ b/ofproto/ofproto-dpif-xlate.c
@@ -4184,6 +4184,18 @@ put_ct_label(const struct flow *flow, struct flow *base_flow,
 }
 
 static void
+put_ct_helper(struct ofpbuf *odp_actions, struct ofpact_conntrack *ofc)
+{
+    if (ofc->alg) {
+        if (ofc->alg == IPPORT_FTP) {
+            nl_msg_put_string(odp_actions, OVS_CT_ATTR_HELPER, "ftp");
+        } else {
+            VLOG_WARN("Cannot serialize ct_helper %d\n", ofc->alg);
+        }
+    }
+}
+
+static void
 compose_conntrack_action(struct xlate_ctx *ctx, struct ofpact_conntrack *ofc)
 {
     ovs_u128 old_ct_label = ctx->base_flow.ct_label;
@@ -4211,6 +4223,7 @@ compose_conntrack_action(struct xlate_ctx *ctx, struct ofpact_conntrack *ofc)
     nl_msg_put_u16(ctx->odp_actions, OVS_CT_ATTR_ZONE, zone);
     put_ct_mark(&ctx->xin->flow, &ctx->base_flow, ctx->odp_actions, ctx->wc);
     put_ct_label(&ctx->xin->flow, &ctx->base_flow, ctx->odp_actions, ctx->wc);
+    put_ct_helper(ctx->odp_actions, ofc);
     nl_msg_end_nested(ctx->odp_actions, ct_offset);
 
     /* Restore the original ct fields in the key. These should only be exposed
diff --git a/tests/atlocal.in b/tests/atlocal.in
index 8e9fd9b..095bc40 100644
--- a/tests/atlocal.in
+++ b/tests/atlocal.in
@@ -117,3 +117,10 @@ if test x`which conntrack` != x; then
 else
     HAVE_CONNTRACK="no"
 fi
+
+if test "$HAVE_PYTHON" = "yes" \
+   && test "x`$PYTHON $abs_top_srcdir/tests/test-l7.py --help | grep 'ftp'`" != x; then
+    HAVE_PYFTPDLIB="yes"
+else
+    HAVE_PYFTPDLIB="no"
+fi
diff --git a/tests/odp.at b/tests/odp.at
index c09d24c..fe61886 100644
--- a/tests/odp.at
+++ b/tests/odp.at
@@ -311,6 +311,7 @@ ct(commit)
 ct(commit,zone=5)
 ct(commit,mark=0xa0a0a0a0/0xfefefefe)
 ct(commit,label=0x1234567890abcdef1234567890abcdef/0xf1f2f3f4f5f6f7f8f9f0fafbfcfdfeff)
+ct(commit,helper=ftp)
 ])
 AT_CHECK_UNQUOTED([ovstest test-odp parse-actions < actions.txt], [0],
   [`cat actions.txt`
diff --git a/tests/ofp-actions.at b/tests/ofp-actions.at
index fd48a8b..01e5b67 100644
--- a/tests/ofp-actions.at
+++ b/tests/ofp-actions.at
@@ -184,6 +184,9 @@ ffff 0018 00002320 0023 0000 00000000 0000 FF 000000 0000
 ffff 0030 00002320 0023 0001 00000000 0000 FF 000000 0000 dnl
 ffff 0018 00002320 0007 001f 00010004 000000000000f009
 
+# actions=ct(alg=ftp)
+ffff 0018 00002320 0023 0000 00000000 0000 FF 000000 0015
+
 ])
 sed '/^[[#&]]/d' < test-data > input.txt
 sed -n 's/^# //p; /^$/p' < test-data > expout
diff --git a/tests/system-traffic.at b/tests/system-traffic.at
index 71440cc..f6d0bdd 100644
--- a/tests/system-traffic.at
+++ b/tests/system-traffic.at
@@ -782,3 +782,148 @@ icmp,vlan_tci=0x0000,dl_src=c6:f9:4e:cb:72:db,dl_dst=e6:4c:47:35:28:c9,nw_src=17
 
 OVS_TRAFFIC_VSWITCHD_STOP
 AT_CLEANUP
+
+AT_SETUP([conntrack - FTP])
+AT_SKIP_IF([test $HAVE_PYFTPDLIB = no])
+CHECK_CONNTRACK()
+OVS_TRAFFIC_VSWITCHD_START(
+   [set-fail-mode br0 standalone -- ])
+
+ADD_NAMESPACES(at_ns0, at_ns1)
+
+ADD_VETH(p0, at_ns0, br0, "10.1.1.1/24")
+ADD_VETH(p1, at_ns1, br0, "10.1.1.2/24")
+
+dnl Allow any traffic from ns0->ns1. Only allow nd, return traffic from ns1->ns0.
+AT_DATA([flows1.txt], [dnl
+priority=1,action=drop
+priority=10,arp,action=normal
+priority=10,icmp,action=normal
+priority=100,in_port=1,tcp,action=ct(alg=ftp,commit),2
+priority=100,in_port=2,tcp,ct_state=-trk,action=ct(table=0)
+priority=100,in_port=2,tcp,ct_state=+trk+est,action=1
+priority=100,in_port=2,tcp,ct_state=+trk+rel,action=1
+])
+
+dnl Similar policy but without allowing all traffic from ns0->ns1.
+AT_DATA([flows2.txt], [dnl
+priority=1,action=drop
+priority=10,arp,action=normal
+priority=10,icmp,action=normal
+priority=100,in_port=1,tcp,ct_state=-trk,action=ct(table=0)
+priority=100,in_port=1,tcp,ct_state=+trk+new,action=ct(commit,alg=ftp),2
+priority=100,in_port=1,tcp,ct_state=+trk+est,action=2
+priority=100,in_port=2,tcp,ct_state=-trk,action=ct(table=0)
+priority=100,in_port=2,tcp,ct_state=+trk+new+rel,action=ct(commit),1
+priority=100,in_port=2,tcp,ct_state=+trk+est,action=1
+priority=100,in_port=2,tcp,ct_state=+trk-new+rel,action=1
+])
+
+AT_CHECK([ovs-ofctl add-flows br0 flows1.txt])
+
+NETNS_DAEMONIZE([at_ns0], [[$PYTHON $srcdir/test-l7.py ftp]], [ftp1.pid])
+NETNS_DAEMONIZE([at_ns1], [[$PYTHON $srcdir/test-l7.py ftp]], [ftp0.pid])
+
+dnl FTP requests from p1->p0 should fail due to network failure.
+dnl Try 3 times, in 1 second intervals.
+NS_CHECK_EXEC([at_ns1], [wget ftp://10.1.1.1 --no-passive-ftp  -t 3 -T 1 -v -o wget1.log], [4])
+AT_CHECK([conntrack -L 2>&1 | FORMAT_CT(10.1.1.1)], [0], [dnl
+])
+
+dnl FTP requests from p0->p1 should work fine.
+NS_CHECK_EXEC([at_ns0], [wget ftp://10.1.1.2 --no-passive-ftp -t 3 -T 1 --retry-connrefused -v -o wget0.log])
+AT_CHECK([conntrack -L 2>&1 | FORMAT_CT(10.1.1.2) | grep -v "FIN"], [0], [dnl
+TIME_WAIT src=10.1.1.1 dst=10.1.1.2 sport=<cleared> dport=<cleared> src=10.1.1.2 dst=10.1.1.1 sport=<cleared> dport=<cleared> [[ASSURED]] mark=0 helper=ftp use=1
+])
+
+dnl Try the second set of flows.
+conntrack -F
+AT_CHECK([ovs-ofctl del-flows br0])
+AT_CHECK([ovs-ofctl add-flows br0 flows2.txt])
+
+dnl FTP requests from p1->p0 should fail due to network failure.
+dnl Try 3 times, in 1 second intervals.
+NS_CHECK_EXEC([at_ns1], [wget ftp://10.1.1.1 --no-passive-ftp  -t 3 -T 1 -v -o wget1.log], [4])
+AT_CHECK([conntrack -L 2>&1 | FORMAT_CT(10.1.1.1)], [0], [dnl
+])
+
+dnl Active FTP requests from p0->p1 should work fine.
+NS_CHECK_EXEC([at_ns0], [wget ftp://10.1.1.2 --no-passive-ftp -t 3 -T 1 --retry-connrefused -v -o wget0.log])
+AT_CHECK([conntrack -L 2>&1 | FORMAT_CT(10.1.1.2) | grep -v "FIN"], [0], [dnl
+TIME_WAIT src=10.1.1.1 dst=10.1.1.2 sport=<cleared> dport=<cleared> src=10.1.1.2 dst=10.1.1.1 sport=<cleared> dport=<cleared> [[ASSURED]] mark=0 helper=ftp use=2
+TIME_WAIT src=10.1.1.2 dst=10.1.1.1 sport=<cleared> dport=<cleared> src=10.1.1.1 dst=10.1.1.2 sport=<cleared> dport=<cleared> [[ASSURED]] mark=0 use=1
+])
+
+AT_CHECK([conntrack -F 2>/dev/null])
+
+dnl Passive FTP requests from p0->p1 should work fine.
+NS_CHECK_EXEC([at_ns0], [wget ftp://10.1.1.2 -t 3 -T 1 --retry-connrefused -v -o wget0.log])
+AT_CHECK([conntrack -L 2>&1 | FORMAT_CT(10.1.1.2) | grep -v "FIN"], [0], [dnl
+TIME_WAIT src=10.1.1.1 dst=10.1.1.2 sport=<cleared> dport=<cleared> src=10.1.1.2 dst=10.1.1.1 sport=<cleared> dport=<cleared> [[ASSURED]] mark=0 helper=ftp use=2
+TIME_WAIT src=10.1.1.1 dst=10.1.1.2 sport=<cleared> dport=<cleared> src=10.1.1.2 dst=10.1.1.1 sport=<cleared> dport=<cleared> [[ASSURED]] mark=0 use=1
+])
+
+OVS_TRAFFIC_VSWITCHD_STOP
+AT_CLEANUP
+
+AT_SETUP([conntrack - FTP with multiple expectations])
+AT_SKIP_IF([test $HAVE_PYFTPDLIB = no])
+CHECK_CONNTRACK()
+OVS_TRAFFIC_VSWITCHD_START(
+   [set-fail-mode br0 standalone -- ])
+
+ADD_NAMESPACES(at_ns0, at_ns1)
+
+ADD_VETH(p0, at_ns0, br0, "10.1.1.1/24")
+ADD_VETH(p1, at_ns1, br0, "10.1.1.2/24")
+
+dnl Dual-firewall, allow all from ns1->ns2, allow established and ftp ns2->ns1.
+AT_DATA([flows.txt], [dnl
+priority=1,action=drop
+priority=10,arp,action=normal
+priority=10,icmp,action=normal
+priority=100,in_port=1,tcp,ct_state=-trk,action=ct(table=0,zone=1)
+priority=100,in_port=1,tcp,ct_zone=1,ct_state=+trk+new,action=ct(commit,alg=ftp,zone=1),ct(commit,alg=ftp,zone=2),2
+priority=100,in_port=1,tcp,ct_zone=1,ct_state=+trk+est,action=ct(table=0,zone=2)
+priority=100,in_port=1,tcp,ct_zone=2,ct_state=+trk+new,action=ct(commit,alg=ftp,zone=2)
+priority=100,in_port=1,tcp,ct_zone=2,ct_state=+trk+est,action=2
+priority=100,in_port=2,tcp,ct_state=-trk,action=ct(table=0,zone=2)
+priority=100,in_port=2,tcp,ct_zone=2,ct_state=+trk+rel,action=ct(commit,zone=2),ct(commit,zone=1),1
+priority=100,in_port=2,tcp,ct_zone=2,ct_state=+trk+est,action=ct(table=0,zone=1)
+priority=100,in_port=2,tcp,ct_zone=1,ct_state=+trk+rel,action=ct(commit,zone=2),ct(commit,zone=1),1
+priority=100,in_port=2,tcp,ct_zone=1,ct_state=+trk+est,action=1
+])
+
+AT_CHECK([ovs-ofctl add-flows br0 flows.txt])
+
+NETNS_DAEMONIZE([at_ns0], [[$PYTHON $srcdir/test-l7.py ftp]], [ftp1.pid])
+NETNS_DAEMONIZE([at_ns1], [[$PYTHON $srcdir/test-l7.py ftp]], [ftp0.pid])
+
+dnl FTP requests from p1->p0 should fail due to network failure.
+dnl Try 3 times, in 1 second intervals.
+NS_CHECK_EXEC([at_ns1], [wget ftp://10.1.1.1 --no-passive-ftp  -t 3 -T 1 -v -o wget1.log], [4])
+AT_CHECK([conntrack -L 2>&1 | FORMAT_CT(10.1.1.1)], [0], [dnl
+])
+
+dnl Active FTP requests from p0->p1 should work fine.
+NS_CHECK_EXEC([at_ns0], [wget ftp://10.1.1.2 --no-passive-ftp -t 3 -T 1 --retry-connrefused -v -o wget0.log])
+AT_CHECK([conntrack -L 2>&1 | FORMAT_CT(10.1.1.2) | grep -v "FIN"], [0], [dnl
+TIME_WAIT src=10.1.1.1 dst=10.1.1.2 sport=<cleared> dport=<cleared> src=10.1.1.2 dst=10.1.1.1 sport=<cleared> dport=<cleared> [[ASSURED]] mark=0 zone=1 helper=ftp use=2
+TIME_WAIT src=10.1.1.1 dst=10.1.1.2 sport=<cleared> dport=<cleared> src=10.1.1.2 dst=10.1.1.1 sport=<cleared> dport=<cleared> [[ASSURED]] mark=0 zone=2 helper=ftp use=2
+TIME_WAIT src=10.1.1.2 dst=10.1.1.1 sport=<cleared> dport=<cleared> src=10.1.1.1 dst=10.1.1.2 sport=<cleared> dport=<cleared> [[ASSURED]] mark=0 zone=1 use=1
+TIME_WAIT src=10.1.1.2 dst=10.1.1.1 sport=<cleared> dport=<cleared> src=10.1.1.1 dst=10.1.1.2 sport=<cleared> dport=<cleared> [[ASSURED]] mark=0 zone=2 use=1
+])
+
+AT_CHECK([conntrack -F 2>/dev/null])
+
+dnl Passive FTP requests from p0->p1 should work fine.
+NS_CHECK_EXEC([at_ns0], [wget ftp://10.1.1.2 -t 3 -T 1 --retry-connrefused -v -o wget0.log])
+AT_CHECK([conntrack -L 2>&1 | FORMAT_CT(10.1.1.2) | grep -v "FIN"], [0], [dnl
+TIME_WAIT src=10.1.1.1 dst=10.1.1.2 sport=<cleared> dport=<cleared> src=10.1.1.2 dst=10.1.1.1 sport=<cleared> dport=<cleared> [[ASSURED]] mark=0 zone=1 helper=ftp use=2
+TIME_WAIT src=10.1.1.1 dst=10.1.1.2 sport=<cleared> dport=<cleared> src=10.1.1.2 dst=10.1.1.1 sport=<cleared> dport=<cleared> [[ASSURED]] mark=0 zone=1 use=1
+TIME_WAIT src=10.1.1.1 dst=10.1.1.2 sport=<cleared> dport=<cleared> src=10.1.1.2 dst=10.1.1.1 sport=<cleared> dport=<cleared> [[ASSURED]] mark=0 zone=2 helper=ftp use=2
+TIME_WAIT src=10.1.1.1 dst=10.1.1.2 sport=<cleared> dport=<cleared> src=10.1.1.2 dst=10.1.1.1 sport=<cleared> dport=<cleared> [[ASSURED]] mark=0 zone=2 use=1
+])
+
+OVS_TRAFFIC_VSWITCHD_STOP
+AT_CLEANUP
diff --git a/utilities/ovs-ofctl.8.in b/utilities/ovs-ofctl.8.in
index fcb47d8..a6087f6 100644
--- a/utilities/ovs-ofctl.8.in
+++ b/utilities/ovs-ofctl.8.in
@@ -1692,6 +1692,16 @@ connection tracker with the \fBtable\fR specified.
 .IP
 The \fBcommit\fR parameter must be specified to use \fBexec(...)\fR.
 .
+.IP \fBalg=\fIalg\fR
+Specify application layer gateway \fIalg\fR to track specific connection
+types. Supported types include:
+.RS
+.IP \fBftp\fR
+Look for negotiation of FTP data connections. If a subsequent FTP data
+connection arrives which is related, the \fBct\fR action will set the
+\fBrel\fR flag in the \fBct_state\fR field for packets sent through \fBct\fR.
+.RE
+.
 .RE
 .IP
 The \fBct\fR action may be used as a primitive to construct stateful firewalls
